Skip to content

Python: [Bug]: AG-UI approval resume executes only the first of several approved calls in one batch — the rest are silently dropped #7569

Description

Description

Summary

When a model emits two or more approval_mode="always_require" calls in one assistant message, AG-UI correctly surfaces one interrupt per call, and a client that approves all of them gets exactly one executed. The others are dropped with no error, no event and no trace in the transcript: no TOOL_CALL_START, no result, and nothing in the final MESSAGES_SNAPSHOT.

The model therefore never learns the outcome of a call it made and the user approved, and a UI that renders tool calls from the event stream is left with a chip that never resolves.

A batch of one gated call plus never-require siblings works correctly — the siblings ride along in the pending entry's already_approved_requests and are released on the resume. It is specifically two or more gated calls that lose all but the first.

Reproduction

Self-contained, no network or credentials — a scripted chat client emitting a two-call batch, two always_require tools, and the stock AG-UI agent:

batch = [
    Content.from_function_call(call_id="c1", name="deploy_change", arguments={"target": "a"}),
    Content.from_function_call(call_id="c2", name="deploy_other", arguments={"target": "b"}),
]
# turn 1: run with a single user message -> RUN_FINISHED(outcome=interrupt, interrupts=[c1, c2])
# turn 2: resume with BOTH ids resolved/accepted
resume = [
    {"interruptId": "c1", "status": "resolved", "payload": {"accepted": True}},
    {"interruptId": "c2", "status": "resolved", "payload": {"accepted": True}},
]

Observed on the resume run, verbatim, against main @ d56e81357e5179b6f916b180c0cb34ea2e73c80b:

turn 1 -> interrupts surfaced: ['c1', 'c2']
turn 1 -> executed: []

resume -> executed:      ['deploy_change(a)']
resume -> results:       [('c1', 'deployed a')]
resume -> RUN_ERROR:     none
resume -> event types:   ['RunStartedEvent', 'ToolCallResultEvent', 'TextMessageStartEvent',
                          'TextMessageContentEvent', 'TextMessageEndEvent',
                          'MessagesSnapshotEvent', 'RunFinishedEvent']
resume -> snapshot roles: ['user', 'tool', 'assistant']

VERDICT: approved 2 call(s), executed 1

Expected: both tools execute, both results stream, and the snapshot carries two role="tool" messages.

The chat client's view of the resume turn confirms the loss is not a display artifact — it is handed user/text and a single tool/function_result for c1, with no approval response, no result and no function call for c2 anywhere in the transcript.

Where it is lost

Not in the AG-UI resume synthesis. Instrumenting _canonical_approval_resume_messages on the resume shows it doing exactly the right thing for both entries:

[synthesis] messages=2  handled=['c1', 'c2']  err=None

Both interrupt ids are validated, both approval responses are built, and no resume error is raised. Execution then produces one result. So the drop is downstream of the AG-UI layer, where the synthesized approval responses are turned into function invocations — the second response is present in the input and does not lead to a call.

That also explains the asymmetry with never-require siblings: those are carried inside the gated entry as already_approved_requests (_PendingApprovalWithSiblings) and are released as part of one approval, whereas two gated calls are two independent entries whose responses must each drive an execution.

Impact

An approved tool call that silently does not happen is worse than one that fails: nothing surfaces to the user, the model proceeds as though the call never existed, and any UI that pairs TOOL_CALL_START with a result shows an unresolvable pending state.

It also bites hardest exactly where batching is most natural — a plan that approves several writes at once, or a model that emits parallel calls for speed. In our own deployment the mitigation is an app-side shim that pops the parked approvals and executes them itself, which then has to re-stream and re-seat the results because the framework has no record of a call it never ran.

Prior art (checked before filing)

Affected packages: agent-framework-core (the function-invocation path that consumes synthesized approval responses). The AG-UI layer (agent-framework-ag-ui) appears to do its part correctly — see Where it is lost.

Verified against: live main @ d56e81357e5179b6f916b180c0cb34ea2e73c80b (2026-08-04T17:23Z, core 1.13.0) — installed fresh and reproduced with the stock agent_framework_ag_ui.AgentFrameworkAgent, no application wrappers of any kind. Also reproduces on 5f3ca8f9 and 07511b80.

Code Sample

Error Messages / Stack Traces

Package Versions

agent-framework-core: 1.13.0, agent-framework-ag-ui: 1.0.1

Python Version

No response

Additional Context

Suggested direction

The synthesized approval responses already carry everything needed — the AG-UI layer builds one per approved interrupt and reports both as handled. The gap is that only the first drives an invocation, so the natural fix is for the approval-response consumer to iterate every response present in the input rather than resolving a single pending approval per resume, and to release each one's call independently.

A regression test that approves two gated calls in one batch and asserts two executions and two results would pin it; the never-require sibling case should keep passing unchanged, since it already does.

Metadata

Metadata

Assignees

No one assigned

    Labels

    pythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflowtriageUsage: [Issues], Target: All issues that still need to be triaged

    Type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions