Bedrock Tool Call Streaming#
Overview#
AWS Bedrock emits tool_call_chunks in a non-standard pattern that differs from most LLM providers: only the first chunk for each tool call carries the id and name fields; all subsequent ("continuation") chunks for the same tool call have id: null and name: null. The @assistant-ui/react-langgraph adapter handles this via a two-tier matching strategy in the chunk-accumulation layer, and the downstream ToolInvocationTracker has an exactly-once contract that governs how mid-stream anomalies (including args regressions) are absorbed.
Chunk Protocol#
A standard streaming provider sends a complete id and name on every chunk. Bedrock sends:
| Chunk | index | id | name | args |
|---|---|---|---|---|
| 1st | 0 | "tool_call_abc" | "my_tool" | '{"key":' |
| 2nd | 0 | null | null | 'value}' |
| … | … | null | null | … |
Because id/name on continuation chunks are null, naive string-match merging fails.
Normalization Layer (normalizeLangGraphTupleMessage)#
normalizeToolCallChunk is the entry gate. It:
- Requires only
indexto be a number — any chunk lacking a numericindexis dropped. - Coerces
null/non-stringidandnameto""instead of rejecting the chunk .
This coercion is what makes Bedrock's null-identity continuation chunks survive into the accumulator. The LangChainToolCallChunk type enforces index: number; id: string; name: string , so callers downstream always receive strings — never null.
Accumulation Layer (appendLangChainChunk)#
appendLangChainChunk merges each incoming AIMessageChunk into the running accumulated message.
First-chunk bootstrap#
When there is no prior AI chunk, the function converts tool_call_chunks directly into tool_calls via the chunkToToolCall helper, which normalizes args/args_json into a partial_json string and a parsed args object .
Continuation-chunk matching (two-tier)#
For subsequent chunks, the merge loop applies:
- Exact
idmatch — finds the existing tool call by non-emptyid === chunk.id. - Index fallback — if
idis empty andchunk.indexis present, matches byindexwhere either the accumulated entry or the incoming chunk has noid.
If neither tier matches, the chunk is treated as a new tool call. Once matched, the partial_json is concatenated safely (guarding against undefined on existing.partial_json) and the id is promoted from whichever side has it .
LangChainToolCall.index field#
LangChainToolCall was extended with an optional index?: number so the accumulated tool call retains the index needed for the fallback lookup on later continuation chunks.
ToolInvocationTracker — Exactly-Once Contract#
After the LangGraph adapter produces a normalized ThreadMessage, the ToolInvocationTracker in @assistant-ui/core processes it .
Core guarantee: streamCall (and execute) fires exactly once per logical toolCallId, regardless of how the snapshot mutates afterward.
Args regressions (edge case A.2)#
Bedrock's streaming can produce snapshots where a later argsText is shorter than what was already accumulated — for example, if a stream restart delivers an earlier fragment. The tracker detects this as a regression (new argsText is not a prefix of the already-streamed text), logs a warning in non-production, and does not restart the stream. The controller retains whatever args prefix it already sent; subsequent snapshots that re-converge to a valid prefix can still flow.
Result delivery during regression (known limitation)#
If a snapshot has both a regressed argsText and a backend result on the same tool call, a race occurs: setResponse closes argsText before the result chunk is enqueued, so the args-text-finish path attempts to parse a stale accumulated value, fails, and emits a parse-error result that beats the real backend result to the reader's response promise. The entry.hasResult guard suppresses double-fire at the onResult callback level, but reader.response.get() may already have resolved with the parse error. This is documented as a known limitation and requires a fix in ToolCallStreamControllerImpl.setResponse.
Other relevant state transitions#
| Edge case | Behavior |
|---|---|
| A.1 Args grow normally | Delta appended; no re-fire |
| A.3 Equivalent JSON key reorder | Tracker silently updates argsText; no re-fire |
| A.4 Args change after first completion | Not restarted; existing streamCall keeps original view |
A.5 result becomes defined | setResponse called; controller closed; single fire |
| B.1 Tool call removed from snapshot | Entry persists until reset(); no re-fire on reappearance |
See EDGE_CASES.md for the full state-transition catalog.
Key Files#
| File | Role |
|---|---|
appendLangChainChunk.ts | Chunk accumulator; two-tier tool-call matching |
normalizeLangGraphTupleMessage.ts | Normalizes raw wire chunks; coerces null id/name to "" |
types.ts | LangChainToolCallChunk and LangChainToolCall type definitions |
ToolInvocationTracker.ts | Exactly-once pipeline; handles args regressions and result delivery |
EDGE_CASES.md | Authoritative catalog of all state-transition edge cases |
Fix history: PR #3780 introduced the null id/name coercion and index-based fallback matching (released in @assistant-ui/react-langgraph@0.13.10).