Skip to content

fix: normalize ToolCall arguments to a map when serializing tool calls#581

Merged
brainlid merged 1 commit into
mainfrom
issue-580-fix
Jul 8, 2026
Merged

fix: normalize ToolCall arguments to a map when serializing tool calls#581
brainlid merged 1 commit into
mainfrom
issue-580-fix

Conversation

@brainlid

@brainlid brainlid commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Closes #580

Problem

When an assistant Message reaches a chat model with a ToolCall whose arguments is still a string (rather than a parsed map), the serialized request sends the tool input as a string and the provider rejects the whole request. On Anthropic:

messages.N.content.M.tool_use.input: Input should be an object

ToolCall.arguments is only parsed from string → map by validate_and_parse_arguments/1, which runs only when status: :complete (via ToolCall.complete/1). Any flow that puts a ToolCall into an outgoing message without going through that completion path still holds a string:

  • a persisted/streamed tool call rehydrated as status: :incomplete (agent frameworks that serialize state and replay history)
  • an interrupted stream where the tool_use JSON was truncated before content_block_stop
  • a zero-argument tool call whose arguments accumulated as "" rather than "{}"

Anthropic's for_api/1 guarded this with call.arguments || %{}, but that only substitutes for nil/false. "" || %{} is "", and a non-empty JSON string is truthy, so the string was sent verbatim.

Root cause is broader than Anthropic

ChatGoogleAI and ChatVertexAI have the same passthrough ("args" => call.arguments) and the same latent bug. (The OpenAI-family providers use Jason.encode!/1, a different code path, and are left unchanged.)

Fix

Add a shared, documented ToolCall.arguments_as_map/1 helper that normalizes any shape into a map:

  • a map is returned unchanged
  • a JSON-object string is decoded
  • nil, empty string, and undecodable / non-object strings become %{}

and use it in the three providers that pass arguments straight through:

  • ChatAnthropictool_use.input
  • ChatGoogleAI / ChatVertexAIfunctionCall.args (both the plain and thoughtSignature clauses)

Google and Vertex previously sent args: nil for zero-argument calls; they now send an empty object, which is the correct representation and consistent with the Anthropic behavior.

Backward compatible: maps pass through unchanged, so existing serialization is unaffected.

Tests

  • ToolCall.arguments_as_map/1 unit tests + doctests covering map / JSON string / nil / empty / invalid / non-object.
  • ChatAnthropic.for_api/1 regression tests for a string-arguments call (→ object) and an empty-string call (→ empty object).
  • Full suite green: 35 doctests, 2017 tests, 0 failures.

🤖 Generated with Claude Code

`ToolCall.arguments` is only parsed from a JSON string into a map by
`complete/1`, which runs when a tool call reaches `status: :complete`. A
tool call serialized without going through that path (a persisted/replayed
`:incomplete` call, or one accumulated from a truncated stream) can still
hold a raw string. Anthropic's `for_api/1` guarded this with
`call.arguments || %{}`, but `"" || %{}` is `""` and a non-empty JSON
string is truthy, so the string was sent verbatim and the API rejected the
request with "messages.N.content.M.tool_use.input: Input should be an object".

Add a shared `ToolCall.arguments_as_map/1` helper that normalizes any shape
(map, JSON-object string, nil, empty/invalid/non-object string) into a map,
and use it in the three providers that pass arguments straight through:
ChatAnthropic (`input`), ChatGoogleAI and ChatVertexAI (`args`). Google and
Vertex previously sent `args: nil` for zero-argument calls; they now send an
empty object, which is the correct representation.

Closes #580

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@brainlid
brainlid merged commit b0e927a into main Jul 8, 2026
2 checks passed
@brainlid
brainlid deleted the issue-580-fix branch July 8, 2026 13:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ChatAnthropic.for_api/1 sends string tool_use.input → Anthropic 400 "Input should be an object"

1 participant