Repository navigation
Python: [Python][AG-UI] Rejecting one approval in a parallel approval batch leaves the run stuck: approved calls never execute and the rejected request is re-issued #9552
Description
Versions
agent-framework-core1.19.0,agent-framework-ag-ui1.4.0 (observed)- The same conditions are still present in tag
python-1.22.0(agent-framework-ag-ui1.6.0) and onmainat the time of writing (_agent_run.pyaround lines 1877 / 1881;_tools.pymissing_request_ids/waiting_requestsunchanged)
Summary
When a model emits several tool calls in one response and every call requires approval, the AG-UI host produces one interrupt per call. If the client resumes with a mix of decisions, for example [approve, reject, approve], then:
- none of the approved calls execute;
- the rejected request is re-issued as a new pending interrupt with the same interrupt id;
- any resume answering that re-issued interrupt is refused with
APPROVAL_RESUME_MISSING_INTERRUPT, because the resume contract still expects all three original interrupts.
The thread stays blocked until the approvals expire. All-approve and all-reject batches work. Answering only some of the interrupts is refused by the contract, as expected.
Steps to reproduce
- Register a tool with
approval_mode="always_require". - Use a chat client that returns three parallel function calls to that tool in a single response.
- Run through the AG-UI endpoint. The run finishes with
outcome.type == "interrupt"and three interrupts A, B and C. - Resume the thread with a new run id. Answer all three interrupts: A
{"approved": true}, B{"approved": false}, C{"approved": true}. The behaviour is the same with B sent asstatus: "cancelled". - Observe the resume run:
- no
TOOL_CALL_RESULTfor A or C; - the run finishes with
outcome.type == "interrupt"again, carrying B's interrupt id.
- no
- Resume again, answering B only. The resume is refused with
APPROVAL_RESUME_MISSING_INTERRUPT; the contract still lists A, B and C.
Expected
A and C execute, B produces a "rejected" function result, and the run continues to the final answer, as it does when all three are approved.
Actual (traced)
agent_framework_ag_ui/_agent_run.py_canonical_approval_resume_messages→_approval_lifecycle.pyresolve_batch(≈630) →claim_batch(≈512). A and C becomeCLAIMED; B becomesREJECTED._agent_run.py_resolve_approval_responses(≈1665) only defers a response to core in-run execution whenprimary_response.approved is True(≈1846 in 1.4.0; ≈1877 / 1881 on main). B is rejected, so_replace_approval_contents_with_resultsreplaces it with a "rejected by user"function_result, and core never receives B's response.agent_framework/_tools.py_resolve_approval_responses(≈4385) →_stage_approval_batch_responses(≈3005). The session statealready_approved_approval_request_groupsholds the group[A, B, C], stored by_store_already_approved_approval_requests(≈2974). Only A and C are present, so the group is held back: B's pending request is returned aswaiting_requests(≈3061–3072), and the action becomes"return"(≈4430–4437). No tool executes.- Core returns B's
function_approval_requestagain.approval_state_store.register(≈3497) creates a new pending occurrence with B's interrupt id (the old one is terminal), and the run interrupts on B. - In the stream's
finally(≈3540–3553), A and C go throughrecover_unfinished→release_claim(PRESERVE_PENDING_RETENTION)back toPENDING. They keeprequires_client_resume=Trueanddecision.accepted=True. - The next resume answering B alone fails
_resume_contract_error(_run_common.py≈236–271) withAPPROVAL_RESUME_MISSING_INTERRUPT, because A and C are still pending client decisions.
Possible fixes
- Let core see the rejection. In ag-ui
_resolve_approval_responses, also defer LOCAL rejections (and cancellations) to core when in-run execution is active, so core receives every response in the group and produces the rejected result itself. - Keep the group consistent with ag-ui's replacement. When ag-ui replaces a rejected or cancelled approval with a
function_result, also remove that request id from the core session state'salready_approved_approval_request_groups, so the remaining approved ids form a complete group and are released.
We currently work around this on our side with option 2: before handing the resume to the host, we remove rejected and cancelled ids from the group. Approved calls then execute and the run completes. We would like to drop the workaround once this is fixed upstream.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
[-][Python][AG-UI] Rejecting one approval in a parallel approval batch leaves the run stuck: approved calls never execute and the rejected request is re-issued[/-][+]Python: [Python][AG-UI] Rejecting one approval in a parallel approval batch leaves the run stuck: approved calls never execute and the rejected request is re-issued[/+]its-amann commentedon Oct 20, 2026
Hi! I'd like to work on this. Eduard van Valkenburg (@eavanvalkenburg), could you assign it to me? I'll follow up with a PR that includes a fix and tests.