Python: fix: preserve parallel function_result contents in AG-UI conversion - #7980
Conversation
…ersion agent_framework_messages_to_agui collapsed a tool Message carrying multiple function_result contents (parallel tool calls) into a single AG-UI tool message, keeping only the last call_id/result. Clients then saw the missing results as pending tool approvals. Emit one AG-UI tool message per function_result, each with its own toolCallId and content, with distinct ids so downstream clients keyed by id do not re-collapse them. Fixes microsoft#7979
There was a problem hiding this comment.
Pull request overview
Fixes silent loss of parallel tool results during Agent Framework-to-AG-UI conversion.
Changes:
- Emits one AG-UI tool message per function result.
- Adds distinct IDs and parallel-result regression tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
_message_adapters.py |
Splits batched function results into tool messages. |
test_message_adapters.py |
Tests parallel and None results. |
Suppressed comments (1)
python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py:1031
- The unconditional
continuediscards any text or function-call contents collected from the sameMessage. Mixed finalized messages are a supported shape (for example,tests/ag_ui/test_workflow_run.py:830-833contains a function result followed by assistant text), so converting such history now silently loses that assistant output. Emit the split tool results, then also emit a separately identified normal AG-UI message whencontent_textortool_callsis present.
continue
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
A single Agent Framework message can carry function_result contents alongside text or function_call contents (e.g. a finalized assistant turn). The parallel-result fix emitted the tool messages then returned early, dropping that leftover content. Emit a separate, distinctly-identified AG-UI message for any remaining text/tool_calls after the split tool results, so mixed messages are preserved instead of silently losing the assistant output.
Deriving suffixes from the source id (f"{base_id}-{idx}") could collide
with a legitimate message id elsewhere in the converted history, letting
id-keyed clients re-collapse a result. Keep the source id for the first
result and generate an independent id for each additional message.
| # message an independent generated id. Deriving suffixes from the source | ||
| # id (e.g. f"{base_id}-1") risks colliding with a legitimate id elsewhere | ||
| # in the history, which would let id-keyed clients re-collapse results. | ||
| for idx, fr in enumerate(function_results): |
There was a problem hiding this comment.
I think we need a follow up here...
Could we preserve the original call/result order when splitting mixed messages? This loop emits every function_result before the separate message containing tool_calls, so a framework-produced [function_call, function_result] message round-trips as [function_result, function_call]. Providers require a tool result to follow its matching assistant call, so AGUIChatClient can send an orphan result that is rejected or dropped while the later call remains pending. Walking msg.contents and flushing assistant call/text segments before each result would preserve parallel results without reversing the transcript.
There was a problem hiding this comment.
You're right, thanks for the careful follow-up. Emitting the results before the tool_calls segment reverses a [function_call, function_result] message into [function_result, function_call], which orphans the result against its call. Walking msg.contents in order and flushing any accumulated assistant call/text segment before each function_result preserves the original call→result ordering, keeps parallel results intact, and keeps trailing assistant text after the results. I'll open a follow-up PR with that change plus a regression test for the [function_call, function_result] round-trip, and link it here.
There was a problem hiding this comment.
Opened the follow-up: #8005. It walks msg.contents in order and flushes the assistant call/text segment before each function_result, so [function_call, function_result] now emits as [assistant(call), tool(result)] instead of reversing. Added regression tests for the ordering plus the parallel-result and trailing-text cases from #7980.
Motivation & Context
When a single Agent Framework
Messagewithrole="tool"carries multiplefunction_resultcontents — which is common when a model issues parallel tool calls in one turn —agent_framework_messages_to_aguiemitted only one AG-UI tool message (the lastfunction_result) and silently dropped the rest.Because the upstream assistant message still lists every
tool_call, clients (e.g. assistant-ui'sfromAgUiMessages) treat the missing results as unresolved tool calls and render Allow / Deny approval prompts on historical chats. No exception is raised — it is silent data loss during conversion.Description & Review Guide
What are the major changes?
In
agent_framework_messages_to_agui(_message_adapters.py), the per-message loop used scalarcontent_text/tool_result_call_idvariables that eachfunction_resultoverwrote, so only the last survived. The loop now collects allfunction_resultcontents and emits one AG-UI tool message per result, each with its owntoolCallIdandcontent.To keep the split messages uniquely addressable (so downstream clients keyed by message id don't re-collapse them), the first result keeps the source
message_idand subsequent results get"{message_id}-{idx}".What is the impact of these changes?
Parallel tool results now round-trip through AG-UI intact; historical conversations no longer show phantom Allow/Deny prompts. The single-result path is unchanged (same id, same output), so existing behavior is preserved. No public API changes.
What do you want reviewers to focus on?
The id scheme for split messages (
message_idfor the first,"{message_id}-{idx}"for the rest) — whether that suffixing convention is preferable to generating fresh event ids for all but the first.Related Issue
Fixes #7979
Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.