* feat(acp): run re-entered, channel and Goal-proposal turns on the shared loop With experimental.unifiedAgentLoop on, channel prompts and Goal-proposal approval turns run as a UserQuery on the shared round, the shared tool batch and the client's stop point, and a retry, a continue and the restore of an unanswered ask_user_question run there with a Retry first round. ACP still strips and restores the re-entered attempt, still runs UserPromptSubmit on a retry through admitUserPrompt, and still answers a restored question with its own tool step. Core: userTurnRecordedByHost also covers a Retry's todo work chain, promptAdmission is accepted on a Retry, and the new input historyPromptId gives a Retry's request the identity of the attempt it replaces. The ACP parity suite gains a channel prompt and a retry after a model error. * fix(acp): treat a re-entered turn's pushed first request as sent on every exit On the shared round, whether the first request reached history is now read when the round ends, on every exit. A retry whose request was pushed and whose response then failed no longer puts the stripped attempt back beside it, and a restore's one-shot notices are taken once its answer is in history. A continue whose first request never goes out, cancelled at the loop top or failed, puts the interrupted run back as it was and keeps the worktree notice its dropped message took pending. A restore cancelled before its request went out keeps the full answer and takes its notices. The stripped-run restore and the Goal-proposal settlement block are shared helpers. The parity suite runs the channel scenario from a trusted parent so the channel mark survives, and adds a retry that fails again. Docs and comments are corrected. * fix(acp): keep a Goal proposal unsettled when its turn gives way or ends early In plan mode, the Todo Stop Guard on the shared round now still yields to a queued prompt or to input the round took, so a Goal proposal is not started from a turn that gave way. The client no longer drops a host-owned Goal proposal when a send ends abnormally. The host takes it when its turn ends, so a cancelled proposal turn still reports that the Goal did not start. A Retry whose admitted prompt is blocked puts back the attempt the client stripped from history.
63 KiB
Unified agent loop
- Status: accepted in #15343. PR 1 (headless) merged in #15369. PR 2a (the model round of ACP prompt turns) is in review in #15509.
- Date: 2026-10-23, against upstream
779a4d5d0d. - Related:
- tracking issue #15327;
- the earlier proposal #8775, which starts with AgentCore and headless; this design extends its goal to all seven loops, and its AgentCore step is PR 5;
- defects found while comparing the loops: #15324, #15325, #15326;
- loop protection setting.
Problem
The agent loop is the cycle that sends a prompt, streams the reply, runs the requested tools, sends their results back, and repeats until the model stops calling tools. Qwen Code has seven implementations of it, one or more per surface.
LlmClient.sendMessageStream runs one model request plus the continuations it owns: the Stop hook, steering at a model boundary and the next-speaker check. It never runs tools. Every surface therefore writes its own "run the tools, send the results, repeat" part, and three of them skip LlmClient altogether.
The copies have drifted apart. #15324, #15325 and #15326 are behavior bugs found only by comparing them, and #14114, #12209, #12020, #8542, #13851 and #12113 come from the same split. Every new loop-level behavior has to be wired into each copy. For example, the loop-protection reminders had to be injected separately in client.ts, agent-core.ts and Session.ts, and Mod tool checks reach only three of the seven loops.
Current behavior
Layers
| Layer | File | Owns |
|---|---|---|
LlmChat |
packages/core/src/core/llm-chat.ts |
provider request, retries, model fallback, max-tokens escalation and output recovery, auto-compaction, history |
Turn |
packages/core/src/core/turn.ts |
turns one model call's raw chunks into typed events (Content, Thought, ToolCallRequest, Retry, ModelFallback, Finished…); marks a call's arguments as truncated (wasOutputTruncated) or incomplete (hadIncompleteArguments) |
LlmClient |
packages/core/src/core/client.ts |
one interaction step: UserPromptSubmit, Goal admission, reminders, IDE context, loop detection, the session turn cap, then the Stop hook, steering and next-speaker continuations |
| surface | below | everything after the model stops with tool calls |
Loops
| Surface | Loop | Model call | Tool execution |
|---|---|---|---|
| Ink TUI | packages/cli/src/ui/hooks/use-llm-stream.ts |
LlmClient |
InteractiveModToolScheduler over CoreToolScheduler; the completion callback calls submitQuery(ToolResult) |
| OpenTUI | packages/cli/src/ui/opentui/live-session.ts |
LlmClient |
a new InteractiveModToolScheduler per batch |
Headless -p, stream-json SDK transport |
packages/cli/src/nonInteractiveCli.ts, with a second copy in the drain turn (:2888-3066) |
LlmClient |
executeToolCall per call, with its own partitioning, in-flight cap and ordered finalize |
ACP: Zed, VS Code companion, qwen serve / Web Shell, channels |
packages/cli/src/acp-integration/session/Session.ts, four loop bodies (main, Stop continuation, cron, notifications) |
LlmChat directly |
its own executor, runToolCalls / runTool |
| Hosted Harness | packages/cli/src/serve/hosted-harness-model.ts:828-1143 |
LlmClient, but fires 13 hook events itself |
journaled sequential dispatch (hosted-workspace-tool-turn.ts) |
| Subagents, background and forked agents, workflows, in-process teams | packages/core/src/agents/runtime/agent-core.ts |
LlmChat directly, parsing chunks itself |
one CoreToolScheduler per round |
| Speculation | packages/core/src/followup/speculation.ts |
LlmChat directly |
runs tools directly, without the scheduler |
Not loops, and out of this design: the forked-agent cache path (one request, no tools), the Omni fixed-policy orchestrator (no model call), and the ACP bridge, SDK and channel packages (transports).
Where the copies differ
-
Session turn cap.
LlmClientstops atmax > 0 && count > max(client.ts:4529-4531).- Headless stops at
max >= 0 && count > max(nonInteractiveCli.ts:663) with its own per-invocation counter. - The Hosted Harness stops at
max > 0 && round >= max(hosted-harness-model.ts:841). - ACP has no cap.
#15325 is the result: a stream-json message past the cap returns success with the previous answer.
-
Duplicate provider tool calls. The suppression is assembled four times (
nonInteractiveCli.ts:1962,use-llm-stream.ts:3236,Session.ts:14096,agent-core.ts:2085), and the key falls back three different ways. -
Concurrency.
- The cap is implemented four times (
coreToolScheduler.ts:5486,nonInteractiveCli.ts:2264,Session.ts:14375and:17041). - The scheduler runs every concurrency-safe call in parallel; ACP parallelizes only consecutive Agent calls.
- The cap is implemented four times (
-
Retry and fallback. Ink and headless drop the tool calls collected before a
RetryorModelFallback; OpenTUI does not (#15326). -
Partial edits. The scheduler refuses an Edit call whose arguments were cut off or arrived incomplete; ACP's executor does not (#15324).
-
Recording.
finalizeToolResponsesis called separately in six loops, and once more inside the scheduler.- OpenTUI records native tool results only when a Mod was involved (#15326).
-
Hooks and Mods.
- ACP re-implements UserPromptSubmit, the Stop hook and MessageDisplay.
- The Hosted Harness fires them itself and sends a Stop continuation as
ToolResultwhereLlmClientsendsHook. - Mod tool checks reach Ink, OpenTUI and headless only.
-
Missing on ACP. ACP has no next-speaker check, no session turn cap, no content loop heuristics and no late MCP/skill reminders (#14114).
Goals and scope
Goals:
- One implementation of the loop in
packages/core: model round, tool batch, continuation, and a typed outcome. Every surface uses it. - An opt-in rollout behind one setting. With the setting off, every surface keeps its current loop unchanged.
- A surface moves only when a parity suite shows that its requests and output match its current loop, apart from listed differences.
- One place to attach loop-level behavior such as hooks, Mods, loop protection, recording and telemetry.
Out of scope for this round:
- session-level scheduling, i.e. when notifications, cron jobs, Goal turns and teammate messages start a loop — each surface keeps its own;
- the Hosted Harness, which is evaluated separately after the other surfaces;
LlmChatand the provider layer;CoreToolSchedulerinternals beyond the hooks the loop needs;- UI rendering and wire protocols;
- changing a surface's default before it meets the conditions in Migration.
Design
Layering
Surfaces: Ink · OpenTUI · headless/SDK · ACP · AgentCore · speculation
│ input, event sink, scheduler wiring, mid-turn input, surface policies
▼
Shared agent loop (packages/core)
model round : LlmClient.sendMessageStream for a main session;
Turn over the agent's own LlmChat for subagents and speculation
tool batch : duplicate handling → scheduling → ordered results
continuation: finalizeToolResponses → recording → mid-turn input → ToolResult
outcome : a typed stop reason
▼
LlmClient → Turn → LlmChat (unchanged)
The loop is an async function. Every model event reaches the surface unchanged through a callback, so the existing surface adapters keep working, and tool results and live tool output reach it the same way. Decisions the surface owns are callbacks too. The loop returns a result whose stop reason the surface maps to its own outcome.
Model round
- The loop asks the surface for the round's send options. Examples: the send type, a model override, first-round fields such as
humanInput, and Goal fields. - It calls the model round and forwards every event to the surface in order.
- It collects
ToolCallRequestevents. - On
RetryorModelFallbackit drops the calls collected so far and tells the surface, which resets its partial output.
- It collects
- The client's terminal events become stop reasons, so a surface can no longer drop them:
MaxSessionTurns,SessionTokenLimitExceeded,UserPromptSubmitBlocked,LoopDetectedandError. - When the round ends with no tool calls, the loop stops with
completed. The Stop hook, steering and next-speaker continuations have already run insideLlmClient.
The main-session round is LlmClient.sendMessageStream. AgentCore and speculation need a round over their own LlmChat. PR 5 adds that round on Turn, so chunk parsing exists only in turn.ts. The interface between the loop and its round appears in that PR, when it gets its second implementation.
Tool batch
- Duplicates. A call that repeats a call id is not run again. Provider replays get the duplicate response, and a repeated provider id stops the loop with
duplicate_provider_call. This uses the existing helpers inturn.tsandtoolCallIdUtils.ts. - Selection. The surface may restrict which calls run (headless structured output). Every call that does not run still gets a response.
- Scheduling. The whole batch is scheduled on one scheduler.
- The default is
CoreToolScheduler. It already provides partitioning by concurrency safety, the in-flight cap, approvals, hooks, the partial-edit guard, Mod tool checks and runtime views. It also skips the sibling calls of a plan-mode entry; headless does that itself today only because it schedules one call at a time. - Tools run under the surface's tool signal, which may differ from the loop's own: headless adds a Goal turn's signal for tools only.
- The surface supplies the scheduler options it owns today: the approval wiring, progress output and the full-turn model callback.
- A surface whose Mods have tool hooks passes the Mod-aware scheduler it uses today, with the same
schedulecontract, so Mod behavior does not change: Ink and OpenTUI useInteractiveModToolScheduler, headless a per-call Mod runner.
- The default is
- Launch hook. One new
CoreToolScheduleroption,beforeToolLaunch(call), runs serially in request order before each launch. In a concurrent batch it runs for every call that fits in flight before any of them starts, so a refusal takes effect before the other calls of that run begin, as in headless today. Returningfalseleaves the call unexecuted. Calls that anexecscript makes are not asked; legacy headless does not count them either. The loop offers every call of the batch to the surface once, in request order; a call the scheduler settled without launching it (invalid arguments, a denial) is offered at its turn. Headless needs it for:- the exact
--max-tool-callscount, which ticks before each launch and counts failed calls too; - "the first valid
structured_outputends the session".
- the exact
- Results at batch end. Results reach the surface when the batch completes, in request order: replays first, then executed calls, skipped calls and repeated ids. Waiting for the batch lets the scheduler apply batch-level hook output and the batch output budget first, so the surface reports exactly what the model receives. Live tool output still streams as it happens.
Continuation
- All responses of the batch go through
finalizeToolResponsesonce, and each result is recorded once. Recording carries the Goal provenance, andrecordCompletedToolCallruns for each call. - The surface may add input taken at the tool boundary after the results:
- headless merges teammate messages and sends the turn as
Teammate; - later, Ink drains its message queue and ACP calls
craft/drainMidTurnQueue.
- headless merges teammate messages and sends the turn as
- A result with
terminateTurnstops the loop withtool_ended_turn; the surface finishes its Goal turn. - Otherwise the loop sends the parts as the next round, typed
ToolResult.
Outcome
The result holds the stop reason, the number of rounds and the last Turn. The stop reasons are:
completed;next_round—maxRoundswas reached; the result carries the tool results to send;cancelled— the loop's own signal was aborted; a cancel that comes only from a signal the client merged in, such as a Goal turn's, ends the round as if the model made no calls, as headless does today;loop_detected;duplicate_provider_call;tool_ended_turn;surface_ended— a callback ended the loop, e.g. a structured-output success;max_session_turns;session_token_limit;user_prompt_blocked;error.
The surface maps the reason. Headless maps it to exit codes and results; ACP later to stopReason; AgentCore to terminateMode.
Headless runs its main loop with maxRounds: 1. Its preparation of each round (teammate messages, the turn cap, the send type) therefore stays where it is today, and the loop owns the round itself. A drain item runs all of its rounds in one call.
Surface policies
Behavior that only some surfaces have is passed in as a policy instead of copying the loop. Examples:
- the
--max-tool-callsticker; - structured output;
- model-override precedence;
- teammate merging;
- and later, the daemon tool-loop guards and the Todo stop guard.
A policy affects only the surface that passes it.
Send options a host owns
Some surfaces did part of the interaction step themselves before they moved, and keep doing it on the shared path for a while. A send option then tells the client not to do that part, so it does not run twice. Each such option is a transition step, not a lasting API: it is listed here with the step that removes it or turns it into an input.
| Option | Why the host keeps the part | Removed by |
|---|---|---|
toolLoopGuardsByHost |
ACP's tool step runs the daemon tool-loop guards: the per-turn tool-call cap, repeated calls, the exploration reminder and the repeated-tool-failure guard | PR 2b for a fresh prompt's tool batches: the client's guards apply, and ACP keeps the ones only it has, its parameter-error, partial-edit and repeated-tool-failure stops, as its policy. A session with live tools and an Agent Host session keep ACP's tool step and this option until PR 2d |
interactionSpanByHost |
ACP's span is parented to the daemon's trace | an input instead: the client joins the span the host started for the prompt; before the default changes for ACP |
userTurnRecordedByHost |
ACP records the prompt as typed, with its display text, attachments, resource links and daemon prompt id, before slash commands run, and takes the turn's file-history snapshot before its first send, so a prompt cancelled before it is sent still has one; on a retry, a continue or a restore it has started the todo work chain | an input instead: the host passes that record and the client writes it; before the default changes for ACP |
toolCallPreparations, memoryRecallQuery, stopPolicies and historyPromptId are not in the table: the first asks for an extra event, the second gives recall the prompt's text, which the host expanded before the send, the third adds the host's reasons to continue at a stop point (Stop-point continuations), and the fourth gives a Retry the history identity of the attempt it replaces; none turns anything off. PR 2c-1 removed stopHookRunByHost, with which ACP fired the Stop hook in its own Stop loop.
A new option that turns off part of the client's work for one host needs a row here, and a surface's default does not change while it has an open row.
Stop-point continuations
When a response ends without tool calls, LlmClient decides whether the turn continues: steering, the Stop hook and the next-speaker check. Ink, OpenTUI and headless rely on that chain as it is. Before PR 2c-1, ACP continued at the same point for more reasons, in a Stop loop of its own that also fired the Stop hook: the Todo Stop Guard, the background results of the current turn, and input that arrived mid-turn.
The stop point is one step of the loop: the Stop hook runs first, and checks that only some surfaces have run in order after it, only once it lets the response stop. Each can continue the turn with a message or let it end. Queued input and background task notifications are taken in after a tool batch; at a stop point they do not continue the turn, and the next turn picks them up. One owner then decides whether a turn continues, and a surface adds its own reasons without a second loop around the client.
So the client's chain takes ordered surface stop policies (SendMessageOptions.stopPolicies):
- once the Stop hook lets the response stop, or when none is configured, the chain asks each policy in order;
- a policy returns the request to continue with, and the chain sends it as it sends a Stop-hook continuation (
Hook, under the turn's prompt id, with its own tool budget), or returns nothing and the next policy is asked; - when the Stop hook's blocking cap ends the turn, the policies are told (
onStopHookCap) and not asked.
ACP passes one policy, the Todo Stop Guard. The current turn's background results and input queued mid-turn are no longer continued at a stop point: ACP takes them at a tool boundary as before, and otherwise they start the next turn, a background-notification turn or the host's next prompt. The other surfaces pass no policy and do not change.
The setting
experimental.unifiedAgentLoop is a boolean, false by default, and takes effect after a restart. QWEN_CODE_ENABLE_UNIFIED_AGENT_LOOP=1 overrides it, which lets CI and scripts run the suites with the shared loop. Both follow the pattern of experimental.agentTeam / QWEN_CODE_ENABLE_AGENT_TEAM.
Config.isUnifiedAgentLoopEnabled() exposes it. Each surface reads it at its loop's entry point:
if (config.isUnifiedAgentLoopEnabled()) {
return runOnSharedLoop(...);
}
// current loop, unchanged
The current loop stays as the else branch and is not refactored while the setting exists. A surface that has not moved yet ignores the setting, and the setting's description names the surfaces it covers. ACP children started by qwen serve read the same settings, so the daemon and Web Shell follow the setting for the ACP turns that have moved. The Web Shell settings panel lists the setting from PR 2a on.
Once surfaces change their defaults one at a time, the setting has no schema default. Config.isUnifiedAgentLoopEnabled(surface) returns the setting when it is set, and otherwise the surface's own default. The environment variable still overrides both.
Headless on the shared loop (PR 1)
| Headless today | With the setting on |
|---|---|
while (true) round loop, plus a second copy in drainBatch |
one loop call per interaction; each drain item is another call with its own send type |
limitedTurnCount gate (exit 53) |
unchanged; the client's MaxSessionTurns now ends the run the same way instead of a false success (#15325) |
discardAbandonedAttempt on Retry / ModelFallback |
the loop drops the calls; the surface restarts its attempt and writes the retry frame |
processToolCallBatch duplicate and replay handling |
the loop |
structured_output pre-scan and first-success end |
surface policy: call selection plus a result callback that ends the loop |
| plan-mode-entry sibling skip | the scheduler, for the whole batch; the per-call Mod runner does it itself |
executeToolCall per call, partitionHeadlessToolCalls, the in-flight cap |
one scheduler per round, with the same partitioning and cap |
| tick before launch | beforeToolLaunch |
approvals: stream-json can_use_tool, one call at a time |
the same callback, for the whole batch before it runs |
| Mods with tool hooks run serially | the per-call Mod runner as the batch scheduler |
ordered finalize, finalizeToolResponses, recordToolResult with Goal provenance |
the loop |
model-override precedence (inline /model > full-turn vision > \0 sentinel) |
surface policy |
| teammate text merged into the tool-result turn | input at the tool boundary, sent as Teammate |
Goal terminateTurn |
stop reason tool_ended_turn |
LoopDetected → exit 1; a repeated duplicate id → loop-detected result |
stop reasons loop_detected, duplicate_provider_call |
abort → routeAbort (55 / 130) |
stop reason cancelled; the surface still routes the abort |
| idle phase: next Goal, teammates, cron, background holdback, memory, final result | unchanged, outside the loop |
ACP prompt turns on the shared round (PR 2a)
PR 2 moves ACP in steps. PR 2a moves the model round of a fresh user prompt: one that is not a retry, a continue or a restore, and not a Goal, channel or goal-proposal approval turn (PR 2c-2 moves all of them but Goal turns, which PR 2c-3 moves). The round is the loop's own runModelRound: one LlmClient.sendMessageStream call whose events go to ACP and whose terminal events become stop reasons. ACP keeps its tool step (runToolCalls, the next message after tools, the daemon tool-loop guards) and everything around the turn. PR 2b moves the tool batch of these turns onto the scheduler, and PR 2c-1 their stop point onto the client's chain. PRs 2c-2 to 2c-4 then move the other turns, so each of ACP's loop bodies changes once.
| ACP today | With the setting on |
|---|---|
chat.sendMessageStream behind #sendMessageStreamWithAutoCompression |
LlmClient.sendMessageStream through runModelRound: the first round is UserQuery, a round after tools is ToolResult, and input taken while a response streamed is Steer |
| raw chunks: text, thought, usage, function calls, tool-call preparations | the client's events: Content, Thought with its raw text, the usage on Finished, ToolCallRequest with the functionCall it came from, ToolCallPreparation |
| UserPromptSubmit through the message bus | admitUserPrompt, with the same input at the same point; the first round applies the verdict, and a blocked prompt is reported by that round without a request |
| the user message record, the todo work chain and the file-history snapshot | ACP's, as today; the first round says so with userTurnRecordedByHost |
| memory recall, plan, arena and output-style reminders, workflow intent, MessageDisplay | the client |
| compaction before the send and its notice; the session token gate | the client's compaction inside the send, with the same notice on ChatCompressed; the client's gate, reported as today (max_tokens) |
| the external Stop hook, its cap and its continuation | ACP's Stop loop, as today, which also runs the Todo Stop Guard and the current turn's background results; the round told the client with stopHookRunByHost until PR 2c-1 moved the stop point to the client's chain |
| the StopFailure hook and the 429 mapping when a request fails | ACP, as today, also for a stream Error event, with the error the provider threw |
| the managed memory tasks after the turn | ACP, when the turn ends as today, through the client's runManagedAutoMemoryBackgroundTasks; until PR 2c-1 the client skipped them under stopHookRunByHost, since ACP's Stop loop could still continue the turn |
| the interaction span | ACP's, as today; the round tells the client with interactionSpanByHost |
| the daemon tool-loop guards: the per-turn tool-call cap, repeated calls, the exploration reminder | ACP's, as today; the round tells the client with toolLoopGuardsByHost, so the client checks no tool-call patterns and adds neither the exploration reminder nor the todo reminder ACP's message carries |
| no content or thought loop heuristics, no session turn cap | the client's content and thought heuristics when model.skipLoopDetection is false, whose LoopDetected rejects the prompt as a loop (LOOP_DETECTED); MaxSessionTurns ends it with max_turn_requests |
The core changes of this step are additive and change nothing for a caller that does not use them:
TurngivesThoughtthe chunk's rawtextandToolCallRequestthefunctionCallit was built from, and yieldsToolCallPreparationfor a call the model started streaming when the send asks for it withtoolCallPreparations;SendMessageOptions.userTurnRecordedByHost,stopHookRunByHost,interactionSpanByHostandtoolLoopGuardsByHost, and the loop detector'stoolCallPatternscheck option;SendMessageOptions.memoryRecallQuery, what aUserQuery's memory recall searches for in place of the request;getStructuredErrorCausereturns the error aTurnErrorevent was built from;admitUserPrompttakes the hook'ssubmitted_promptand a signal;runModelRoundis exported.
ACP tool batches on the shared batch (PR 2b)
PR 2b moves the tool batch of the turns PR 2a moved. The batch is the loop's own runToolBatch on one CoreToolScheduler: duplicates and replays, scheduling, results in request order, finalizeToolResponses and recording. ACP keeps the loop around it: the next request after tools, mid-turn input and background results, and, until PR 2c-1, the Stop loop.
ACP's part is a translation of the scheduler's state changes:
| ACP's executor today | With the setting on |
|---|---|
runToolCalls / runTool: its own lookup, validation, the partial-edit guard, PreToolUse, PermissionRequest, the permission flow, AUTO mode, plan-mode shell rules, PostToolUse, image handling and telemetry |
the scheduler's |
| only consecutive Agent calls run concurrently | the scheduler's partitioning: every concurrency-safe call (Read, Search, Fetch, read-only shell, Agent) runs concurrently, under the same in-flight cap |
| a permission request when a call reaches its turn | session/request_permission for each call the scheduler holds in awaiting_approval, through ACP's permission queue, before any call of the batch runs; the answer goes to the call's onConfirm |
| a declined or failed request stops the turn after the call; the batch's later calls are skipped | the same stop; the batch's other calls are cancelled before they run |
tool_call start, lifecycle, shell heartbeat, subagent and result updates from inside runTool |
the same updates from the scheduler's callbacks: the start when a call launches, the result when it settles, an error update for a call refused before it ran |
| the per-turn tool-call cap, repeated calls and the exploration reminder | the client's, which no longer gets toolLoopGuardsByHost |
| the parameter-error and partial-edit stops, counted per turn, and the repeated-tool-failure guard | ACP's, from the batch's results |
| Goal-approval ownership, the standalone worktree limits | ACP's, through the scheduler's refuseToolCall |
| the PermissionPrompt Notification hook's terminal sequence | the scheduler's hook; ACP forwards the sequence it reports |
| a Managed session's batch close, the memory refresh after a memory write | ACP's, after the batch |
A session with live tools, which ACP trusts without the permission flow, and a read-only Agent Host session, which refuses approvals, keep ACP's executor and toolLoopGuardsByHost until PR 2d.
The core changes of this step are additive:
runToolBatchis exported, withkeepCancelledResults: a batch the loop's signal cancelled is still finished, recorded and returned, since ACP keeps a cancelled turn's results in history;CoreToolScheduleroptionsrefuseToolCall, the owner's own refusal of a call before it is built, andonPermissionPromptTerminalSequence.
ACP's stop point on the client's chain (PR 2c-1)
PR 2c-1 moves the stop point of the turns PR 2a moved onto the client's chain. ACP keeps its Stop loop only for the turns that have not moved.
| ACP's Stop loop today | With the setting on |
|---|---|
ACP fires the Stop hook, with its cap and its continuation under a _stop_hook_N prompt id |
the client's Stop hook and cap; the continuation is a Hook send under the turn's prompt id; ACP shows the hook's system message and, from the second block on, the Stop-hook loop update |
| the Todo Stop Guard, merged into the hook's continuation when both apply | a stop policy, asked once the Stop hook lets the response stop; it claims the continuation from the host, yields to a queued prompt, and stops after its attempts |
the current turn's background results continue the prompt (_background_result_N) |
not continued at the stop point; they start a background-notification turn |
| with the guard armed, input queued mid-turn is drained at the stop point and continues the prompt | not drained at the stop point; the host sends it as its next prompt, and the guard yields to it |
| input that arrives while the Stop hook runs discards its decision | the hook's decision stands |
| after a guard continuation's tools, the next request needs another guard attempt, or the results are kept unsent | the tool results are sent back as after any batch, and the guard decides at the next stop point |
| each continuation streams through ACP's legacy send path and runs ACP's tool step | the client's round and the shared tool batch, as for the rest of the turn |
| ACP runs the managed memory tasks after its Stop loop | the client runs them when the turn's last response stops on its own; ACP runs them when the turn ends otherwise, such as at the Stop-hook cap, on a Steer round or a tool that ended the turn |
The client keeps a host's span, tool-guard and stop-policy options on a Stop-hook continuation, so it neither starts a span of its own nor turns its guards on for the continuation's tools. ACP resets its own tool-guard state for each Stop-hook or Todo Stop Guard continuation, including when Live tools retain ACP's tool executor. Tool-result requests within a continuation keep that budget.
Hook diagnostics do not reopen a finished response's interruption window. Each new model request starts a new final-answer capture; a diagnostic with no following request keeps the preceding answer.
The core change of this step adds SendMessageOptions.stopPolicies with the StopPolicy interface and removes stopHookRunByHost, which only ACP passed.
ACP turns that re-enter a prompt, and channel and Goal-proposal turns (PR 2c-2)
PR 2c-2 puts five more kinds of ACP turn on the shared round, the shared tool batch and the client's stop point. Two are fresh prompts PR 2a left out: channel prompts and Goal-proposal approval turns. Three re-enter a turn the session already has: a retry, a continue of an interrupted turn and the restore of an unanswered ask_user_question. Goal turns, cron and background notifications move in PRs 2c-3 and 2c-4.
| Turn | With the setting on |
|---|---|
| channel prompt | a UserQuery like any fresh prompt, without the submitted prompt or workflow input, as today; its responses are not cut short by queued input, its tool results never end the turn, and its repeated-tool-failure guard stays off |
| Goal-proposal approval | a UserQuery; the session settles the proposal as today, and a Todo Stop Guard that gives way, or the Stop hook's cap, keeps the proposal from starting a Goal |
| retry | ACP strips the failed attempt and puts it back if the retry never sends, as today; the first round is a Retry with historyPromptId, the attempt's identity, and UserPromptSubmit's verdict from admitUserPrompt |
| continue | ACP strips the interrupted run, or closes the interrupted calls, as today; the first round is a Retry with historyPromptId and no UserPromptSubmit; a first request that never goes out puts the interrupted run back as it was |
restore of ask_user_question |
ACP asks the question again with its own tool step; the answer is the first round, a Retry; later batches run on the shared batch |
A re-entered turn's first round is a Retry, not a ToolResult, even when it carries tool results: a ToolResult does not start the client's interaction, so the previous prompt's loop-detector counts would carry over and UserPromptSubmit would run on the results. Headless continues an interrupted turn with a ToolResult because each of its runs is a new process. A Retry gets no system reminders from the client, so ACP adds its plan, arena and output-style reminders to it as today, and the todo reminder ACP takes is the one the client would take.
The restored question keeps ACP's tool step: the shared batch records every result, and a question left unanswered on a timeout must stay unrecorded so a later load can ask it again.
Whether a re-entered turn's first request went out is read from the chat's push count when the round ends, on every exit. A request in history replaces what the turn stripped and carries the restore's notices, even when its response then fails; a request that never went out leaves history as the turn found it, and the notices stay pending.
The core changes of this step: userTurnRecordedByHost also covers a Retry, whose todo work chain the host started; promptAdmission is accepted on a Retry; and the new input historyPromptId, the history identity a Retry's request carries, spent by its first request.
Migration
| PR | Content |
|---|---|
| 0 | this design |
| 1 | the shared loop, beforeToolLaunch, the setting, headless (-p and stream-json), the parity suite |
| 1d | headless: default on |
| 1r | headless: remove its current loop |
| 2a | ACP: the model round of a fresh user prompt moves to LlmClient; ACP's re-implementations stay out of the shared path, and ACP keeps its tool step |
| 2b | ACP: the tool batch of those turns on the scheduler; the daemon tool-loop guards become the client's guards plus a surface policy |
| 2c-1 | ACP: the stop point of those turns on the client's chain, with the Todo Stop Guard as a stop policy; stopHookRunByHost is removed |
| 2c-2 | ACP: channel, Goal-proposal, retry, continue and restore turns |
| 2c-3 | ACP: Goal turns |
| 2c-4 | ACP: cron and background-notification turns |
| 2d | ACP: default on, then remove its current loop |
| 3 | Ink TUI |
| 4 | OpenTUI |
| 5 | AgentCore on a Turn round (subagents, background and forked agents, workflows) |
| 6 | speculation |
| — | Hosted Harness: separate evaluation |
Each of PRs 3–6 is followed by its own default-on and removal steps.
A surface counts as moved when it passes its parity suite with the setting on and its known differences are listed. Each surface then changes its default on its own, without waiting for the others. Its default changes when:
- the setting has been available for that surface for at least one release;
- the scheduled job that runs the surface's integration suite with the setting on has passed on main for two weeks;
- the surface has no open row in Send options a host owns;
- the release notes list its intended differences from Compatibility.
Its current loop is removed one release after its default changed. Until then, setting experimental.unifiedAgentLoop to false brings the current loop back for that surface.
Parity suite
integration-tests/fake-openai-server.ts already records every request body.
How it runs. The suite runs each scripted scenario on a surface twice, once with QWEN_CODE_ENABLE_UNIFIED_AGENT_LOOP unset and once with it set to 1. It compares:
- the request sequence the model saw, normalized by removing timestamps, the date reminder, ids and durations;
- the surface's output: the stream-json frames for headless; for ACP the
session/updatenotifications and each prompt's stop reason, withoutavailable_commands_update, which arrives on its own schedule.
A difference fails the scenario unless it is listed with a linked issue.
First scenarios:
- plain text;
- one tool;
- a parallel read-only batch;
- mixed safe and unsafe calls;
- a Code Mode
execscript that calls a tool; - approval allowed and denied (stream-json
can_use_tool); - cancel during a tool;
- a mid-stream retry and a model fallback;
- an output-truncated edit;
- loop detection;
- a blocking Stop hook;
--max-tool-callsat the budget edge: a parallel batch, a sequential batch, a call rejected before it runs, and a batch that needs approval;--max-session-turnsacross two stream-json messages;- structured output with a sibling call;
- a teammate message merged into a tool-result turn.
ACP scenarios (integration-tests/cli/acp-agent-loop-parity.test.ts):
- plain text, and streamed text with a thought;
- one tool, and two reads in one response, whose updates are compared per call because the reads run concurrently on the shared batch;
- an approved write between reads, and a denied write, in the default approval mode;
- reads past the per-turn tool-call cap, without the end of the response the client's cap stops;
- an edit cut off by the output limit;
- a stream that drops and is retried;
- two prompts in one session;
- a channel prompt that reads a file, from a client that holds the private parent capability so the agent keeps the channel mark;
- a retry after a model error;
- a retry that fails again after its request reached history;
- a model error;
- a UserPromptSubmit hook that blocks;
- a Stop hook that blocks once, and one that blocks up to the cap;
- the Todo Stop Guard continuing past unfinished todos until its attempts are spent.
A continuation's request runs under a prompt id of its own on the current loop and under the turn's prompt id on the shared loop; the suite compares them as one.
The suite runs in the integration job. A scheduled job also runs the whole headless integration suite with the setting on.
Compatibility
-
With the setting off, no surface changes. The shared code that changes is additive: the scheduler's optional
beforeToolLaunch, from PR 2a theTurnevent fields and send options above, from PR 2b the exportedrunToolBatchand the scheduler'srefuseToolCallandonPermissionPromptTerminalSequence, and from PR 2c-1stopPolicies, which change nothing for a caller that does not use them. -
With the setting on, headless differs from today in these intended ways. Most follow from scheduling a batch as one unit, as the TUI already does:
- a stream-json message past
--max-session-turnsends the run at the session turn limit, as-pdoes (the error on stderr, exit code 53), instead of a success that repeats the previous answer (#15325); - a notification turn (cron, monitor and other queued items) counts each of its model rounds toward
--max-session-turns, as the main turn does, instead of one turn for the whole item, so a run whose notification turns call tools can reach the limit sooner; - a batch's PreToolUse hooks and approvals (stream-json
can_use_tool) run for all of its calls before any of them is launched, instead of one call at a time; under--max-tool-calls, a call past the budget can be approved and then stopped before it runs; - a PostToolBatch hook fires once per batch with all of its calls, and the batch output budget applies across the batch; today each call is its own batch;
- telemetry follows the batch, as in the TUI: a plan-mode entry's siblings are logged as failed tool calls, also in the JSON
stats, and a call stopped before its launch still has a tool span; with Mod tool hooks the calls are still scheduled one at a time, so neither applies; - the results of a batch with several sequential calls are printed when the batch completes, instead of after each call;
- with PreToolUse or PermissionRequest hooks configured, a read-only shell call can run in parallel, judged by its input after the hooks ran, instead of always running alone;
- when a skill earlier in the batch registers a PreToolUse hook, a later call of the batch is stopped and the model has to issue it again, instead of being checked by the new hook;
- under
--json-schema, astructured_outputcall that repeats a call id in the same response is answered as a duplicate instead of being run, so a valid second submission waits for the next round; a later round that resubmits it under the same call id with the same arguments is answered as a duplicate too, and a second such resubmission ends the run as a provider duplicate loop, instead of running it each time.
The parity suite covers the common cases unchanged and asserts the #15325 stop and the approved call under
--max-tool-calls. The headless unit tests run the notification-turn count and the resubmittedstructured_outputon both loops. - a stream-json message past
-
With the setting on, an ACP prompt turn differs from today in these intended ways. Most follow from the client doing for ACP what it does for the other surfaces:
- a prompt carries the client's date reminder when the date changed since the last one, and its system reminders are the client's, the advisor and paused-Goal reminders included; they come before ACP's one-shot notices instead of after them;
- UserPromptSubmit also runs for the turn's tool-result requests, with their text (empty when the request carries only tool results), as on the other surfaces (#9511); cancelling the turn stops a hook still running there; a block there ends the turn with the block notice and closes the round's tool calls with a result saying the hook blocked it, keeping the input taken with the results in history;
- the session token limit is checked before the client compacts the history instead of after;
- the client's session turn cap applies to ACP prompts, and so do its content and thought loop heuristics when
model.skipLoopDetectionis false and the next-speaker check when it is enabled (#14114); - a tool-result request carries the recall and the queued loop reminder and runs the client's size-only microcompaction, the managed memory tasks are the client's, skill review included, and the prompt counts toward commit attribution;
- every compaction shows the compaction notice, also one inside a send;
- from PR 2b, a tool batch is scheduled as one unit, as in the TUI: its approvals are asked for before any of its calls runs, and its concurrency-safe calls run concurrently, so their updates interleave;
- the client's per-turn tool-call cap stops the response as it streams, so that request ends interrupted and reports no usage; the call past the cap is still shown as skipped;
- a cancelled call's result reads
[Operation Cancelled] Reason: …, and the other calls of a batch whose approval was declined are cancelled instead of skipped with ACP's permission-cancel message; - ACP's parameter-error and partial-edit stops take effect after the batch, so its other calls still run; a repeated provider call id ends the turn without recording skipped results;
- the todo and exploration reminders of a tool-result request are the client's, placed by the client after the input taken at the tool boundary;
- a PostToolBatch hook fires once per batch, and the batch output budget applies across the batch;
- a PostToolUse hook's artifacts arrive with the tool's result update instead of a separate
artifact-eventnotification; - from PR 2c-1, the current turn's background results that arrive after its last response start a background-notification turn instead of continuing the prompt, and with the Todo Stop Guard armed, input queued mid-turn becomes the next prompt instead of continuing this one;
- the Todo Stop Guard continues only once the Stop hook lets the response stop, in a request of its own instead of one merged with the hook's reason, and input that arrives while the Stop hook runs no longer discards its decision;
- Stop-hook and Todo Stop Guard continuations run under the turn's prompt id, run UserPromptSubmit as the TUI's do, run the client's tool-call guards except in Live and Agent Host sessions, which keep ACP's, and send their tool results back as any batch does;
- from PR 2c-2, a channel prompt and a Goal-proposal turn get what a fresh prompt gets on the shared path, the date and system reminders and the client's recall included;
- a retry, a continue and a restore start the client's interaction: its loop detector, exploration budget and turn budget start over, and their first round does not count toward the session turn cap;
- a continue whose first request never goes out, cancelled or failed, puts the interrupted user run back as it was, instead of keeping it as one merged entry, and the worktree notice the merged entry took stays pending for the next turn;
The ACP parity suite covers the common cases unchanged and asserts the date reminder, UserPromptSubmit on tool results, cancelling while that hook runs, and that a batch's approvals come before its calls run.
-
Later surfaces bring their own fixes: #15324 (ACP), #15326 (OpenTUI), #14114 and #12209 (ACP).
-
No setting, protocol field or file format is removed.
Risks
| Risk | Mitigation |
|---|---|
Whole-batch scheduling changes headless frame order or the exact --max-tool-calls count |
ordered result streaming; beforeToolLaunch; parity scenarios at the budget edge |
Moving ACP onto LlmClient adds behavior (caps, heuristics, reminders) and could run hooks twice |
ACP's own copies stay off the shared path; parity lists every added behavior; separate PR |
| AgentCore's deferred approvals must restore four async-local frames | move the existing contract tests with the code; a real deferred-approval run in the TUI agent view (PR 5) |
| Two implementations coexist while the setting exists, and new loop-level behavior has to be wired into both | each surface switches its default and drops its current loop as soon as it is ready, instead of after every surface has moved; the current loops are not refactored meanwhile; parity runs in CI |
| Large PRs touch core | each PR moves one surface; PR 1 is a feature PR behind an off-by-default setting |
Acceptance criteria
Per surface PR:
- With the setting off, the existing unit and integration suites pass unchanged.
- With the setting on, the surface passes its parity scenarios; every difference is listed and linked.
- The surface's loop-specific tests also run with the setting on.
End state:
| Measure | Today | Target |
|---|---|---|
| Agent loop implementations | 7, plus the extra copies inside headless and ACP | 1 |
Loops that call LlmChat.sendMessageStream directly |
3 | 0 |
Loops that parse stream chunks outside turn.ts |
3 | 0 |
Loops that call finalizeToolResponses |
6 | 1 |
| Session turn-cap formulas | 3 | 1 |
Open questions
- Should
model.maxSessionTurns: 0mean "no turns" or be rejected? Today the loops disagree (#15325). - The parity suite compares frames in order, and no scenario yet runs concurrent calls with live output, whose progress frames have no fixed order in either loop. Should such scenarios compare progress frames per call?
- A call
beforeToolLaunchrefuses is still a member of its batch for PostToolBatch, so a stop or added context the hook attaches to it does not reach the model. Headless refuses a call only when the run ends, so this does not show there. Before another surface usesbeforeToolLaunch, should refused calls leave the batch the hook sees, or should the hook's output move to the last call that ran?
Follow-up
- Session-level scheduling: one place that decides when a notification, cron job, Goal turn or teammate message starts a loop.
- The Hosted Harness evaluation: whether its journaled dispatch becomes a scheduler behind the same
schedulecontract.