Skip to content

Python: fix: keep AG-UI workflow reasoning in thread snapshots - #8058

Merged
Evan Mattson (moonbox3) merged 6 commits into
microsoft:mainfrom
manjunathshiva:python-agui-workflow-reasoning-snapshot-8054
Sep 15, 2026
Merged

Evan Mattson (moonbox3) merged 6 commits into
microsoft:mainfrom
manjunathshiva:python-agui-workflow-reasoning-snapshot-8054

Conversation

@manjunathshiva

@manjunathshiva Manjunath Janardhan (manjunathshiva) commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Motivation & Context

With an AG-UI snapshot store enabled, a Workflow run's intermediate reasoning renders live and then disappears when the thread is hydrated. The same run through the agent path keeps it, so live and replayed output disagree.

The reasoning is already recorded and simply never read back. _emit_text_reasoning (_run_common.py:1051), which _emit_content delegates to for text_reasoning content, persists each reasoning message into flow.reasoning_messages — its docstring says so outright. The agent runner honours that contract (_agent_run.py:2100-2102, emitted terminally at :3202-3212); the workflow runner calls the same _emit_content, filling the same list, and never consumes it.

_WorkflowSnapshotBuilder.observe folds TextMessage* and ToolCall* events into the synthesized snapshot but had no Reasoning* branch, so reasoning events fell through silently and never reached the store.

Evan Mattson (@moonbox3) raised this in review on #8003 and confirmed a follow-up PR was the right home for it ("Follow up is fine, thanks."). This is that follow-up, built on top of #8003 now that it has merged.

Description & Review Guide

  • What are the major changes?

    _WorkflowSnapshotBuilder now folds reasoning events, mirroring the existing text handling:

    • observe gains branches for ReasoningMessageStartEvent, ReasoningMessageContentEvent,
      ReasoningMessageEndEvent / ReasoningEndEvent, and ReasoningEncryptedValueEvent.
    • New _observe_reasoning_* / _flush_open_reasoning_message helpers accumulate deltas per
      message_id and emit entries in the same shape the agent path produces:
      {"id", "role": "reasoning", "content", ["encryptedValue"]}.

    Review found that the first version of this only ordered correctly for some event streams, so the
    flush points are now symmetric rather than partial:

    • Reasoning is flushed wherever other output is appended, and open text is flushed wherever
      reasoning opens. The two slots can therefore never both be open, which is what makes build()'s
      flush order stop deciding the sequence. Previously an output event followed by a later
      intermediate event left both open and hydration replayed them in the wrong order.
    • That covers three paths the first version missed: _observe_text_content (text resuming with no
      start event), _observe_tool_call_result, and reasoning opened from a content event with no
      start event.
    • A reasoning block carrying only protected data emits no content event, so output arriving before
      its encrypted value used to flush an empty message that the late ReasoningEncryptedValueEvent
      could no longer find, silently dropping the protected value. The empty message now stays
      addressable in the position it streamed, and build() leaves it out of the snapshot only while
      nothing has claimed it, so genuinely empty reasoning is still absent.
    • Splitting an open text message means a message resuming under the same message_id would replay
      twice under that id. The later fragment is re-identified the way _observe_tool_call_start
      already re-identifies a split message, and only when a collision is real.
    • Separately found while adding that flush: _observe_text_content opened a message over one
      already open under a different id and discarded its content, where _observe_text_start flushes
      first. It now flushes as well.
  • What is the impact of these changes?

    Reasoning that streamed during a workflow run is still present after the thread is hydrated from a
    snapshot, and it replays in the position it streamed rather than wherever build() happened to put
    it.

    Reasoning still does not close an open tool-call group -- verified, two tool calls with a reasoning
    block between them stay in one assistant message. A reasoning row can now sit between a tool call
    and its result, which is safe because _message_adapters.py:691-694 drops role == "reasoning"
    when converting back to provider messages, so the call and result come back adjacent. Nothing
    asserted that across the two modules before; a test now converts the snapshot and checks it.

    One behaviour change worth calling out: a text message that resumes after an interleaved reasoning
    block replays as two messages, and the later fragment carries a generated id rather than the one it
    streamed under. A message that never resumes keeps its original id.

    This is the smaller of the two shapes discussed on the issue. It reads the real event stream rather
    than flow, which matters for workflows: request_info/interrupt tool calls and executor
    passthrough events are yielded directly and never touch flow, so observe sees strictly more
    than _build_messages_snapshot would. The second shape -- emitting a terminal
    MessagesSnapshotEvent from the workflow runner -- is left to the issue, since an emitted snapshot
    is treated as authoritative and would need to reproduce those bypassing events too.

  • What do you want reviewers to focus on?

    Two judgment calls rather than the mechanics.

    First, the re-identification above. It preserves streamed order at the cost of one generated id.
    The alternative is to merge the resumed fragment back into the earlier message, which keeps ids
    stable but replays the reasoning after text that streamed later -- a smaller version of the defect
    this PR fixes. Say if you would rather have stable ids.

    Second, the empty-reasoning message being filtered at build() rather than dropped at flush.
    Filtering is not observable through the single call path today, which builds once per run; it keeps
    build() a projection of accumulated state rather than a mutation of it. Attaching the value on
    arrival instead would have been smaller but would replay the reasoning after the output that
    flushed it.

    A note on measurement, since two of these depend on what the provider and scheduler actually do
    rather than on the folding logic: a live fan-out workflow on gpt-5-mini shows reasoning arriving
    with an encrypted value and no visible text, the same value emitted twice for one item, and
    concurrent executors interleaving their events. Details are in a PR comment.

Related Issue

Fixes #8054

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

`_emit_text_reasoning` persists each workflow reasoning message into
`flow.reasoning_messages`, and `_WorkflowSnapshotBuilder.observe` folded
`TextMessage*` and `ToolCall*` events into the synthesized snapshot but had
no `Reasoning*` branch. Reasoning events fell through silently, so
intermediate output rendered live and then vanished when the thread was
hydrated from a snapshot -- while the same run through the agent path kept
it.

Fold reasoning into the builder: accumulate deltas per message_id and emit
entries in the shape the agent path already produces
({"id", "role": "reasoning", "content", ["encryptedValue"]}). Reasoning is
flushed at build() and at text-message and tool-call starts so a block that
streamed before other output replays in the position it streamed in.

Reasoning deliberately does not close an open tool-call group: it is UI-only
state that `agui_messages_to_agent_framework` drops, so it cannot break the
tool_calls/result adjacency providers require.

Fixes microsoft#8054

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The moderate encrypted-only reasoning loss must be fixed before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Preserves AG-UI workflow reasoning in thread snapshots so hydrated output matches live streaming.

Changes:

  • Folds reasoning events and encrypted values into workflow snapshots.
  • Preserves reasoning output order.
  • Adds unit and end-to-end regression coverage.
File summaries
File Summary
python/packages/ag-ui/tests/ag_ui/test_workflow_agent.py Tests reasoning persistence through snapshot hydration.
python/packages/ag-ui/tests/ag_ui/test_snapshots.py Tests reasoning synthesis, ordering, and encryption.
python/packages/ag-ui/agent_framework_ag_ui/_workflow.py Adds reasoning snapshot synthesis; encrypted-only reasoning can be dropped when the message-end event precedes its encrypted value.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread python/packages/ag-ui/agent_framework_ag_ui/_workflow.py
Address review: an encrypted-value-only reasoning message was dropped for the
event order `_emit_text_reasoning` produces without a flow, where
REASONING_MESSAGE_END precedes REASONING_ENCRYPTED_VALUE. The message carries
no display text, so closing it at REASONING_MESSAGE_END discarded it and left
the encrypted value with nothing to attach to.

The underlying mistake was conflating two protocol levels.
REASONING_MESSAGE_END closes the message; REASONING_END closes the block; and
an encrypted value is block-scoped, so it legitimately trails the message end.
Keep the message open past REASONING_MESSAGE_END and let REASONING_END, a new
REASONING_START, intervening text/tool output, or build() finalize it. Handle
REASONING_START so a new block also closes anything the previous one left open.

Adds regression coverage for the reported order, for an empty block with
neither text nor an encrypted value still being dropped, for a block closed by
the next REASONING_START, for an encrypted value arriving after intervening
text, and for end events naming a message that was never opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread python/packages/ag-ui/agent_framework_ag_ui/_workflow.py
Comment thread python/packages/ag-ui/agent_framework_ag_ui/_workflow.py
@moonbox3 Evan Mattson (moonbox3) added the ag-ui Usage: [Issues, PRs], Target: AG-UI protocol integration label Sep 8, 2026
Review on microsoft#8058 found two ordering defects in `_WorkflowSnapshotBuilder`.

An `output` event followed by a later `intermediate` event left both the open
text message and the open reasoning message unflushed, so `build()`'s fixed
flush order decided the sequence and hydration reversed what streamed. Nothing
flushed open text when reasoning opened, even though `_observe_text_start` and
`_observe_tool_call_start` already flush open reasoning. Reasoning is now
flushed wherever other output is appended and text is flushed wherever
reasoning opens, so the two slots can never both be open and `build()`'s order
cannot matter. That covers the `_observe_text_content` resume path and
`_observe_tool_call_result`, which had the same gap. Reasoning is UI-only and
dropped before provider conversion, so a block that now lands between a tool
call and its result still converts back with the two adjacent, which provider
APIs require; a test pins that across the two modules.

