Skip to content

Python: [Bug]: AG-UI: streamed reasoning fragments into one block per delta when the provider sends no per-chunk id #6787

Description

@antsok

Description

agent-framework-ag-ui renders streamed model reasoning ("thinking") as a separate AG-UI reasoning block per delta whenever the chat client emits reasoning content without a stable id. The reasoning emitter keys each block on content.id or generate_event_id(), so id=None deltas each get a fresh id, closing and reopening the block on every chunk.

A CopilotKit (or any AG-UI) frontend then shows one "Thought" accordion per token batch instead of a single expandable reasoning block for the turn.

This is an internal inconsistency in ag-ui: its text emitter already tolerates id=None (it keys on a turn-level flow.message_id and ignores content.id), so streamed answer text correctly coalesces into one message — but the reasoning emitter does not, even though it already tracks an equivalent turn-level flow.reasoning_message_id.

Reproduced with agent-framework-ollama (whose streamed text_reasoning chunks carry id=None), but it affects any provider that streams reasoning without per-chunk ids.

Code Sample

1. Wrap a harness agent backed by a streaming "thinking" Ollama model in `AgentFrameworkAgent` and expose it via `add_agent_framework_fastapi_endpoint`.
2. Stream a run that makes the model think for more than one chunk (any non-trivial prompt).
3. Observe the AG-UI event stream (or a CopilotKit client).

Error Messages / Stack Traces

**Observed events** — the `Start → Content → End` triplet repeats once per reasoning delta, each with a different `message_id`:


REASONING_START            message_id=r1
REASONING_MESSAGE_START     message_id=r1 role=reasoning
REASONING_MESSAGE_CONTENT   message_id=r1 delta="The user wants"
REASONING_MESSAGE_END       message_id=r1
REASONING_END              message_id=r1
REASONING_START            message_id=r2          # next delta -> brand-new block
REASONING_MESSAGE_CONTENT   message_id=r2 delta=" to deploy an AI landing zone…"
REASONING_MESSAGE_END       message_id=r2
…


The matching answer **text** in the same turn streams under a single `message_id` — the asymmetry that pinpoints the bug.

Package Versions

agent-framework-core:1.9.0, agent-framework-ag-ui:1.0.0rc5, agent-framework-ollama: 1.0.0b260521, ollama: 0.5.3

Python Version

Python 3.12

Additional Context

Expected

All reasoning deltas of a single assistant turn share one message_id, producing one REASONING_START → (many) REASONING_MESSAGE_CONTENT → REASONING_END block — mirroring how _emit_text coalesces streamed text. A subsequent turn (e.g. after a tool call) opens its own block.

Actual

One block per delta → the frontend renders a separate "Thought for a few seconds" accordion per chunk, with the reasoning text chopped mid-sentence across them.

Root cause

In python/packages/ag_ui/agent_framework_ag_ui/_run_common.py, the two emitters treat a missing content.id differently.

Text (tolerant — keys on turn-level id, ignores content.id):

def _emit_text(content, flow, skip_text=False):
    ...
    if not flow.message_id:
        flow.message_id = generate_event_id()      # one id per turn
        ...
    events.append(TextMessageContentEvent(message_id=flow.message_id, delta=content.text))

Reasoning (fragments — keys on per-content id):

def _emit_text_reasoning(content, flow=None):
    ...
    message_id = content.id or generate_event_id()   # <-- new id every delta when content.id is None
    if flow is not None:
        if flow.reasoning_message_id != message_id:   # always true -> close + reopen each delta
            events.extend(_close_reasoning_block(flow))
            events.append(ReasoningStartEvent(message_id=message_id))
            events.append(ReasoningMessageStartEvent(message_id=message_id, role="reasoning"))
            flow.reasoning_message_id = message_id
        ...

When content.id is None, generate_event_id() returns a fresh value per call, so flow.reasoning_message_id != message_id is true on every delta and the block is closed/reopened each time. The streaming machinery (flow.reasoning_message_id, _close_reasoning_block) is already there to keep one block per turn; the emitter just discards it by re-keying on content.id.

Impact

  • Any AG-UI consumer (CopilotKit, etc.) shows fragmented per-chunk reasoning cards instead of one expandable block — a visible UX regression for "thinking" models.
  • Triggers for any provider whose streamed reasoning content lacks per-chunk ids;

Metadata

Metadata

Assignees

Labels

ag-uiUsage: [Issues, PRs], Target: AG-UI protocol integrationpythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow

Type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions