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
Skip to content

chat message builder leaves preliminary: true on a streaming tool's final output; ignoreIncompleteToolCalls then drops the call from every later turn #2635

Open

Description

@TheDominator54

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 (ai dist/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)

export class PrelimAgent extends Think<Env> {
  seen: LanguageModelV4Prompt[] = [];
  getModel() { return scripted(/* "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 }; }
      })
    };
  }
  async run() {
    await this.runTurn({ input: "call" });
    const stored = this.messages.flatMap((m) => m.parts).find((p) => p.type === "tool-wait_tool");
    await this.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:

if (chunk.preliminary === true) p.preliminary = true;
else delete p.preliminary;

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.

Activity

  1. tlq5l commented on Oct 21, 2026

    @tlq5l

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions