cloudwego / cloudwego/eino-ext
fix(claude): convToolCalls crashes with "unexpected end of JSON input" when tool_call Arguments is invalid (not just empty)
- Dominant language
- Go
- Stars
- 811
- Forks
- 368
- Avg merge
- 16h 22m
- Merged PRs (30d)
- 13
Description
**Describe the bug**
`convToolCalls` in `components/model/claude/claude.go` only guards tool-call `Arguments` against the **empty** case before wrapping it in `json.RawMessage`. When a model emits a tool call whose `Arguments` is **non-empty but invalid JSON** — most commonly a *truncated* tool_use streamed for a large payload — the raw partial JSON is passed straight to `anthropic.NewToolUseBlock`, and `ToolUseBlockParam.MarshalJSON` then fails when the message is re-assembled for the next request:
```
[NodeRunError] create new streaming message fail: json: error calling MarshalJSON
for type anthropic.MessageParam: json: error calling MarshalJSON for type
anthropic.ContentBlockParamUnion: json: error calling MarshalJSON for type
*anthropic.ToolUseBlockParam: json: error calling MarshalJSON for type
json.RawMessage: unexpected end of JSON input
------------------------
node path: [node_1, ChatModel]
```
This is the **same error and root cause as #462** (reported for the *empty* `arg` case and addressed by the `if args == ""` guard), and the same family as #304. The empty case is fixed; the **invalid/truncated** case is not.
**Root cause** (current `main`, `components/model/claude/claude.go`, around lines 1126–1134):
```go
args := tc.Function.Arguments
if args == "" {
args = "{}"
}
// Arguments are limited to object type ...
messageParams = append(messageParams,
anthropic.NewToolUseBlock(tc.ID, json.RawMessage(args), tc.Function.Name))
```
The guard catches `args == ""` but not `args` that is non-empty yet not valid JSON (e.g. `{"file_path":"/tmp/x.json","content":"{\"items\":[` — a `write_file` call cut off mid-`content`). `json.RawMessage(args)` keeps the partial bytes; `MarshalJSON` rejects them.
**Why the streamed `Arguments` can be invalid**
With streaming, tool-call input arrives as `input_json_delta` chunks that `ConcatMessageStream` folds into `Function.Arguments`. If the tool_use stream is cut before the JSON object closes (large payload, connection blip), the concatenated `Arguments` is syntactically incomplete. Per Anthropic's fine-grained tool streaming docs, partial tool_use deltas may not be valid JSON — so the consumer must tolerate invalid `Arguments`. The empty-case guard already acknowledges this class of problem; it just doesn't cover the non-empty-invalid sub-case.
**To Reproduce**
1. Use the Claude ChatModel in streaming mode with a tool whose input can be large (e.g. a `write_file` with a big `content`).
2. Have the model emit a tool call whose argument JSON gets truncated mid-stream (or, deterministically, hand-build a `schema.Message` with one `ToolCall` whose `Function.Arguments` is `{"a":` and run another turn so `convToolCalls` re-assembles it).
3. The next `Generate`/`Stream` fails with the `MarshalJSON ... unexpected end of JSON input` error above, ending the run as a framework error.
**Expected behavior**
A tool call with invalid `Arguments` should not crash request re-assembly. The empty-case guard should be generalized to **any** non-valid-JSON `Arguments`, mirroring the existing `if args == ""` fix:
```go
args := tc.Function.Arguments
if args == "" || !json.Valid([]byte(args)) {
args = "{}"
}
messageParams = append(messageParams,
anthropic.NewToolUseBlock(tc.ID, json.RawMessage(args), tc.Function.Name))
```
This keeps existing behavior for empty/valid args and replaces only the invalid case with a valid empty object so `MarshalJSON` succeeds. The model then receives a normal tool result (instead of a framework crash) and can retry. `encoding/json` is already imported in this file.
**Environment**
- eino-ext `components/model/claude` (observed on v0.1.18; same code on `main` / v0.1.20)
- anthropics/anthropic-sdk-go (`ToolUseBlockParam.MarshalJSON` over `json.RawMessage`)
- Go (any), streaming path
**Additional context — how this affects real consumers**
We hit this in production in a Go agent harness built on Eino's DeepAgent: an agent asked to write a large JSON file in one `write_file` call had its tool_use stream truncated, and the *entire turn crashed* (`status: failed`) rather than recovering. It happened across multiple unrelated tasks (a generic large-tool-input problem, not specific to one tool).
As a downstream workaround we wrapped the public `ToolCallingChatModel`/`BaseModel` interface (same composition pattern as Eino's own examples — no fork) and sanitize `ToolCalls[].Function.Arguments` on the model boundary before the message proceeds in the graph: any `Arguments` that fails `json.Valid` is replaced with a valid placeholder, so re-assembly never crashes, and we hand the model a tool result explaining the call was discarded so it can rewrite the call smaller. That fully fixes it for us — but it is a per-consumer wrapper for what is really a one-line generalization in `convToolCalls`. Fixing it upstream would protect every Eino+Claude user against the same crash for free, exactly as the `if args == ""` guard already does for the empty case.
A PR follows. Happy to adjust per your preference.
Contributor guide
Research direction
Start in components/model/claude/claude.go at convToolCalls, around lines 1126–1134, and inspect how tool-call Arguments are passed into anthropic.NewToolUseBlock during Generate or Stream re-assembly. Reproduce the truncated {"a": case, then verify that re-assembly no longer fails with an unexpected end of JSON input while empty and valid arguments retain their behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- ai, backend-api-design
- Issue type
- Bug
- Difficulty
- 1/5
- Estimated time
- Under an hour
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 85/100