Skip to content

DurableAgent should preserve reasoning content in conversation history across tool loop steps #1393

Closed

Description

@gr2m

I ran into this as I was researching differences between AI SDK's streamText() and DurableAgent's implementation. I came upon sanitizeProviderMetadataForToolCall() and tried to find out why it exists in the first place. Is there a reason why reasoning is not tracked or is that an oversight?

Problem

When DurableAgent's tool loop continues to the next step, the assistant message sent back to the model only contains tool-call parts — reasoning content is omitted:

// stream-text-iterator.ts, inside finishReason === 'tool-calls' branch
conversationPrompt.push({
  role: 'assistant',
  content: toolCalls.map(toolCall => ({
    type: 'tool-call',
    toolCallId: toolCall.toolCallId,
    toolName: toolCall.toolName,
    input: JSON.parse(toolCall.input),
  })),
});

The AI SDK's own streamText includes reasoning parts in the assistant message via toResponseMessages():

case 'reasoning':
  content.push({
    type: 'reasoning',
    text: part.text,
    providerOptions: part.providerMetadata,
  });

This means reasoning models (OpenAI o-series, Anthropic extended thinking, Gemini thinking) lose access to their own prior reasoning during multi-step tool loops in DurableAgent, but not in streamText.

Impact

  1. OpenAI itemId workaround would become unnecessary. PR fix(ai): strip openai itemID from tool call metadata  #889 strips itemId from OpenAI metadata because it references reasoning items that aren't in the conversation. If reasoning items were preserved, itemId references would be valid and no sanitization would be needed.

  2. Reasoning model quality during tool loops. As raised in Bug: DurableAgent + OpenAI Responses API fails on tool calls due to missing required reasoning item #880 by @bhuvaneshprasad: "without (itemId + reasoningItem) or previousResponseId, the code will still function, but I'm unclear on whether the reasoning models would be able to access and leverage the prior reasoning text to improve performance." This was never answered and the workaround was merged instead.

  3. Parity with AI SDK. streamText preserves reasoning across steps. DurableAgent should too.

Proposed Fix

The reasoning data is already available — chunksToStep() in do-stream-step.ts collects reasoning chunks into the step result. The fix is to include reasoning content parts in the assistant message alongside tool-call parts in stream-text-iterator.ts, mirroring what toResponseMessages() does in the AI SDK.

This would also allow removing sanitizeProviderMetadataForToolCall() and the provider-specific itemId stripping from PR #889.

Context

Activity

  1. added a commit that references this issue on Mar 18, 2026
    df4c22b
  2. craze3 commented on Mar 29, 2026

    @craze3
    Contributor

    I hit what looks like the same root problem in a real multi-step DurableAgent run with openai/gpt-5.4 and high reasoning.

    What I observed:

    • long tool-call loop
    • repeated finishReason: 'tool-calls'
    • later finishReason: 'error'
    • log: Error processing UI message chunks: Error: [object Object]

    After digging locally, this lines up with the current behavior where DurableAgent does not preserve reasoning content across tool-loop steps, while OpenAI reasoning models still appear to depend on that continuity.

    I also confirmed the published package still has:

    • do-stream-step.js: error instanceof Error ? error.message : String(error)
    • stream-text-iterator.js: the OpenAI itemId sanitization path with the comment about reasoning items not being preserved

    So from my side this issue is not just theoretical:

    • there is still a real failure mode on long reasoning/tool-call runs
    • when it blows up, downstream consumers only get [object Object], which makes the actual cause hard to diagnose

    I’ve worked around it locally by disabling provider-level reasoning for Workflow chat turns on GPT-5.4, but that is not a real solution 🙃

  3. iNishant commented on May 14, 2026

    @iNishant
    Contributor

    Heads up: the same class of bug as this issue exists for assistant
    text content emitted alongside tool calls, and was not fixed by
    #1444.

    In stream-text-iterator.ts, the finishReason === 'tool-calls'
    branch builds the assistant ModelMessage from reasoningParts +
    toolCalls — text parts from step.content are still being dropped:

    // stream-text-iterator.ts (current)
    if (finishReason === 'tool-calls') {
      const reasoningParts = (step.reasoning ?? []).map(...);   // ✅
    fixed in #1444
      conversationPrompt.push({
        role: 'assistant',
        content: [
          ...reasoningParts,
          ...toolCalls.map(...),                                  // ❌
     no text parts
        ],
      });
      ...
    }
    else if (finishReason === 'stop') {
      // Add assistant message with text content to the conversation
      const textContent = step.content.filter((item) => item.type ===
    'text');
      if (textContent.length > 0) {
        conversationPrompt.push({ role: 'assistant', content:
    textContent });
      }
      ...
    }

    The 'stop' branch correctly carries text forward, and the AI SDK's
    own toResponseMessages() includes both text and tool-calls in the
    same assistant message
    (ai/src/generate-text/to-response-messages.ts). The 'tool-calls'
    branch is the only place this divergence remains.

    Repro

    Prompt an agent to (a) call a tool, and (b) emit text alongside the
    tool call ("explain what you're about to do" + todo_write).
    Anthropic emits a text block followed by a tool_use block in the
    same assistant turn — both are present in step.content. On the next
    step, only the tool-call shows up in the conversation prompt; the
    text is gone.

    Concrete symptom we hit: model emitted intro text + todo_write on
    step 1, then on step 2 (with its own prior text invisible to it)
    started the entire task over from scratch, duplicated the intro,
    and ran into maxOutputTokens on what should have been a short
    follow-up turn.

    Suggested fix

    Mirror the 'stop' branch / toResponseMessages() — one extra spread:

    const textParts = (step.content ?? []).filter((item) => item.type
    === 'text');
    conversationPrompt.push({
      role: 'assistant',
      content: [
        ...textParts,
        ...reasoningParts,
        ...toolCalls.map(...),
      ],
    });

    Note: step.content is built by chunksToStep() as [{type:'text',
    text: }, ...toolCalls], so the ordering above is the only
    ordering available — original streaming interleaving of text →
    tool_use → text → tool_use is already collapsed before this code
    runs. Preserving full interleaving would require a separate change
    in chunksToStep.

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