@@ -12,6 +12,7 @@ import (
14 "encoding/base64"
14 "encoding/base64"
17 "log/slog"
18 "log/slog"
@@ -780,6 +781,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
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)...)
925 OnToolCall: func(tc fantasy.ToolCallContent) error {
927 OnToolCall: func(tc fantasy.ToolCallContent) error {
928+ input, wasSanitized := sanitizeToolInput(tc.ToolName, tc.ToolCallID, tc.Input)
930+ sanitizedToolCalls[tc.ToolCallID] = true
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,
@@ -937,6 +943,10 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *
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
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
2181 return fields
2191 return fields
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",
2203+ "input_len", len(input),
2207+ return input, false