Skip to content

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

Open

Description

@Ryusei-0407

Versions

  • agent-framework-core 1.19.0, agent-framework-ag-ui 1.4.0 (observed)
  • The same conditions are still present in tag python-1.22.0 (agent-framework-ag-ui 1.6.0) and on main at the time of writing (_agent_run.py around lines 1877 / 1881; _tools.py missing_request_ids / waiting_requests unchanged)

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

  1. Register a tool with approval_mode="always_require".
  2. Use a chat client that returns three parallel function calls to that tool in a single response.
  3. Run through the AG-UI endpoint. The run finishes with outcome.type == "interrupt" and three interrupts A, B and C.
  4. 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 as status: "cancelled".
  5. Observe the resume run:
    • no TOOL_CALL_RESULT for A or C;
    • the run finishes with outcome.type == "interrupt" again, carrying B's interrupt id.
  6. 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)

  1. agent_framework_ag_ui/_agent_run.py _canonical_approval_resume_messages → _approval_lifecycle.py resolve_batch (≈630) → claim_batch (≈512). A and C become CLAIMED; B becomes REJECTED.
  2. _agent_run.py _resolve_approval_responses (≈1665) only defers a response to core in-run execution when primary_response.approved is True (≈1846 in 1.4.0; ≈1877 / 1881 on main). B is rejected, so _replace_approval_contents_with_results replaces it with a "rejected by user" function_result, and core never receives B's response.
  3. agent_framework/_tools.py _resolve_approval_responses (≈4385) → _stage_approval_batch_responses (≈3005). The session state already_approved_approval_request_groups holds 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 as waiting_requests (≈3061–3072), and the action becomes "return" (≈4430–4437). No tool executes.
  4. Core returns B's function_approval_request again. 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.
  5. In the stream's finally (≈3540–3553), A and C go through recover_unfinished → release_claim(PRESERVE_PENDING_RETENTION) back to PENDING. They keep requires_client_resume=True and decision.accepted=True.
  6. The next resume answering B alone fails _resume_contract_error (_run_common.py ≈236–271) with APPROVAL_RESUME_MISSING_INTERRUPT, because A and C are still pending client decisions.

Possible fixes

  1. 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.
  2. 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's already_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.

Activity

  1. added
    pythonUsage: [Issues, PRs], Target: Python
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Oct 20, 2026
  2. changed the title [-][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[/+] on Oct 20, 2026
  3. its-amann commented on Oct 20, 2026

    @its-amann

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    pythonUsage: [Issues, PRs], Target: PythontriageUsage: [Issues], Target: All issues that still need to be triaged

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions