@@ -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 {
@@ -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) {