From d73b308628f5a2750c872ce80f283639a83ae4d1 Mon Sep 17 00:00:00 2001 From: Lauri Jutila Date: Wed, 29 Apr 2026 15:42:09 +0300 Subject: [PATCH] fix(anthropic): preserve `tool_use` when `ToolCallPart.Input` is empty or malformed (#219) 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. --- providers/anthropic/anthropic.go | 60 +++++++++++++++++++++++---- providers/anthropic/anthropic_test.go | 57 +++++++++++++++++++++++-- 2 files changed, 106 insertions(+), 11 deletions(-) diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index 882a22c9c51b5301302da7135f024088e4d6eac9..2760b0d71b244b86f46da5d6fce60c094b8a1128 100644 --- a/providers/anthropic/anthropic.go +++ b/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 { diff --git a/providers/anthropic/anthropic_test.go b/providers/anthropic/anthropic_test.go index 69c41f94ff38214f4393b09d68caf8ca371cfbf1..219171c0b576c89453c5881213a37fdf9b3253fe 100644 --- a/providers/anthropic/anthropic_test.go +++ b/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) {