fix(anthropic): preserve `tool_use` when `ToolCallPart.Input` is empty or malformed (#219)

Lauri Jutila created

json.Unmarshal("", &m) errors and previously caused the tool_use to be
silently dropped in toPrompt, leaving the matching tool_result orphaned
in the next user message. The Anthropic API then rejects the request
with "tool_result must have a corresponding tool_use in the previous
message". Default empty/whitespace input to {} (and nil for the
server_tool_use branch); on malformed-but-non-empty input, emit {} +
a CallWarning rather than dropping the block.

Hit in the wild via DeepSeek's anthropic-compat endpoint, which emits
empty input for parameterless tool calls.

Change summary

providers/anthropic/anthropic.go      | 60 +++++++++++++++++++++++++---
providers/anthropic/anthropic_test.go | 57 ++++++++++++++++++++++++++-
2 files changed, 106 insertions(+), 11 deletions(-)

Detailed changes

providers/anthropic/anthropic.go 🔗

@@ -999,10 +999,9 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl
 						if toolCall.ProviderExecuted {
 							// Reconstruct server_tool_use block for
 							// multi-turn round-tripping.
-							var inputAny any
-							err := json.Unmarshal([]byte(toolCall.Input), &inputAny)
-							if err != nil {
-								continue
+							inputAny, warning := decodeToolCallInputAny(toolCall)
+							if warning != nil {
+								warnings = append(warnings, *warning)
 							}
 							anthropicContent = append(anthropicContent, anthropic.ContentBlockParamUnion{
 								OfServerToolUse: &anthropic.ServerToolUseBlockParam{
@@ -1013,10 +1012,9 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl
 							})
 							continue
 						}
-						var inputMap map[string]any
-						err := json.Unmarshal([]byte(toolCall.Input), &inputMap)
-						if err != nil {
-							continue
+						inputMap, warning := decodeToolCallInputMap(toolCall)
+						if warning != nil {
+							warnings = append(warnings, *warning)
 						}
 						toolUseBlock := anthropic.NewToolUseBlock(toolCall.ToolCallID, inputMap, toolCall.ToolName)
 						if cacheControl != nil {
@@ -1077,6 +1075,52 @@ func hasVisibleAssistantContent(content []anthropic.ContentBlockParamUnion) bool
 	return false
 }
 
+// decodeToolCallInputMap unmarshals a ToolCallPart.Input into a map for
+// reconstructing an Anthropic tool_use block. The Anthropic API rejects any
+// request whose tool_result lacks a matching tool_use in the previous
+// message, so this helper never drops the block: empty input becomes {},
+// and malformed input falls back to {} with a CallWarning. The caller still
+// emits a tool_use block with the original ToolCallID, preserving the pair.
+func decodeToolCallInputMap(toolCall fantasy.ToolCallPart) (map[string]any, *fantasy.CallWarning) {
+	if strings.TrimSpace(toolCall.Input) == "" {
+		return map[string]any{}, nil
+	}
+	var inputMap map[string]any
+	if err := json.Unmarshal([]byte(toolCall.Input), &inputMap); err != nil {
+		return map[string]any{}, &fantasy.CallWarning{
+			Type: fantasy.CallWarningTypeOther,
+			Message: fmt.Sprintf(
+				"tool call %q has malformed input JSON; emitting empty arguments to preserve tool_use ↔ tool_result pairing: %s",
+				toolCall.ToolCallID, err,
+			),
+		}
+	}
+	if inputMap == nil {
+		return map[string]any{}, nil
+	}
+	return inputMap, nil
+}
+
+// decodeToolCallInputAny is the server_tool_use counterpart to
+// decodeToolCallInputMap. ServerToolUseBlockParam.Input has type `any` so
+// nil is acceptable for the empty case.
+func decodeToolCallInputAny(toolCall fantasy.ToolCallPart) (any, *fantasy.CallWarning) {
+	if strings.TrimSpace(toolCall.Input) == "" {
+		return nil, nil
+	}
+	var inputAny any
+	if err := json.Unmarshal([]byte(toolCall.Input), &inputAny); err != nil {
+		return nil, &fantasy.CallWarning{
+			Type: fantasy.CallWarningTypeOther,
+			Message: fmt.Sprintf(
+				"server tool call %q has malformed input JSON; emitting empty arguments to preserve tool_use ↔ tool_result pairing: %s",
+				toolCall.ToolCallID, err,
+			),
+		}
+	}
+	return inputAny, nil
+}
+
 // buildWebSearchToolResultBlock constructs an Anthropic
 // web_search_tool_result content block from structured metadata.
 func buildWebSearchToolResultBlock(toolCallID string, searchMeta *WebSearchResultMetadata) anthropic.ContentBlockParamUnion {

providers/anthropic/anthropic_test.go 🔗

@@ -176,9 +176,14 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) {
 		require.Empty(t, warnings)
 	})
 
-	t.Run("should drop assistant messages with invalid tool input", func(t *testing.T) {
+	t.Run("should preserve tool_use block when input JSON is malformed", func(t *testing.T) {
 		t.Parallel()
 
+		// Anthropic's API rejects any request whose tool_result lacks a
+		// matching tool_use in the previous message. Dropping the tool_use
+		// because its input failed to parse leaves the next turn's
+		// tool_result orphaned and produces a 400. Emit the block with
+		// empty arguments instead, and surface the parse error as a warning.
 		prompt := fantasy.Prompt{
 			{
 				Role: fantasy.MessageRoleUser,
@@ -201,10 +206,56 @@ func TestToPrompt_DropsEmptyMessages(t *testing.T) {
 		systemBlocks, messages, warnings := toPrompt(prompt, true)
 
 		require.Empty(t, systemBlocks)
-		require.Len(t, messages, 1, "should only have user message")
+		require.Len(t, messages, 2, "user + assistant — assistant must be preserved so tool_result can pair")
+		assistant := messages[1]
+		require.Equal(t, anthropic.MessageParamRoleAssistant, assistant.Role)
+		require.Len(t, assistant.Content, 1)
+		toolUse := assistant.Content[0].OfToolUse
+		require.NotNil(t, toolUse, "tool_use block should be emitted even when input is malformed")
+		require.Equal(t, "call_123", toolUse.ID)
+		require.Equal(t, "get_weather", toolUse.Name)
 		require.Len(t, warnings, 1)
 		require.Equal(t, fantasy.CallWarningTypeOther, warnings[0].Type)
-		require.Contains(t, warnings[0].Message, "dropping empty assistant message")
+		require.Contains(t, warnings[0].Message, "malformed input JSON")
+		require.Contains(t, warnings[0].Message, "call_123")
+	})
+
+	t.Run("should preserve tool_use block when input is empty", func(t *testing.T) {
+		t.Parallel()
+
+		// Some upstream providers (notably DeepSeek's anthropic-compat
+		// endpoint) emit tool_use blocks with empty input for parameterless
+		// tool calls. Treat empty input as {} rather than dropping the
+		// block, since the next turn's tool_result still needs its pair.
+		prompt := fantasy.Prompt{
+			{
+				Role: fantasy.MessageRoleUser,
+				Content: []fantasy.MessagePart{
+					fantasy.TextPart{Text: "Hi"},
+				},
+			},
+			{
+				Role: fantasy.MessageRoleAssistant,
+				Content: []fantasy.MessagePart{
+					fantasy.ToolCallPart{
+						ToolCallID: "call_empty",
+						ToolName:   "ping",
+						Input:      "",
+					},
+				},
+			},
+		}
+
+		systemBlocks, messages, warnings := toPrompt(prompt, true)
+
+		require.Empty(t, systemBlocks)
+		require.Empty(t, warnings, "empty input is a valid round-trip; no warning")
+		require.Len(t, messages, 2)
+		require.Equal(t, anthropic.MessageParamRoleAssistant, messages[1].Role)
+		toolUse := messages[1].Content[0].OfToolUse
+		require.NotNil(t, toolUse)
+		require.Equal(t, "call_empty", toolUse.ID)
+		require.Equal(t, "ping", toolUse.Name)
 	})
 
 	t.Run("should keep assistant messages with reasoning and text", func(t *testing.T) {