Repository navigation
Python: fix(ag-ui): start each replayed declaration-only tool call once - #9770
eatmoreduck 多吃鸭 (eatmoreduck) wants to merge 3 commits into
Conversation
Guard TOOL_CALL_START with the started-call set instead of the last-id cursor: with two declaration-only calls in one response, the cursor sits on the second when the function loop replays the pauses, so both calls re-emitted START and duplicated in the thread snapshot. Fixes microsoft#9638
There was a problem hiding this comment.
🟡 Changes recommended
Run-wide ID deduplication suppresses distinct calls that reuse completed IDs and can corrupt their snapshot arguments.
1 open issue
What changed in this PR
This PR addresses #9638 in the Python AG-UI integration by preventing duplicate tool-call starts and snapshot entries when multiple declaration-only calls are replayed.
Changes:
- Replace the last-call-ID guard with a lookup of already registered call IDs.
- Explain the replay scenario in a code comment.
- Add a regression test checking that two replayed calls each start and appear in the snapshot once.
| File | Description |
|---|---|
python/packages/ag-ui/tests/ag_ui/test_run_common.py |
Add coverage for replaying two declaration-only calls. |
python/packages/ag-ui/agent_framework_ag_ui/_run_common.py |
Change and document the tool-call START deduplication guard. |
Review effort: Balanced
| # Why a started-set, not the last-id cursor: declaration-only calls replay after the | ||
| # function loop pauses; with two calls the cursor sits on the second, so the replay of | ||
| # the first (and then the second) re-emitted TOOL_CALL_START (#9638). | ||
| if content.name and tool_call_id not in flow.tool_calls_by_id: |
There was a problem hiding this comment.
Good catch — addressed in 803db77. The guard is now "is this call still open" (tool_call_id in flow.tool_calls_by_id and tool_call_id not in flow.tool_calls_ended) rather than run-wide id membership:
- a declaration-only replay of an open call stays deduplicated (the Python: [Bug]: AG-UI starts each tool call twice when an agent calls two declaration-only tools in one response #9638 fix holds);
- a completed id reused by a later distinct call STARTs again, replaces the snapshot entry instead of appending arguments into the old one, and
discards the ended marker so the second occurrence can emit TOOL_CALL_END again.
I first tried deduplicating by Content.id per the spec, but _emit_content can receive id-less function-call content on both the streamed and replayed paths, where the occurrence identity degrades back to the call id — the open/ended lifecycle is the signal that separates "replay of this occurrence" from "a new occurrence reusing the id" in both cases. The regression you suggested is in: stream → result → a distinct call reusing the same provider id asserts a fresh START, arguments not concatenated, and the call endable again. Full ag-ui suite: 1,695 passed.
|
@microsoft-github-policy-service agree [company=""] |
Guard TOOL_CALL_START on whether the call is still open instead of started-id membership: a completed id reused by a later distinct call STARTs again with a fresh snapshot entry and becomes endable again, while declaration-only replay of an open call stays deduplicated.
|
@microsoft-github-policy-service agree |
|
Thanks for picking this up, eatmoreduck 多吃鸭 (@eatmoreduck). I reported #9638, and I checked this PR locally at The fix works for #9638. The reproduction from the issue now gives one Two CI checks fail because of this PR:
One problem in the reused-id path. flow = FlowState()
_emit_content(Content.from_function_call(call_id="call_x", name="get_weather", arguments='{"city": "NYC"}'), flow)
_emit_content(Content.from_function_result(call_id="call_x", result="72F"), flow)
_emit_content(Content.from_function_call(call_id="call_x", name="get_weather", arguments='{"city": "LA"}'), flow)
print([tc["id"] for tc in flow.get_pending_without_end()])
The END state has to be tracked per occurrence, not per id. One way I tried: make return [tc for call_id, tc in self.tool_calls_by_id.items() if call_id not in self.tool_calls_ended]That gives exactly one END here, and the only new test failure is Once CI is green and the END count is fixed, this looks good to me. I used AI assistance for this review; I ran the checks and the reproduction above myself. |
1 similar comment
|
Thanks for picking this up, eatmoreduck 多吃鸭 (@eatmoreduck). I reported #9638, and I checked this PR locally at The fix works for #9638. The reproduction from the issue now gives one Two CI checks fail because of this PR:
One problem in the reused-id path. flow = FlowState()
_emit_content(Content.from_function_call(call_id="call_x", name="get_weather", arguments='{"city": "NYC"}'), flow)
_emit_content(Content.from_function_result(call_id="call_x", result="72F"), flow)
_emit_content(Content.from_function_call(call_id="call_x", name="get_weather", arguments='{"city": "LA"}'), flow)
print([tc["id"] for tc in flow.get_pending_without_end()])
The END state has to be tracked per occurrence, not per id. One way I tried: make return [tc for call_id, tc in self.tool_calls_by_id.items() if call_id not in self.tool_calls_ended]That gives exactly one END here, and the only new test failure is Once CI is green and the END count is fixed, this looks good to me. I used AI assistance for this review; I ran the checks and the reproduction above myself. |
The end-state check keyed only by call id: discarding the id when a provider reuses it reopens every snapshot entry with that id, including the occurrence that already ended, so the end-of-run loop emitted two TOOL_CALL_ENDs for one open call. get_pending_without_end() now reads from tool_calls_by_id — the latest occurrence per id — so a reused id ends exactly once. - _run_common.py: get_pending_without_end reads tool_calls_by_id - test_run.py: the hand-filled fixture populates tool_calls_by_id; a reused-id case counts the pending END entries (none while ended, one after the id is discarded) - test_run_common.py: the reused-id scenario asserts the end-of-run pending list holds exactly one entry; ToolCallStartEvent filters use isinstance so type checkers narrow the attribute; import order fixed
|
Thank you for the exceptionally thorough local review — everything is addressed in 17d6a6e:
Verified locally: |

Motivation & Context
When a model calls two declaration-only (client-side) tools in one response, the AG-UI endpoint starts each tool call twice: the client receives two
TOOL_CALL_STARTevents per call id and the thread snapshot records each call twice, so the next run fails when the provider rejects the duplicate call ids. A single declaration-only call per response is not affected. Reported in #9638, which includes a minimal reproduction againstFlowState/_emit_content.Description & Review Guide
_emit_tool_callnow guardsTOOL_CALL_STARTwith the started-call set (tool_call_id not in flow.tool_calls_by_id) instead of the last-id cursor (tool_call_id != flow.tool_call_id), and adds a comment explaining why.TOOL_CALL_STARTand re-appended the snapshot entry. Guarding on the set makes each call start exactly once. A single call was unaffected before only because the cursor happened to equal its own id; the set makes that correctness explicit rather than incidental. Resume semantics are unchanged: a freshFlowStatestarts nothing, so replayed ids on a new run still START normally.tool_calls_by_idis only populated on the START path here (the approvalconfirm_changesand MCP paths are separate), so membership means "already STARTed by_emit_tool_callin this run".The new regression test mirrors the issue's reproduction: stream two fully-argumented function calls, then replay both as declaration-only pauses, and assert exactly one
TOOL_CALL_STARTper id and no duplicate snapshot entries (fails on main, passes with the fix).Related Issue
Fixes #9638