fix(anthropic): route input_json deltas by content-block index#3009
Conversation
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The fix is correct and well-targeted. Replacing the single toolID string field with a toolIDByBlock map[int64]string keyed by content-block index is the right solution for routing InputJSONDelta events across parallel tool_use blocks. Both streamAdapter and betaStreamAdapter are patched consistently, and the new regression tests faithfully reproduce the interleaved-delta scenario described in the PR.
Verification summary
| Finding | Severity | Verdict |
|---|---|---|
| Silent empty-string ToolCall ID on missing block index | Medium | ✅ DISMISSED — Anthropic API guarantees content_block_start before any delta; retry path restarts from the beginning so indices are re-populated before deltas arrive |
toolIDByBlock not cleared on stream retry |
Medium | ✅ DISMISSED — retry replaces the stream from the beginning; all content_block_start events are re-delivered before any deltas, overwriting any stale entries |
Redundant nil-guard inside Recv() |
Low | Informational note (see inline comment) |
One low-severity observation is left as an inline comment for awareness. No blockers found.
476c052 to
fd8757b
Compare
Parallel tool_use blocks streamed from Anthropic share a single streamAdapter.toolID field that is overwritten on every ContentBlockStartEvent. Subsequent InputJSONDelta events for the first block then carry the second block's tool ID, and the runtime accumulator (keyed by ToolCall.ID in pkg/runtime/streaming.go) concatenates both calls' argument fragments into one buffer. The first tool's args end up empty; the second tool's args contain interleaved fragments that fail to parse, surfacing as invalid character 's' looking for beginning of value invalid character '-' after object key:value pair (the exact character depends on where the chunk boundaries fall). Track tool IDs per content-block index via toolIDByBlock map[int64]string and route InputJSONDelta events using eventVariant.Index. Apply the same fix to betaStreamAdapter, which shared the bug. Adds pkg/model/provider/anthropic/parallel_toolcall_test.go covering both the standard and Beta adapters with interleaved input_json_delta events; both tests fail without this fix and pass with it. Note: pkg/model/provider/bedrock/adapter.go carries the same single-currentToolID pattern and is likely vulnerable to the same bug on parallel Converse tool calls; left for a follow-up PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…anic Assisted-By: ai
fd8757b to
71b1bcd
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The fix correctly addresses the parallel tool_use cross-wiring bug by replacing the single toolID string field with a toolIDByBlock map[int64]string keyed by content-block index, applied consistently to both streamAdapter and betaStreamAdapter. Map initialization in both constructors (newStreamAdapter, newBetaStreamAdapter) prevents nil-map panics.
Review summary
| Area | Result |
|---|---|
| Core fix correctness | ✅ Map-per-block-index correctly routes InputJSONDelta events |
| Nil-map safety | ✅ Both constructors initialize the map |
| Type correctness | ✅ int64 index matches the SDK struct field type |
| Test coverage | ✅ Both streamAdapter and betaStreamAdapter paths covered with interleaved-delta scenarios |
| No regressions introduced | ✅ |
Observations (informational, not blocking)
-
Stale map on retry (non-issue):
toolIDByBlockis not explicitly cleared whenretryableStreamtransparently swaps the underlying SSE stream on a context-length error. However, the SSE protocol guarantees thatContentBlockStartEventalways precedes anyContentBlockDeltaEventfor a given block index — so the new stream's start events overwrite stale entries before any delta events can read them. No practical cross-wiring risk. -
toolCallflag on retry (pre-existing, out of scope): ThetoolCall boolfield (pre-existing, not changed by this PR) is never reset if the retried stream contains no tool calls — potentially causing an incorrectFinishReasonToolCalls. This pre-dates this PR and is out of scope here. -
Bedrock follow-up: The PR description already flags
pkg/model/provider/bedrock/adapter.goas carrying the same single-currentToolIDpattern. Worth tracking as a follow-up.
The regression tests (TestParallelToolCallIDsAreNotCrossWired, TestBetaParallelToolCallIDsAreNotCrossWired) directly exercise the failure mode described in the PR and provide good coverage for the fix.
Summary
Parallel
tool_useblocks streamed from Anthropic share a singlestreamAdapter.toolIDfield that gets overwritten on everyContentBlockStartEvent. SubsequentInputJSONDeltaevents for the first block carry the second block's tool ID, and the runtime accumulator (keyed byToolCall.IDinpkg/runtime/streaming.go) concatenates both calls' argument fragments into one buffer. The first tool's args end up empty; the second tool's args contain interleaved fragments that fail to parse, surfacing as:(the exact character depends on where the chunk boundaries fall.)
This fix tracks tool IDs per content-block index via
toolIDByBlock map[int64]stringand routesInputJSONDeltaevents usingeventVariant.Index. The same fix is applied tobetaStreamAdapter, which shared the bug.How it manifests in production
Observed against a local GM agent team (cagent v1.57.0) running Claude Opus with ~18 parallel MCP tool calls in a single turn. Five calls failed with the JSON errors above before reaching the MCP gateway; the other thirteen succeeded. Retrying the same calls in isolation always succeeded — which made it look like a transient parser corruption issue, but the actual condition is deterministic: it only fires when the provider interleaves
input_json_deltaevents across blocks, which is more likely under heavier fan-out.The bug is present on current
main(verified at HEAD03386b31). Bug was never reported via gh search for "parallel tool", "invalid character", or "tool call arguments" before this PR.Test plan
pkg/model/provider/anthropic/parallel_toolcall_test.go(added in this PR) feeds a fake SSE decoder a sequence of two paralleltool_useblocks with interleavedinput_json_deltaevents, mirroring what Anthropic emits for parallel tool calls. Covers bothstreamAdapterandbetaStreamAdapter.Without the fix:
With the fix:
Follow-up
`pkg/model/provider/bedrock/adapter.go` carries the same single-`currentToolID` pattern and is likely vulnerable to the same bug on parallel Bedrock Converse tool calls. Not patched here to keep the diff focused — happy to file a follow-up PR if useful.
🤖 Generated with Claude Code