cloudwego / cloudwego/eino-ext

fix(claude): convToolCalls crashes with "unexpected end of JSON input" when tool_call Arguments is invalid (not just empty)

Open Beginner friendly
#894 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.