chat message builder leaves preliminary: true on a streaming tool's final output; ignoreIncompleteToolCalls then drops the call from every later turn · Issue #2635 · cloudflare/agents · GitHub
You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
chat message builder leaves preliminary: true on a streaming tool's final output; ignoreIncompleteToolCalls then drops the call from every later turn #2635
Versions:agents 0.28.1 and @cloudflare/think 0.20.2. It still reproduces on agents 0.29.0 and @cloudflare/think 0.21.0 (ai 7.0.147), and the inspected main at fc7f59f has the same code.
What happens
A tool whose execute is an async generator streams each yield as a preliminary output. The AI SDK then sends the final output as a tool-output-available chunk with no preliminary field. The AI SDK's own UI-message reducer assigns preliminary every time (aidist/index.js, anyPart.preliminary = anyOptions.preliminary), which clears it. The agents message builder copies the flag only when it is present:
// packages/agents/src/chat/message-builder.ts (main, line 545)case"tool-output-available": {…p.output=chunk.output;if(chunk.preliminary!==undefined){p.preliminary=chunk.preliminary;}
So the persisted part is {state: "output-available", preliminary: true, output: <final>}. On the next turn Think calls convertToModelMessages(…, {ignoreIncompleteToolCalls: true}). That filter keeps output-available parts only when preliminary !== true, so it removes both the call and its result. From then on the model never sees that tool's call or result, only its own words about them.
Reproduction (a Think agent with a scripted model)
exportclassPrelimAgentextendsThink<Env>{seen: LanguageModelV4Prompt[]=[];getModel(){returnscripted(/* "call" → tool-call wait_tool; after a tool result → "done"; else "hello" */,this.seen);}getTools(){return{wait_tool: tool({inputSchema: z.object({}),async*execute(){yield{status: "waiting"};yield{status: "done",value: 42};}})};}asyncrun(){awaitthis.runTurn({input: "call"});conststored=this.messages.flatMap((m)=>m.parts).find((p)=>p.type==="tool-wait_tool");awaitthis.runTurn({input: "next"});return{ stored,turn2Prompt: this.seen.at(-1)};}}
Result:
stored is {"state":"output-available","preliminary":true,"output":{"status":"done","value":42}}.
Turn 2's prompt is system, user:text, assistant:text, user:text, with no tool-call and no tool-result.
With the flag cleared, turn 2's prompt is system, user:text, assistant:tool-call, tool:tool-result, assistant:text, user:text.
Expected: the final output clears preliminary, matching processUIMessageStream, and later turns keep the call and result.
Workaround: in onChatResponse, remove preliminary from finished tool parts and call updateMessageInHistory().
Proposed fix: in message-builder.ts, set the flag from the chunk every time:
Applying this to the 0.29.0 build fixes the repro: the part is stored without the flag and turn 2 sees the call. apply-chunks.ts already assigns it every time (part.preliminary = chunk.preliminary), so the two code paths would then agree.
I have a fix for this on a branch: https://github.com/tlq5l/agents/tree/fix/clear-preliminary-on-final-output (I could not open a PR, since PRs here are limited to collaborators). applyChunkToParts now clears preliminary when the final tool-output-available chunk has no preliminary field, matching the AI SDK reducer, with a test and a changeset. Happy to open a PR if that helps, or feel free to take the commit.
Activity
tlq5l commentedon Oct 21, 2026
I have a fix for this on a branch: https://github.com/tlq5l/agents/tree/fix/clear-preliminary-on-final-output (I could not open a PR, since PRs here are limited to collaborators).
applyChunkToPartsnow clearspreliminarywhen the finaltool-output-availablechunk has nopreliminaryfield, matching the AI SDK reducer, with a test and a changeset. Happy to open a PR if that helps, or feel free to take the commit.