on main help sign in
fix: validate tool call JSON before storing to prevent stuck conversations
21a457dKieran Klukas
internal/agent/agent.go+27−1
@@ -12,6 +12,7 @@ import (
12 "context"
12 "context"
13 _ "embed"
13 _ "embed"
14 "encoding/base64"
14 "encoding/base64"
15+ "encoding/json"
15 "errors"
16 "errors"
16 "fmt"
17 "fmt"
17 "log/slog"
18 "log/slog"
@@ -780,6 +781,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
780
781
781 var stepMessages []fantasy.Message
782 var stepMessages []fantasy.Message
782 var shouldSummarize bool
783 var shouldSummarize bool
784+ sanitizedToolCalls := make(map[string]bool)
783 // Don't send MaxOutputTokens if 0 — some providers (e.g. LM Studio) reject it
785 // Don't send MaxOutputTokens if 0 — some providers (e.g. LM Studio) reject it
784 var maxOutputTokens *int64
786 var maxOutputTokens *int64
785 if call.MaxOutputTokens > 0 {
787 if call.MaxOutputTokens > 0 {
@@ -923,10 +925,14 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
923 slog.Warn("Provider request failed, retrying", providerRetryLogFields(err, delay)...)
925 slog.Warn("Provider request failed, retrying", providerRetryLogFields(err, delay)...)
924 },
926 },
925 OnToolCall: func(tc fantasy.ToolCallContent) error {
927 OnToolCall: func(tc fantasy.ToolCallContent) error {
928+ input, wasSanitized := sanitizeToolInput(tc.ToolName, tc.ToolCallID, tc.Input)
929+ if wasSanitized {
930+ sanitizedToolCalls[tc.ToolCallID] = true
931+ }
926 toolCall := message.ToolCall{
932 toolCall := message.ToolCall{
927 ID: tc.ToolCallID,
933 ID: tc.ToolCallID,
928 Name: tc.ToolName,
934 Name: tc.ToolName,
929− Input: tc.Input,
935+ Input: input,
930 ProviderExecuted: false,
936 ProviderExecuted: false,
931 Finished: true,
937 Finished: true,
932 }
938 }
@@ -937,6 +943,10 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
937 },
943 },
938 OnToolResult: func(result fantasy.ToolResultContent) error {
944 OnToolResult: func(result fantasy.ToolResultContent) error {
939 toolResult := a.convertToToolResult(result)
945 toolResult := a.convertToToolResult(result)
946+ if sanitizedToolCalls[result.ToolCallID] {
947+ toolResult.Content = "Tool call failed: arguments were not valid JSON. Please check your tool call format and try again."
948+ toolResult.IsError = true
949+ }
940 // Use parent ctx instead of genCtx to ensure the message is created
950 // Use parent ctx instead of genCtx to ensure the message is created
941 // even if the request is canceled mid-stream
951 // even if the request is canceled mid-stream
942 _, createMsgErr := a.messages.Create(ctx, currentAssistant.SessionID, message.CreateMessageParams{
952 _, createMsgErr := a.messages.Create(ctx, currentAssistant.SessionID, message.CreateMessageParams{
@@ -2180,3 +2190,19 @@ func providerRetryLogFields(err *fantasy.ProviderError, delay time.Duration) []a
2180 }
2190 }
2181 return fields
2191 return fields
2182}
2192}
2193+
2194+// sanitizeToolInput validates tool call JSON from the provider.
2195+// Malformed input is replaced with an empty object to prevent
2196+// stuck conversations from truncated or malformed model output.
2197+// The second return value indicates whether sanitization occurred.
2198+func sanitizeToolInput(toolName, toolCallID, input string) (string, bool) {
2199+ if !json.Valid([]byte(input)) {
2200+ slog.Warn("Malformed tool call JSON from provider, replacing with empty object",
2201+ "tool", toolName,
2202+ "id", toolCallID,
2203+ "input_len", len(input),
2204+ )
2205+ return "{}", true
2206+ }
2207+ return input, false
2208+}