Skip to content

[bug] ACP permission gates are raised serially, so parallel gated tool calls are approved one at a time #5391

Description

@mmabrouk

What happens

Ask an agent to do two things that each need approval in a single turn — the canonical case is "read file A and file B" where the read tool is gated with an ask rule. The model issues both tool calls together in one turn, but the human is shown only one approval card. You answer it, the tool runs, and only then does the second card appear. You approve the files strictly one at a time, one card per turn, even though the agent asked for both at once.

This is the user-visible half of #5373 ("HITL breaks on multi-file approval flow… the interaction takes additional turns"). PR #5382 built the runner-side machinery to show and answer several cards together, but a live QA pass found the cards never arrive together, because the harness never asks for them together.

Why: the ACP adapter serializes permission requests

Live QA on the EE dev stack (driving the real product endpoint, asserting on the SSE frame stream and the runner log, never on model prose) established that both agent harnesses serialize their permission requests:

  • Claude (claude-agent-acp 0.58.1). Two parallel gated tool calls in one turn produce exactly one request_permission from the adapter. The adapter blocks on that request and does not issue the next one until it is answered. The runner logs its [HITL] ACP gate … line (which sits at the entry of the permission handler, before any pause or park logic) exactly once per turn, even when two tool-input-available frames for two distinct tool-call ids stream on the wire.
  • Pi (pi-builtin gate path). Same shape: two parallel gated bash calls in one turn, both tool inputs stream, but only one [HITL] pi-gate … line is logged and only one tool-approval-request frame is emitted. The sibling is force-settled.

In both cases the runner pauses the turn on the first gate; the sibling gated call is force-settled as TOOL_NOT_EXECUTED_PAUSED (surfaced as a tool-output-error frame) and re-raised on the next turn after the first gate is answered. On the warm keep-alive path this next gate arrives on the same live session with no model re-plan between them, so the chain is not slow — but it is still one card at a time, one turn per gate.

Turn-1 wire frames (Claude, "read /etc/hostname and /etc/os-release", permission.default = ask)

tool-input-available   toolu_013f…  (Read /etc/hostname)
tool-approval-request  toolu_013f…  approvalId=7defd9b6      <- the ONE card
tool-input-available   toolu_01FU…  (Read /etc/os-release)   <- second read's input arrives after the card
tool-output-error      toolu_01FU…                           <- second read force-settled, not carded
finish                 finishReason=other                    <- turn parks on the one gate

Where the block lives

The serialization is upstream of the runner. The runner's plural machinery (PR #5382: one card per gate keyed by tool-call id, a parkedApprovals map, one warm resume that answers each gate by its own permissionId) is ready to surface and answer several concurrent cards. It is never exercised live because the adapter only ever hands the runner one pending request_permission at a time. The adapter's blocking respondPermission contract — issue one permission request, await its answer, then continue the prompt — is what forces the one-at-a-time UX.

Direction (not yet scoped)

  • Confirm at the ACP layer whether claude-agent-acp (and the Pi builtin-gate path) can hold several request_permission calls open concurrently, or whether serialization is baked into the adapter's prompt loop.
  • If serialization is fixed upstream, investigate a non-blocking / batched permission-request mode so the harness can raise every gated call in a turn at once and the runner can surface them as the several cards PR feat(runner): reliable multi-gate approvals: truthful results, persisted answers, per-card dispatch #5382 already supports.
  • If it cannot be changed upstream, decide whether the runner should synthesize the concurrent set itself (raise cards for every announced-but-not-yet-gated sibling in the same turn), rather than force-settling siblings and waiting for the adapter to re-raise them.

Links

Metadata

Metadata

Assignees

No one assigned

    Labels

    Bug ReportSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions