Skip to content

Python: fix(ag-ui): start each replayed declaration-only tool call once - #9770

Open
eatmoreduck 多吃鸭 (eatmoreduck) wants to merge 2 commits into
microsoft:mainfrom
eatmoreduck:fix/agui-duplicate-tool-call-start
Open

eatmoreduck 多吃鸭 (eatmoreduck) wants to merge 2 commits into
microsoft:mainfrom
eatmoreduck:fix/agui-duplicate-tool-call-start

Conversation

@eatmoreduck

Copy link
Copy Markdown

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_START events 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 against FlowState/_emit_content.

Description & Review Guide

  • What are the major changes? _emit_tool_call now guards TOOL_CALL_START with 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.
  • What is the impact of these changes? With two declaration-only calls, the function loop pauses and replays both calls after streaming; the cursor sits on the second call, so the replay of the first (and then the second) re-emitted TOOL_CALL_START and 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 fresh FlowState starts nothing, so replayed ids on a new run still START normally.
  • What do you want reviewers to focus on? That tool_calls_by_id is only populated on the START path here (the approval confirm_changes and MCP paths are separate), so membership means "already STARTed by _emit_tool_call in 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_START per id and no duplicate snapshot entries (fails on main, passes with the fix).

Related Issue

Fixes #9638

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
Copilot AI balanced review requested due to automatic review settings October 27, 2026 16:58
@agent-framework-automation agent-framework-automation Bot added the python Usage: [Issues, PRs], Target: Python label Oct 27, 2026
@github-actions github-actions Bot changed the title fix(ag-ui): start each replayed declaration-only tool call once Python: fix(ag-ui): start each replayed declaration-only tool call once Oct 27, 2026

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

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:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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:

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.

@eatmoreduck

Copy link
Copy Markdown
Author

@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.
@eatmoreduck

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

This branch was previously deployed

1 inactive deployment
github-app-auth — 803db775 Deployed Oct 27, 2026 by eatmoreduck via add_label #26347
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: AG-UI starts each tool call twice when an agent calls two declaration-only tools in one response

2 participants