From a028590f3c79cd7ae692ab486acaa930940a4e36 Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Sat, 18 Jul 2026 08:59:50 +0530 Subject: [PATCH 1/2] Send {} for empty Anthropic tool_use input instead of null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a FunctionCallContent has empty Arguments, buildMessageParams left the parsed args map nil and passed it to NewToolUseBlock. A nil map serializes to "input": null, but Anthropic requires a tool_use block's input to be an object and rejects null — so replaying history that contains a no-argument tool call produced a 400. Default the args map to an empty object when Arguments is empty or absent (and when it decodes to null), so the tool_use input is always {}. Adds a request-capturing test asserting the serialized tool_use input is an object. --- provider/anthropicprovider/agent.go | 6 +++ provider/anthropicprovider/agent_test.go | 66 +++++++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/provider/anthropicprovider/agent.go b/provider/anthropicprovider/agent.go index 4325cf5c..3f414758 100644 --- a/provider/anthropicprovider/agent.go +++ b/provider/anthropicprovider/agent.go @@ -448,6 +448,12 @@ func buildMessageParam(msg *message.Message) (anthropic.MessageParam, error) { return anthropic.MessageParam{}, fmt.Errorf("failed to unmarshal tool arguments: %w", err) } } + if args == nil { + // Anthropic requires a tool_use block's input to be an object; a + // nil map serializes to null (rejected by the API), so send {} + // for a tool call with empty or absent arguments. + args = map[string]any{} + } content = append(content, anthropic.NewToolUseBlock(c.CallID, args, c.Name)) case *message.FunctionResultContent: resStr := "" diff --git a/provider/anthropicprovider/agent_test.go b/provider/anthropicprovider/agent_test.go index 3e0adfb4..b5f00952 100644 --- a/provider/anthropicprovider/agent_test.go +++ b/provider/anthropicprovider/agent_test.go @@ -34,7 +34,8 @@ func newTestClient(t *testing.T, server *httptest.Server) *agent.Agent { anthropicprovider.AgentConfig{ Model: "claude-3-5-sonnet-20241022", Config: agent.Config{DisableFuncAutoCall: true}, - }) + }, + ) } // nestedKey traverses a decoded JSON map following the given path of keys and @@ -197,7 +198,8 @@ func TestConfigInstructions(t *testing.T) { Config: agent.Config{ DisableFuncAutoCall: true, }, - }) + }, + ) if _, err := a.RunText(t.Context(), "hi").Collect(); err != nil { t.Fatalf("unexpected error: %v", err) @@ -455,3 +457,63 @@ func TestStreamingToolCallsSupportInterleavedDeltas(t *testing.T) { } } } + +// A tool call with empty Arguments must serialize to an object input ({}), not +// null: Anthropic rejects a tool_use block whose input is null. +func TestToolUseEmptyArgumentsSerializeAsObject(t *testing.T) { + bodyCh := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request body: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + bodyCh <- body + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, minimalMessageResponse("ok")) + })) + defer server.Close() + + a := anthropicprovider.NewAgent( + anthropic.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test")), + anthropicprovider.AgentConfig{ + Model: "claude-3-5-sonnet-20241022", + Config: agent.Config{DisableFuncAutoCall: true}, + }, + ) + + msgs := []*message.Message{ + {Role: message.RoleUser, Contents: message.Contents{&message.TextContent{Text: "what time is it?"}}}, + {Role: message.RoleAssistant, Contents: message.Contents{&message.FunctionCallContent{CallID: "toolu_1", Name: "get_time", Arguments: ""}}}, + {Role: message.RoleTool, Contents: message.Contents{&message.FunctionResultContent{CallID: "toolu_1", Result: "12:00"}}}, + } + if _, err := a.Run(t.Context(), msgs).Collect(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var req map[string]any + if err := json.Unmarshal(<-bodyCh, &req); err != nil { + t.Fatalf("unmarshal request body: %v", err) + } + found := false + for _, m := range req["messages"].([]any) { + blocks, ok := m.(map[string]any)["content"].([]any) + if !ok { + continue + } + for _, b := range blocks { + block := b.(map[string]any) + if block["type"] != "tool_use" || block["id"] != "toolu_1" { + continue + } + found = true + if _, isObject := block["input"].(map[string]any); !isObject { + t.Errorf("tool_use input = %#v (%T), want an object", block["input"], block["input"]) + } + } + } + if !found { + t.Fatal("tool_use block for toolu_1 not found in request") + } +} From e1ff2dc2f204ebe0a2b114346768de27543d4faa Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Sat, 18 Jul 2026 09:51:20 +0530 Subject: [PATCH 2/2] Use guarded type assertions in empty-tool-args test Address review feedback: replace unchecked assertions on the decoded request body with guarded ones so a schema mismatch reports a readable failure instead of panicking. --- provider/anthropicprovider/agent_test.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/provider/anthropicprovider/agent_test.go b/provider/anthropicprovider/agent_test.go index b5f00952..57ff2ebc 100644 --- a/provider/anthropicprovider/agent_test.go +++ b/provider/anthropicprovider/agent_test.go @@ -496,14 +496,25 @@ func TestToolUseEmptyArgumentsSerializeAsObject(t *testing.T) { if err := json.Unmarshal(<-bodyCh, &req); err != nil { t.Fatalf("unmarshal request body: %v", err) } + messages, ok := req["messages"].([]any) + if !ok { + t.Fatalf("request messages = %#v, want a JSON array", req["messages"]) + } found := false - for _, m := range req["messages"].([]any) { - blocks, ok := m.(map[string]any)["content"].([]any) + for _, m := range messages { + msg, ok := m.(map[string]any) + if !ok { + continue + } + blocks, ok := msg["content"].([]any) if !ok { continue } for _, b := range blocks { - block := b.(map[string]any) + block, ok := b.(map[string]any) + if !ok { + continue + } if block["type"] != "tool_use" || block["id"] != "toolu_1" { continue }