A block carrying only protected data has no content event at all, so output
arriving before its encrypted value flushed an empty message that the late
`ReasoningEncryptedValueEvent` could no longer find, and the protected value
was lost. The empty message now stays addressable in the position it streamed
and `build()` filters it out only while nothing has claimed it, so genuinely
empty reasoning is still absent from the snapshot. Filtering rather than
deleting is not observable through the one call path today, which builds once
per run; it keeps `build()` a projection of accumulated state so the choice
does not have to be revisited if the builder is ever driven incrementally.

A live fan-out workflow on gpt-5-mini shows both preconditions for that loss.
Reasoning arrives with an encrypted value and no visible text, the value is
emitted twice for one item, and with a flow it lands between
REASONING_MESSAGE_START and REASONING_MESSAGE_END rather than after it.
Concurrent executors interleave their events, so a text message can start in
the gap before the value arrives; replaying the captured order with that
interleaving loses the encrypted reasoning before this change and keeps it
after.

That same interleaving exposed a second loss on the no-start text path:
`_observe_text_content` opened a message over one already open under a
different id, discarding its content, where `_observe_text_start` flushes
first. It now flushes as well.

Splitting an open text message on reasoning meant a resumed message reusing its
id replayed twice under that id; the later fragment is now re-identified the way
`_observe_tool_call_start` already re-identifies a split message, and only when
a collision is real. A repeated start for the block already open is now a no-op
rather than silently discarding the deltas folded into it.

Detecting that collision by scanning the accumulated messages on every flush
made snapshot building quadratic -- flat at about 8us per message before,
rising to 124us per message by 4000 messages. Appends now go through one method
that maintains an id index, which restores linear scaling, and a test pins the
index against a future append that bypasses it.
@manjunathshiva

Copy link
Copy Markdown
Contributor Author

Two things that belong on the PR rather than in either review thread.

A performance regression I introduced and fixed. Detecting the message-id collision by scanning
the accumulated messages on each flush made snapshot building quadratic. Measured on this branch
against the previous commit: flat at about 8us per message at every length tested before, rising to
124us per message by 4000 messages -- roughly a 15x slowdown on a long run, and worse with scale.
Appends now go through a single method that maintains an id index, which puts it back to a flat 9us
per message. The guard is a test asserting the index covers every appended message, since a timing
assertion would be flaky in CI and the real failure mode is a future append bypassing the helper.

Live validation on real Foundry infrastructure. Two of the questions in review are about what the
provider and the scheduler actually do rather than about the folding logic, so I ran a fan-out
workflow with two concurrent agents and a tool call against gpt-5-mini on a real Foundry project.

  • Reasoning arrives with an encrypted value and no visible text, so the empty-message case is what
    the model produces rather than a constructed one.
  • ReasoningEncryptedValueEvent is emitted twice for one reasoning item, same entity_id.
  • With a flow the encrypted value lands between REASONING_MESSAGE_START and REASONING_MESSAGE_END,
    not after the message end; the order described in review is the no-flow branch.
  • Concurrent executors interleave: the reasoning/text/tool alternation count varied between 4 and 10
    across three runs of the same prompt.
  • End to end the saved snapshot had no duplicate ids, the reasoning row where it streamed, that row
    dropped before provider conversion, and every function call immediately followed by its result.

Replaying the captured event order with a concurrent text message in the gap before the value arrives
loses the encrypted reasoning before this change and keeps it after, which is the regression test
added for it.

Validation: poe test -P ag-ui 1173 passed at 92% coverage, poe syntax and poe typing clean
across all five checkers. Because _observe_tool_call_result carries function-call content I also ran
the spec 004 set -- core 4315, openai 477, declarative 989, foundry_hosting 286 -- plus foundry 388,
which that list omits. All nine new tests fail against the previous commit.

@moonbox3

Copy link
Copy Markdown
Contributor

Please address the file conflicts when you can. Thanks.

…reasoning-snapshot-8054

# Conflicts:
#	python/packages/ag-ui/agent_framework_ag_ui/_workflow.py
#	python/packages/ag-ui/tests/ag_ui/test_snapshots.py
@manjunathshiva

Manjunath Janardhan (manjunathshiva) commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Done — merged main in and pushed as 6338c3251.

