Context
Client-defined tools (registered via the tools option on useAgentChat, with execute running in the browser).
Using @cloudflare/[email protected], [email protected].
Symptom
After a client tool's addToolOutput is called, the agent attempts to continue but the second step emits no output. The assistant message ends with metadata.finishReason: "tool-calls" and the tool's output is rendered, but no natural-language follow-up is generated.
The worker logs:
(warn) [AIChatAgent] _applyToolResult: Tool part with toolCallId call_xxx
not in expected state (expected: input-available|approval-requested|approval-responded)
The frequency depends heavily on network latency between client and worker — under sub-millisecond latency it's rare, under realistic latency it reproduces on most tool calls.
Direct evidence: the tool part's state regresses
Logging chat.messages on every change shows the tool part's state going backwards during the round-trip. Excerpt (timestamps elided), same toolCallId throughout:
parts: [step-start, { tool, state: "output-available", output: "..." }]
parts: [step-start, { tool, state: "output-available", output: "..." }, { type: "step-start" }]
parts: [step-start, { tool, state: "input-streaming" }, { type: "step-start" }] ← regressed
parts: [step-start, { tool, state: "input-streaming", input: {} }, { type: "step-start" }]
parts: [step-start, { tool, state: "output-available", output: "..." }, { type: "step-start" }]
parts: [step-start, { tool, state: "output-available", output: "..." }, { type: "step-start" }] + metadata.finishReason: "tool-calls"
The second step-start is the continuation actually beginning — so the continuation is firing — but it's born into a corrupted view where the tool part has been clobbered back to input-streaming. The model has nothing useful to look at, the warning above fires, and the step finishes with finishReason: "tool-calls" and no content.
Hypothesis
There are two writers to the tool part's state during the round-trip:
- The client SDK calls
addToolOutput, which transitions the part to output-available locally and syncs that state up to the worker over the WebSocket.
- The worker calls
_applyToolResult to apply the result and trigger the continuation, which expects the part to still be in input-available.
These two writes race. When the server-side replay/sync wins, it clobbers the already-advanced output-available state back to an earlier value. The continuation then starts against the regressed state and produces nothing useful.
Repro
- Run an
AIChatAgent with a client-defined tool (a synchronous return is fine).
- Send a prompt that triggers the tool.
- Log
chat.messages on every change in the consumer (e.g. console.log(JSON.stringify(messages, null, 2)) from a child of SidekickProvider calling useSidekickChat).
- Observe the state regression and the warning.
Suggested direction (not a PR yet)
Either:
- Treat "already at
output-available with the matching toolCallId and matching output" as a valid starting state for _applyToolResult (idempotent), and never let any sync path regress a part to an earlier state. Or
- Have the WebSocket sync path skip writing tool-part state when the server is the authority for that part of the lifecycle.
Happy to PR if there's a preferred direction.
Related
#1365 (closed) is in the same neighborhood — client-tool round-trip state — but distinct: that one was about exposed state fields not signaling activity coherently. This is about the tool part's state itself getting corrupted mid-round-trip.
Context
Client-defined tools (registered via the
toolsoption onuseAgentChat, withexecuterunning in the browser).Using
@cloudflare/[email protected],[email protected].Symptom
After a client tool's
addToolOutputis called, the agent attempts to continue but the second step emits no output. The assistant message ends withmetadata.finishReason: "tool-calls"and the tool's output is rendered, but no natural-language follow-up is generated.The worker logs:
The frequency depends heavily on network latency between client and worker — under sub-millisecond latency it's rare, under realistic latency it reproduces on most tool calls.
Direct evidence: the tool part's state regresses
Logging
chat.messageson every change shows the tool part's state going backwards during the round-trip. Excerpt (timestamps elided), sametoolCallIdthroughout:The second
step-startis the continuation actually beginning — so the continuation is firing — but it's born into a corrupted view where the tool part has been clobbered back toinput-streaming. The model has nothing useful to look at, the warning above fires, and the step finishes withfinishReason: "tool-calls"and no content.Hypothesis
There are two writers to the tool part's state during the round-trip:
addToolOutput, which transitions the part tooutput-availablelocally and syncs that state up to the worker over the WebSocket._applyToolResultto apply the result and trigger the continuation, which expects the part to still be ininput-available.These two writes race. When the server-side replay/sync wins, it clobbers the already-advanced
output-availablestate back to an earlier value. The continuation then starts against the regressed state and produces nothing useful.Repro
AIChatAgentwith a client-defined tool (a synchronous return is fine).chat.messageson every change in the consumer (e.g.console.log(JSON.stringify(messages, null, 2))from a child ofSidekickProvidercallinguseSidekickChat).Suggested direction (not a PR yet)
Either:
output-availablewith the matchingtoolCallIdand matching output" as a valid starting state for_applyToolResult(idempotent), and never let any sync path regress a part to an earlier state. OrHappy to PR if there's a preferred direction.
Related
#1365 (closed) is in the same neighborhood — client-tool round-trip state — but distinct: that one was about exposed state fields not signaling activity coherently. This is about the tool part's state itself getting corrupted mid-round-trip.