Repository navigation
Python: Keep every queued approval result in the AG-UI thread snapshot - #8851
Conversation
With ToolApprovalMiddleware, decisions collected across separate resumes are released as one batch. Both calls ran and streamed TOOL_CALL_RESULT, but the snapshot kept only the result whose approval arrived in the final request, so the next turn replayed both calls with one result. Native approval results are dropped from the flow on the assumption that the snapshot merge restores them from the request's messages. A decision collected in an earlier request has no approval response there, so its result was lost. Carry those results into the merge so each is persisted next to its call. Fixes microsoft#8789 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for picking up #8789. I independently reran the unchanged reproduction against I also confirmed the separate reused-provider-ID case raised by Evan Mattson (@moonbox3) in this review. Extending the existing three-occurrence endpoint fixture to inspect the emitted snapshot, saved snapshot and next-turn provider input gives:
So my original distinct-ID reproduction passes, while the occurrence-aware regression remains open. These are local offline checks with the in-memory snapshot store; I haven't verified a deployed backend. Thanks for carrying the endpoint regression into the fix. |
|
/review |
There was a problem hiding this comment.
MAF Automated Review — Iteration 1
Result: No findings
Scope: full PR (1 commit(s)): 1b7ef23054a5
Model: gpt-5.6-sol
Overview
The change records native queued approval results during streaming and carries only results absent from the current messages into the existing snapshot merge. Collection remains limited to lifecycle-authorized, non-hosted in-run results, while the new regression covers streamed snapshot order, persisted history, and next-turn replay. No additional publishable issue was established after reconciliation and deduplication against the supplied existing feedback.
Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
No publishable findings remained after source verification for this scope.
…merge Distinct function-call occurrences can share one provider call id, for example hidden siblings released with a guarded call. The snapshot merge keyed resolved results by call id, so only the last one survived and the emitted and persisted snapshots dropped the rest. _resolved_tool_result_snapshot_messages now groups results by call id in resolution order, and the merge places every result for a call id after the assistant message that declares it. Results carried from earlier requests are matched by result object instead of call id, so a reused id cannot hide a result that was never folded into this request's messages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Evan Mattson (@moonbox3) I addressed your occurrence-aware dedup point in 439f366: results are now grouped per call id in resolution order, and the carry-over filter matches by result object instead of |
|
I rechecked
Both new endpoint regressions pass locally ( The smaller reproduction is in the existing history sanitizer: from agent_framework import Content, Message
from agent_framework_ag_ui._message_adapters import _sanitize_tool_history
for ids in [("shared",) * 3, ("call-a", "call-b", "call-c")]:
messages = [Message(role="assistant", contents=[
Content.from_function_call(
id=f"occurrence-{n}", call_id=cid,
name=f"tool_{n}", arguments="{}",
) for n, cid in enumerate(ids)
])]
messages += [Message(role="tool", contents=[
Content.from_function_result(call_id=cid, result=f"result-{n}")
]) for n, cid in enumerate(ids)]
print([c.result for m in _sanitize_tool_history(messages)
for c in m.contents if c.type == "function_result"])
# Shared ID: ['result-0']
# Distinct IDs: ['result-0', 'result-1', 'result-2']This identifies a remaining replay boundary in the existing sanitizer; I am not claiming this PR introduced it. No competing patch from me. The snapshot correction itself is verified in these cases. Environment: Python 3.12.11, core/AG-UI/orchestrations imported from this PR checkout, |
The provider history sanitizer collapsed function calls that share a call id into a single pending id, so the first result consumed it and the results of later occurrences were dropped from replay. _deduplicate_messages did the same by keying tool results on call id alone. The sanitizer now tracks one pending entry per function call and clears one per result, while still injecting a single synthetic result per missing call id. Deduplication keeps as many results for a call id as the kept assistant messages declare occurrences of it, and still drops or fills in replays beyond that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
gomission.ai (@gomission) thanks for the precise repro. 07c6c69 makes the replay path occurrence-aware: _sanitize_tool_history now tracks one pending entry per function call (so your shared-ID case keeps result-0/1/2), and _deduplicate_messages had the same collapse — it now keeps one result per declared occurrence. Going end-to-end, there's one more layer: _emit_tool_call treats consecutive same-call_id calls as argument chunks of one call, so the stream and snapshot carry a single tool call before the three results, and the next turn still replays one call. Since AG-UI identifies tool calls by toolCallId, fixing that changes stream emission rather than this snapshot merge. Evan Mattson (@moonbox3) would you prefer that in this PR or as a follow-up? |
A later turn that reuses a call id already completed earlier lost its pairing in two places: - _deduplicate_messages keyed assistant tool-call messages by call ids only, so the later turn's call was dropped as a duplicate and its result went with it. Assistant messages with a message_id are now deduplicated by id like other messages, and each declared occurrence opens one result slot, so a replayed result is still dropped. - _merge_resolved_approval_results_into_snapshot bucketed results by call id across the whole snapshot and drained the bucket at the first declaring assistant message, attaching the new result to the earlier call and keeping the old one twice. Results are now keyed by call id and declaration occurrence and placed after the matching assistant message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_sanitize_tool_history treated a call id as answered when a result for it appeared anywhere in history, so a later turn reusing a completed call id and then abandoning it got no synthetic skipped result and replayed as an unpaired call. Only results from the flush point on, before the call id is declared again, now count as answering the pending occurrence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Retested exact head The smaller sanitizer example I posted there now retains The unchanged endpoint reproduction still fails only at its next-turn assertion: all three tools execute once; all three results are streamed and retained in the emitted and persisted snapshots; the next model turn receives only Leaving the same-batch stream-emission scope decision with the reviewers. This is a verification follow-up; no competing implementation from me. Environment: Python 3.12.11 on macOS arm64; core/AG-UI/orchestrations imported from this PR checkout; |
Fixes the reportOptionalIterable error from the test typing checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
5647ee9 fixes the gomission.ai (@gomission) thanks for re-verifying On the remaining same-batch case: when hidden siblings share a call ID within one assistant turn, |
_approval_state_tool_call_ids returned the call ids of every approval occurrence the lifecycle still holds, including terminal ones that are only retained for replay. A later turn that reuses such a call id and is then abandoned was treated as server-owned, so _sanitize_tool_history never added a skipped result and the next request replayed an unpaired call. Only non-terminal occurrences (and queued approval state) now protect their call ids. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Motivation & Context
With
ToolApprovalMiddleware, approval decisions collected across separate AG-UI resumes are released as one batch. Both calls execute and bothTOOL_CALL_RESULTevents are streamed, but theMESSAGES_SNAPSHOTand the persisted thread snapshot keep only the result whose approval arrived in the final request. The next user turn then replays both calls with only one result, leaving an orphaned call in history.Description & Review Guide
_run_agent_stream, native approval results are removed fromflow.tool_resultson the assumption that_merge_resolved_approval_results_into_snapshotrestores them from the request'smessages. Results only reachmessagesby being matched to an approval response in the current request, so a decision collected in an earlier request had no match and its result was dropped from the snapshot.messagesinto the existing merge, so each result is persisted directly after the assistant message that issued its call, in call order.test_endpoint_queued_approval_snapshot_retains_every_released_result, which drives the queued two-approval flow through the endpoint with a snapshot store and checks the streamed snapshot, the persisted snapshot, result order, and next-turn replay. It fails onmainwith['call_second']._build_tool_approval_queue_endpointgained an optionalsnapshot_storefor it._merge_resolved_approval_results_into_snapshotis the right seam, versus folding them intomessageswhen the result streams. I chose the former so the in-flightmessageslist is not changed while the run is streaming.This preserves the documented "AG-UI approval result event: approved result emits once with content and persists in snapshot" behavior from
docs/specs/004-python-function-calling-loop.md, so the spec is unchanged.Related Issue
Fixes #8789
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.🤖 Generated with Claude Code