Worth a note on how it was resolved, because it was not a pick-one-side conflict. #8129 changed the
same two methods of _WorkflowSnapshotBuilder that this PR changes, so both hunks needed combining:

  • build() — the Host-payload processing from Python: Preserve MCP Host payloads in AG-UI snapshots #8129 now runs over this PR's filtered message source
    rather than self._synthesized_messages directly, so _persistable_host_payload_history and
    _bound_host_payload_history still see every message while the unclaimed reasoning shells this PR
    introduces stay out of the snapshot.
  • _observe_tool_call_result — kept Python: Preserve MCP Host payloads in AG-UI snapshots #8129's MCP message construction and this PR's reasoning flush,
    and routed the append through _append_synthesized_message instead of
    self._synthesized_messages.append. That last part matters: this PR added an id index so
    collision detection is a set lookup rather than a scan, and a raw append would leave the index
    stale. There is a test asserting the index covers every appended message, and it now covers the
    MCP message too.

poe test -P ag-ui is 1251 passing, including #8129's
test_workflow_snapshot_builder_preserves_safe_bounded_mcp_replay alongside this PR's tests, and
poe syntax / poe typing are clean across all five checkers. Core is 5040 passing.

One question while you are here: this is still a draft because #8054 has not had a pick between the
three shapes I offered, and shape (b) or (c) would reshape part of this.

@eavanvalkenburg

Copy link
Copy Markdown
Member

Thanks for the update. Before this is ready, could you please:

Once those are addressed, please re-request review. Thanks!

@manjunathshiva

Copy link
Copy Markdown
Contributor Author

Both review discussions are resolved — each had my reply from 2026-09-08 and was waiting only on the
resolve click. Sorry for leaving those sitting.

On re-requesting review: this is still a draft for one reason, and it is a decision rather than
outstanding work. #8054 offered three shapes and asked for a steer before I wrote the code:

  • (a) fold Reasoning* events into _WorkflowSnapshotBuilder.observe — smallest, and correct
    regardless of which flow fields the workflow runner populates, because it reads the real event
    stream
  • (b) emit a terminal MessagesSnapshotEvent from the workflow runner — keeps the two runners
    symmetrical, but it cannot just call _build_messages_snapshot, or it trades one dropped-content
    bug for another
  • (c) both

No pick came back, so this PR implements (a). If you would rather have (b) or (c), part of this
gets reshaped — which is why I held it as a draft rather than spend your review time on a shape you
might not want.

So unless you tell me otherwise, I will take (a) as accepted, rebase on current main and mark
this ready.
Say the word if you want (b) or (c) instead and I will rework rather than have you
review this twice.

Two judgment calls in the description are also yours rather than mechanics, and neither blocks a
review — a text message resuming after interleaved reasoning replays as two messages with a
generated id on the later fragment (the alternative keeps ids stable but replays reasoning out of
order), and empty reasoning is filtered at build() rather than dropped at flush.

Current state: _workflow.py, test_snapshots.py and test_workflow_agent.py are untouched by
main since this branch last merged it, the merge is clean, and validation was green at the last
push — ag-ui 1251 passing, poe syntax and poe typing clean across all five checkers, core 5040.
I will re-run the full set against current main before marking it ready.

@moonbox3

Copy link
Copy Markdown
Contributor

Please fix failing tests.

@manjunathshiva

Copy link
Copy Markdown
Contributor Author

Green now, in 34464d035 — though this one turned out not to be mine, so it is worth saying where it
came from in case the same thing lands on other PRs this week.

The run was 1 failed / 12568 passed, and the single failure was
test_execute_code_tool_clears_output_after_rejection in packages/hyperlight/, on
output_root.iterdir(). This PR only touches packages/ag-ui/.

Your own #8380, "stabilize Hyperlight output cleanup test", fixes it — it landed on main at
23:30Z, and this branch had merged main at 22:57Z, thirty-three minutes earlier. So the branch was
just below that commit. I have merged main again to pick it up and confirmed the test passes here.

Current state: ag-ui 1414 passing, poe check -P ag-ui clean across all five type checkers, and no
change to this PR's own code was needed.

Separately, the open question from my previous comment still stands whenever you have a moment: this
implements shape (a) from #8054 and I am treating that as accepted unless you would rather have
(b) or (c). Nothing blocks a review on it either way.

The workflows on this head are at action_required and need approval before they can run — could
you release them when convenient, so the green is on the record rather than just on my machine?

@moonbox3
Evan Mattson (moonbox3) added this pull request to the merge queue Sep 15, 2026
Merged via the queue into microsoft:main with commit 43e4b6a Sep 15, 2026
40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ag-ui Usage: [Issues, PRs], Target: AG-UI protocol integration python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: AG-UI workflow reasoning is dropped from thread snapshots, so intermediate output vanishes on hydration

4 participants