From f6bd1468f219be94c187e15ddb5f2de5419f9c9a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 05:50:39 +0800 Subject: [PATCH 1/6] simplify(agent): drop the unused public Agent.abort(), keep whenIdle() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public Agent handle exposed abort() (step-only) and cancel() (queue-aware). No production caller used abort() — ACP maps session/cancel to cancel(), and lifecycle owners tear down via AgentHandle.dispose(); the loop's own stop paths abort their per-step AbortController directly. So abort() is latent generality that keeps a private loop mechanic public. RFC-premise correction: the public-agent-stop-surface RFC proposed removing whenIdle() too. Implementation found whenIdle() load-bearing — a real quiescence primitive with a deliberate loop contract (settle-without-transition, the replacement-turn race) and ACP test consumers; its proposed replacement ("observe the running->idle transition") is exactly the async-state race AGENTS.md warns against. So only abort() is removed; whenIdle() stays. The RFC is amended on the way to implemented/ to record the narrowed scope, and the new AGENTS.md "RFCs are proposals, not golden truth" principle (PR1) gets its worked example. - Remove Agent.abort() from the interface + the ReactLoopAgent impl; the no-arg 'aborted' default goes with it (cancel() keeps its 'cancelled' default). - Migrate tests: empty-queue abort() -> cancel(reason); the two review-fixes tests whose subject is the in-flight step's AbortController drive that controller directly via the private currentAbort field (cancel() would clear the inbox and destroy the queued steering one of them proves survives a step abort). The no-arg-default test is dropped (cancel()'s default is already covered in cancel.spec.ts). - Resulting public stop surface: cancel() + whenIdle(). Update agent/agent-loop READMEs, architecture.md, core.md type-equiv, the extension cookbook, the lifecycle RFC (short note), and the proposed ACP RFC. Implements docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md --- docs/architecture.md | 4 +-- docs/cookbook/extension-cookbook.md | 4 +-- docs/cordis-catalog/events-and-services.md | 34 +++++++++---------- docs/core-data-structures/core.md | 16 ++++----- ...-18-agent-lifecycle-and-ownership-seams.md | 2 +- .../2026-06-14-acp-agent-client-protocol.md | 2 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 6 +--- packages/core/agent-loop/src/loop.ts | 8 ++--- packages/core/agent-loop/tests/agent.spec.ts | 18 +--------- packages/core/agent-loop/tests/loop.spec.ts | 6 ++-- .../agent-loop/tests/review-fixes.spec.ts | 15 ++++++-- packages/core/agent/README.md | 5 ++- packages/core/agent/src/types.ts | 16 ++++----- packages/core/agent/tests/agent.spec.ts | 1 - 15 files changed, 58 insertions(+), 81 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 7c7e891a78..d853239c06 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,7 +113,7 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told - `steer(content)` — mid-turn injection, drained **between steps**; behaves like `send` when idle - `inject(content)` — in-session context (`context/message` event); the next request sees it (Claude Code attachment / system-reminder analog). An inject made while the agent is *running* joins the open turn; an inject while *idle* is wrapped in a one-shot turn (`turn/start{trigger:injection}` → `context/message` → `turn/end`) so every event stays turn-enclosed (see [the turn-enclosure invariant](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `abort(reason)` — aborts the in-flight step via `AbortSignal` -- `cancel(reason)` — the broad cancel: clears queued + steering work, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. `abort()` is the narrower step-only verb; `cancel()` is what a UI/ACP `session/cancel` maps to. +- `cancel(reason)` — the single public stop primitive: clears queued + steering work, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. A UI/ACP `session/cancel` maps to it. - `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). The teardown signal: `abort()` then `await whenIdle()` guarantees the in-flight turn has fully stopped. Observes the transition without disposing the agent. - `session`, `status`, `options` @@ -159,7 +159,7 @@ forever: emit agent/status(idle) unless more queued ``` -Error containment: a throwing `agent/turn-continuation` listener or a broken step ends the **turn** with an `error` event (appended INSIDE the turn, before `turn/end`) — never the driver loop. An adapter that ends its stream with a `finish {kind:'error'}` or `{kind:'aborted'}` chunk (the in-band error path, for adapters that can't throw mid-stream) is likewise translated into a step error, so the turn ends `error`/`aborted` instead of logging a normal `completed` assistant message. `abort()` is honored mid-stream **and** between tool calls; disposal mid-turn ends the turn with reason `disposed` and emits `agent/status('disposed')`. +Error containment: a throwing `agent/turn-continuation` listener or a broken step ends the **turn** with an `error` event (appended INSIDE the turn, before `turn/end`) — never the driver loop. An adapter that ends its stream with a `finish {kind:'error'}` or `{kind:'aborted'}` chunk (the in-band error path, for adapters that can't throw mid-stream) is likewise translated into a step error, so the turn ends `error`/`aborted` instead of logging a normal `completed` assistant message. A `cancel()` is honored mid-stream **and** between tool calls; disposal mid-turn ends the turn with reason `disposed` and emits `agent/status('disposed')`. Turn-end reasons: a turn ends with one `TurnEndReason` — `completed`, `aborted`, `error`, `disposed`, or `max-tokens`. `max-tokens` mirrors the model-call `FinishReason` of the same name (DeepSeek's `length`): a step that hit the output-token ceiling makes the turn end `max-tokens` rather than `completed`, by the rule *any `max-tokens` step in the turn surfaces as `max-tokens`* (a continuation plugin may run further steps after one, but the cut-short fact wins; the `disposed`/`aborted`/`error` outcomes still take precedence). This lets a consumer distinguish a clean stop from a truncated one (the ACP bridge maps it to the `max_tokens` stop reason). `TurnEndReason` is merge-extensible; `refusal` and `max_turn_requests` are the next variants to add when an adapter/loop first emits them. diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 1db79f627b..41fc85be0e 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -56,7 +56,7 @@ export function apply(ctx: Context) { ## A client-driver plugin (external protocol bridge) -A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.abort()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (the turn can end without its `agent/turn-end` event firing — fall back through the logged `turn/end` record), and on disposal reach quiescence (`await agent.whenIdle()` after `abort()`), not just request it. +A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (the turn can end without its `agent/turn-end` event firing — fall back through the logged `turn/end` record), and on disposal reach quiescence (handle disposal aborts in-flight work then `await`s `agent.whenIdle()`), not just request it. `packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the deferred-permission-gate note. @@ -77,7 +77,7 @@ export function apply(ctx: Context) { } }) // Inbound "prompt": create/resume an agent and feed it; settle on turn end. - // Disposal awaits quiescence: agent.abort() then await agent.whenIdle(). + // Disposal awaits quiescence: handle disposal aborts, then await agent.whenIdle(). } ``` diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index de5cf6ad72..a07711d89d 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:141`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:136`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:147`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:142`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:219`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:160`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:155`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:193`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:149`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:184`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:199`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:194`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:174`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:206`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) ### `llm/*` @@ -288,12 +288,12 @@ The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loo The loop itself is deliberately thin — every behavior beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy declared in @deepseek-ai/dsh-agent. ```ts cordis-catalog -create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent +create(id: string, options: AgentOptions = {}): ReactLoopAgent createAgent(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:63`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:60`](../../packages/core/agent-loop/src/index.ts) ### `ctx.agents` — `AgentRegistry` @@ -327,9 +327,7 @@ Semantics every implementation must honor: abstract resolve(request: BashExecRequest): BashExecSpec abstract run(spec: BashExecSpec): Promise abstract start(spec: BashExecSpec): BashTask -abstract get(id: BashTaskId): BashTask | undefined abstract ownerOf(id: BashTaskId): OwnerToken | undefined -abstract list(): BashTask[] abstract readOutput(id: BashTaskId): BashTaskRead abstract kill(id: BashTaskId): boolean onTaskDone(listener: BashTaskListener): () => void diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 46b416724e..ae6086b7b7 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -233,12 +233,8 @@ interface Agent { */ inject(content: ContentBlock[], options?: SendOptions): void - /** Abort the in-flight step (if any); the turn ends with reason 'aborted'. */ - abort(reason?: string): void - /** - * Cancel ALL pending work for the agent — the narrower {@link abort} kills - * only the in-flight step. `cancel()`: + * Cancel ALL pending work for the agent. `cancel()`: * * - clears the queued FIFO (un-started prompts never run) and the steering * FIFO (steering for the cancelled turn is dropped, not re-enqueued); @@ -258,11 +254,11 @@ interface Agent { /** * Resolve once the agent has reached quiescence after settling out of * `running`, or immediately if it is already idle with no queued work. The - * quiescence signal a teardown awaits: `agent.abort()` then - * `await agent.whenIdle()` guarantees queued/running work has fully stopped - * before the caller proceeds (a closing ACP connection, a disposing UI - * plugin), rather than returning while the driver is still streaming or about - * to start a queued turn. + * quiescence signal a teardown awaits: a lifecycle owner disposes the agent + * through its `AgentHandle` (which aborts in-flight work then awaits this), so + * the caller proceeds only after queued/running work has fully stopped (a + * closing ACP connection, a disposing UI plugin) rather than returning while + * the driver is still streaming or about to start a queued turn. * * "Quiescence", not merely "status changed": a disposed agent emits * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index dc4b2428a1..35cb2eb6b3 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -12,7 +12,7 @@ The three seams shipped across a stacked chain of PRs (the queue-aware cancel, t ### 1. Queue-aware `Agent.cancel(reason?)` -A new `cancel()` verb on the `Agent` interface (distinct from the narrower step-only `abort()`). It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later prompt cannot be batched into the cancelled turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. +A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later prompt cannot be batched into the cancelled turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. ### 2. `AgentHandle` async disposer diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md index f133cc1d82..e3af4abb3c 100644 --- a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md @@ -37,7 +37,7 @@ The mapping between ACP and existing harness seams — each row names the seam a The permission gate is the first real consumer of the `tools/execute` veto seam (the documented "single veto/sandbox/permission seam" plus the deferred "Permission system" TODO in [docs/architecture.md](../../../architecture.md)). It is a single global listener registered with `prepend: true` so it runs before any other tool wrapper. `ToolExecution.agent` is optional and the `Agent` interface carries no origin marker, so the bridge tracks ownership itself: it records each agent it creates in a `WeakMap` and the gate no-ops (calls `next()` immediately) for any `exec.agent` it does not own — non-ACP agents and the no-agent case pass straight through. For an owned agent it resolves the session, issues `session/request_permission`, and stores the pending resolver on that session's record so the outcome — or a `session/cancel`/connection-close — settles it exactly once. -Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and awaits quiescence — close the connection, settle/reject pending permissions, `agent.abort()`, and wait for the agent to settle. The disposal-settle signal must come from the `dsh-agent` interface, not the loop: `agent.done` exists only on the concrete `ReactLoopAgent`, so the bridge instead observes `agent/status` reaching `idle`/`disposed` (or the RFC lifts a quiescence promise onto the `Agent` interface). Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn. +Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and awaits quiescence — close the connection, settle/reject pending permissions, `agent.cancel()`, and wait for the agent to settle. The disposal-settle signal must come from the `dsh-agent` interface, not the loop: `agent.done` exists only on the concrete `ReactLoopAgent`, so the bridge instead observes `agent/status` reaching `idle`/`disposed` (or the RFC lifts a quiescence promise onto the `Agent` interface). Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn. **Dependency note (architecture rule).** [docs/architecture.md](../../../architecture.md) states "plugins depend on interface packages, never on `dsh-agent-loop`." Creating and resuming agents is currently only on the concrete `AgentLoop` (`ctx.agentLoop`), so this RFC proposes adding an **abstract create/resume factory** to the `dsh-agent` interface (registry-level `create({ sessionId, meta })` / `resume(...)`), implemented by the loop, so `dsh-acp` injects only `agents` (the interface) and the dependency rule holds. The alternative — injecting the concrete `agentLoop` and recording a documented exception in the architecture doc — is explicitly the non-preferred fallback. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index c7ea092c72..4892b357bf 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -68,7 +68,7 @@ forever: Error containment: a throwing plugin ends the **turn**, never the loop. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop. -Cancellation: `agent.abort()` aborts only the in-flight step; `agent.cancel()` is the broad verb — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. +Cancellation: `agent.cancel()` is the single public stop primitive — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. (The loop still aborts its own per-step `AbortController` directly on disposal and from `cancel()`; that controller is loop-internal, not a public verb.) ### What is NOT here diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index df25af1c11..12f15868dc 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -191,10 +191,6 @@ export class ReactLoopAgent implements Agent { } } - abort(reason?: string): void { - this.currentAbort?.abort(reason ?? 'aborted') - } - cancel(reason?: string): void { // Arm-gate: only mark a cancellation when there is actually work to cancel — // a running turn, an in-flight step, or queued/steering work. An idle cancel @@ -233,7 +229,7 @@ export class ReactLoopAgent implements Agent { * running→idle/disposed transition, resolving on `idle` directly (the turn * fully ended) or chaining {@link done} on `disposed` (wait for the loop to * actually exit). Implements the {@link Agent.whenIdle} contract used by - * teardown (`abort()` then `await whenIdle()`). + * teardown (handle disposal aborts in-flight work, then awaits `whenIdle()`). */ whenIdle(): Promise { if (this._status === 'disposed') return this.done diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index b8f43a0a9b..b5d7aca146 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -435,7 +435,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, if (handle.isDisposed()) { reason = { kind: 'disposed' } } else if (abort.signal.aborted) { - /* v8 ignore next -- abort.signal.reason always set by agent.abort() which provides a default */ + /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') } } else { failTurn(error) @@ -590,7 +590,7 @@ async function runStep( // --- Model call (streaming-first; raw chunks are the replay record) --- const assembler = new BlockAssembler() for await (const chunk of ctx.llm.stream(request)) { - /* v8 ignore next -- signal.reason always set by agent.abort() which provides a default */ + /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) session.append('assistant/chunk', { turn, step, chunk }) ctx.emit('agent/stream-chunk', agent, turn, step, chunk) @@ -633,7 +633,7 @@ async function runStep( // isError results, so abort is re-checked around every call here. const toolCalls = message.content.filter(block => block.type === 'tool-call') for (const call of toolCalls) { - /* v8 ignore next -- signal.reason always set by agent.abort() which provides a default */ + /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments }) let parsedArguments: unknown @@ -664,7 +664,7 @@ async function runStep( }) // signal CAN flip during the await above (abort() inside a tool); // the analyzer can't see through the await boundary. - /* v8 ignore start -- signal.reason default unreachable via agent.abort() */ + /* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) /* v8 ignore stop */ diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index d9235cfef3..ed163bf4c2 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -288,7 +288,7 @@ describe('ReactLoopAgent', () => { expect(settled).toBe(false) await waitForStatus(ctx, agent, 'running') - agent.abort('done') + agent.cancel('done') await idle expect(settled).toBe(true) expect(agent.status).toBe('idle') @@ -430,20 +430,4 @@ describe('ReactLoopAgent', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on idle')) warn.mockRestore() }) - - it('abort() resolves reason to "aborted" when no reason provided', async () => { - const adapter = new MockAdapter(['hang']) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - - const reasons: { kind: string; reason?: string }[] = [] - ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) - - send(agent, 'go') - await new Promise(r => setTimeout(r, 30)) - agent.abort() // no reason string - await waitForIdle(ctx, agent) - - expect(reasons[0]).toMatchObject({ kind: 'aborted', reason: 'aborted' }) - }) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index cd912f39cd..18dbafeb04 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -318,7 +318,7 @@ describe('agent loop', () => { expect(adapter.requests[0]!.model).toBe('other-model') }) - it('abort() mid-stream ends the turn with reason aborted', async () => { + it('cancel() mid-stream ends the turn with reason aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -327,10 +327,10 @@ describe('agent loop', () => { ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason)) send(agent, 'go') - // wait until the stream is hanging, then abort + // wait until the stream is hanging, then cancel await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - agent.abort('user interrupt') + agent.cancel('user interrupt') await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }]) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index fa6a560c7f..0d2441c7db 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -92,7 +92,7 @@ describe('HIGH: session log records what agent/step-result actually produced', ( }) describe('HIGH: abort during tool execution ends the turn', () => { - it('abort() inside a tool prevents both remaining tools and the next model step', async () => { + it('aborting the in-flight step inside a tool prevents both remaining tools and the next model step', async () => { const adapter = new MockAdapter([ // model asks for two tool calls in one step [ @@ -113,7 +113,11 @@ describe('HIGH: abort during tool execution ends the turn', () => { parameters: {}, async execute() { executed.push('aborter') - agent.abort('user interrupt') + // Fire the in-flight step's AbortController directly (the loop registers + // it on the agent). This is the bare step-abort path — distinct from + // cancel(), which would also clear the inbox; here the subject is the + // loop's response to its running step being aborted mid-tool. + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt') return [{ type: 'text', text: 'done' }] }, })) @@ -228,7 +232,12 @@ describe('HIGH: steering from late extension points is never stranded', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) agent.steer([{ type: 'text', text: 'redirect' }]) - agent.abort('user interrupt') + // Abort ONLY the in-flight step, via its AbortController directly — NOT + // cancel(), which clears the inbox and would drop the queued steering this + // test proves survives a step abort. There is no public step-only abort + // verb (cancel() is the only public stop primitive), so reach the private + // controller the loop registered. + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt') await waitForIdle(ctx, agent) // a new turn ran with the steering content delivered as a message diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8e9d41855c..28a8cd0cf0 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -56,9 +56,8 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle - `agent.steer(content, options?)` — steer a running turn (inject between steps); behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) -- `agent.abort(reason?)` — abort the in-flight step (the narrow, step-only verb) -- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. Idle with nothing pending → a safe no-op. -- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit), the signal a teardown awaits (`abort()` then `await whenIdle()`). Observes the transition without disposing the agent. +- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. +- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit), the signal a teardown awaits (a lifecycle owner disposes the handle, which aborts in-flight work then awaits this). Observes the transition without disposing the agent. - `agent.session`, `agent.status`, `agent.options`, `agent.id` ### Extension points diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index ef20bf2705..be0394adc5 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -79,12 +79,8 @@ export interface Agent { */ inject(content: ContentBlock[], options?: SendOptions): void - /** Abort the in-flight step (if any); the turn ends with reason 'aborted'. */ - abort(reason?: string): void - /** - * Cancel ALL pending work for the agent — the narrower {@link abort} kills - * only the in-flight step. `cancel()`: + * Cancel ALL pending work for the agent. `cancel()`: * * - clears the queued FIFO (un-started prompts never run) and the steering * FIFO (steering for the cancelled turn is dropped, not re-enqueued); @@ -104,11 +100,11 @@ export interface Agent { /** * Resolve once the agent has reached quiescence after settling out of * `running`, or immediately if it is already idle with no queued work. The - * quiescence signal a teardown awaits: `agent.abort()` then - * `await agent.whenIdle()` guarantees queued/running work has fully stopped - * before the caller proceeds (a closing ACP connection, a disposing UI - * plugin), rather than returning while the driver is still streaming or about - * to start a queued turn. + * quiescence signal a teardown awaits: a lifecycle owner disposes the agent + * through its `AgentHandle` (which aborts in-flight work then awaits this), so + * the caller proceeds only after queued/running work has fully stopped (a + * closing ACP connection, a disposing UI plugin) rather than returning while + * the driver is still streaming or about to start a queued turn. * * "Quiescence", not merely "status changed": a disposed agent emits * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index ff952aee4f..c344cd2a6f 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -13,7 +13,6 @@ function stubAgent(rawId: string): Agent { send() {}, steer() {}, inject() {}, - abort() {}, cancel() {}, whenIdle() { return Promise.resolve() }, } From c6ed980d6f08d2b90efc90ee158c61ace42e4497 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 09:10:56 +0800 Subject: [PATCH 2/6] fix review findings: stale abort() docs + move RFC to implemented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's no-ship was a completeness/docs-sync gap, not loop behavior: - docs/architecture.md: drop the public abort() handle row; the teardown signal is now cancel() then await whenIdle(). - cancel.spec.ts: the module doc and the turn-start comment contrasted cancel() against a public abort() verb that no longer exists — reword to name the loop's private step AbortController. - packages/ui/acp/src/index.ts: the post-resume-leak comment cited abort(); cancel() is the surviving stop verb that likewise does not unregister. - Move the RFC proposed -> implemented/simplification with amended text: Status flips, the both-removal proposal is narrowed to abort-only, and an implementation note records why whenIdle() is retained (load-bearing quiescence primitive with live ACP consumers). Update docs/rfc/README.md. - AGENTS.md "RFCs are proposals, not golden truth": add the concrete abort/whenIdle worked example now that the implemented RFC exists to link. - Regenerate the cordis catalog (line-number drift from the rebase). --- AGENTS.md | 2 ++ docs/architecture.md | 3 +- docs/cordis-catalog/events-and-services.md | 34 +++++++++--------- docs/rfc/README.md | 2 +- .../2026-06-20-public-agent-stop-surface.md | 36 +++++++++++++++++++ .../2026-06-20-public-agent-stop-surface.md | 32 ----------------- packages/core/agent-loop/tests/cancel.spec.ts | 5 +-- packages/ui/acp/src/index.ts | 2 +- 8 files changed, 62 insertions(+), 54 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md delete mode 100644 docs/rfc/proposed/simplification/2026-06-20-public-agent-stop-surface.md diff --git a/AGENTS.md b/AGENTS.md index dc4e65a962..2fdd25b552 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,8 @@ The same discipline applies one level up, to the RFCs in `docs/rfc/`. A **propos When carrying out the change fights back — a removal forces an awkward migration, deletes machinery that turns out to be load-bearing, or pushes consumers onto a more brittle hand-rolled equivalent — treat that friction as **evidence the RFC over-reached**, not as work to push through. Keep, split, or amend the change to match what the code actually wants, and say so in the PR. An RFC that ships in amended form gets its text amended on the way to `implemented/`, so the landed RFC describes what actually shipped rather than the original guess. The discipline cuts both ways: an RFC is also not a reason to *avoid* a change a maintainer would otherwise make — it is one input, weighed against the code in front of you. +The worked example is [Keep one public stop primitive](docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md): it proposed removing BOTH `Agent.abort()` and `Agent.whenIdle()` as redundant stop/quiescence surface. Validating against the code, `abort()` was genuinely dead — no production caller, the loop aborts its own `AbortController` directly — so it was removed as proposed. But `whenIdle()` was load-bearing: a deliberate quiescence primitive with live ACP consumers, and the RFC's suggested migration (observe the `running`→`idle` transition by hand) is exactly the brittle path § Defensive patterns warns against ("Async state is not synchronous state"). So only `abort()` shipped, `whenIdle()` stayed, and the RFC's text was amended on the way to `implemented/` to record the narrowed scope — the landed RFC is not a lie about what was built. + ## Architecture This codebase is based on the **Cordis** framework, built microkernel-style: **everything is a plugin**. All necessary Cordis dependencies are copied into this monorepo as vendored source (under `vendor/`) instead of being depended on via npm. diff --git a/docs/architecture.md b/docs/architecture.md index d853239c06..c7c84ce3b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,9 +112,8 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told - `send(content)` — queued message; starts a turn when idle, else next turn - `steer(content)` — mid-turn injection, drained **between steps**; behaves like `send` when idle - `inject(content)` — in-session context (`context/message` event); the next request sees it (Claude Code attachment / system-reminder analog). An inject made while the agent is *running* joins the open turn; an inject while *idle* is wrapped in a one-shot turn (`turn/start{trigger:injection}` → `context/message` → `turn/end`) so every event stays turn-enclosed (see [the turn-enclosure invariant](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). -- `abort(reason)` — aborts the in-flight step via `AbortSignal` - `cancel(reason)` — the single public stop primitive: clears queued + steering work, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. A UI/ACP `session/cancel` maps to it. -- `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). The teardown signal: `abort()` then `await whenIdle()` guarantees the in-flight turn has fully stopped. Observes the transition without disposing the agent. +- `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). The teardown signal: `cancel()` then `await whenIdle()` guarantees the in-flight turn has fully stopped. Observes the transition without disposing the agent. - `session`, `status`, `options` **TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds the child Session with the parent's event log, spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Inter-agent channels beyond these primitives are deliberately deferred. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index a07711d89d..afa4356880 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:136`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:137`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:142`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:143`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:219`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:155`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:149`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:194`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:174`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) ### `llm/*` @@ -288,12 +288,12 @@ The agent-loop plugin (`ctx.agentLoop`): creates ReactLoopAgents, runs their loo The loop itself is deliberately thin — every behavior beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy declared in @deepseek-ai/dsh-agent. ```ts cordis-catalog -create(id: string, options: AgentOptions = {}): ReactLoopAgent +create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent createAgent(options: CreateAgentOptions): AgentHandle async resume(options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:60`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:63`](../../packages/core/agent-loop/src/index.ts) ### `ctx.agents` — `AgentRegistry` @@ -327,7 +327,9 @@ Semantics every implementation must honor: abstract resolve(request: BashExecRequest): BashExecSpec abstract run(spec: BashExecSpec): Promise abstract start(spec: BashExecSpec): BashTask +abstract get(id: BashTaskId): BashTask | undefined abstract ownerOf(id: BashTaskId): OwnerToken | undefined +abstract list(): BashTask[] abstract readOutput(id: BashTaskId): BashTaskRead abstract kill(id: BashTaskId): boolean onTaskDone(listener: BashTaskListener): () => void diff --git a/docs/rfc/README.md b/docs/rfc/README.md index eaefbdda94..fae21e0351 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -51,7 +51,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r |---|---| | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Stop mirroring durable boundaries as agent events](proposed/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | -| [Keep one public stop primitive](proposed/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | | [Fold trace-only session facts into load-bearing events](proposed/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | ### Architecture @@ -95,6 +94,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Drop unconsumed assembled LLM convenience surfaces](implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 2026-06-20 | | [Drop the unconsumed `llm/adapter-change` event](implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 | | [Prune dead methods from the persistence and bash seams](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | +| [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | ### Architecture diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md new file mode 100644 index 0000000000..a737acbbd0 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md @@ -0,0 +1,36 @@ +# RFC: Keep one public stop primitive + +Status: implemented (proposed 2026-06-20; accepted in amended form — `whenIdle()` retained) + +> **Implementation note (scope narrowed from the original proposal).** This RFC proposed removing BOTH `abort()` and `whenIdle()` from the public `Agent` handle. Only `abort()` was removed. Validating the premise against the code ([AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md)) found `whenIdle()` to be a **load-bearing quiescence primitive**, not dead surface: it is the settle signal in several ACP tests (`packages/ui/acp/tests/{edges,turns,dispose}.spec.ts`) and is backed by a deliberate loop contract (settle waiters without a status transition; handle the replacement-turn race). The RFC's suggested migration — have consumers observe the `running`→`idle` transition by hand — is exactly the brittle hand-rolled path [AGENTS.md § Defensive patterns](../../../../AGENTS.md) warns against ("Async state is not synchronous state"). Deleting a clean primitive to push every consumer onto that is a net loss, so `whenIdle()` stays. `abort()` was genuinely dead public surface (no production caller; the loop aborts its own `AbortController` directly), so it was removed as proposed. The text below is amended to describe what shipped. + +## Problem + +The public `Agent` handle exposed two overlapping ways to stop in-flight work: `abort(reason?)` and `cancel(reason?)`. `abort()` killed only the in-flight step and left queued work alone; `cancel()` clears queued and steering work, aborts the running step, and handles the pre-step race. In production, ACP uses `cancel()` for `session/cancel`, while lifecycle owners tear down agents through `AgentHandle.dispose()`. No production caller needed bare `abort()`. + +The `abort()`/`cancel()` distinction is real — `abort()` preserves queued prompts and steering while `cancel()` drops them — but no shipping code called the public `abort()` verb. The loop's own stop paths (`cancel()` and disposal) abort the current `AbortController` directly rather than routing through `Agent.abort()`. Most tests that called `abort()` interrupt an empty queue and switch to `cancel(reason)`; the steering re-delivery test that deliberately depends on queue preservation drives the in-flight `AbortController` directly, because `cancel()` would drop the queued steering it is trying to prove survives a step abort. The no-argument `abort()` default reason (`'aborted'`) is deleted with the verb rather than preserved by accident; `cancel()` keeps its own `'cancelled'` default. + +The extra surface area made the loop carry a public verb that is mostly a teardown internal: `abort()` had to be documented as distinct from queue-aware cancellation even though a UI cancellation almost always wants the broader operation. + +## Proposal + +Keep `cancel()` as the only public *stop* primitive on `Agent`. Lifecycle owners use `AgentHandle.dispose()` to stop and unregister an agent; non-owners use `cancel()` to abandon current and queued work. The implementation keeps a private abort controller, but it is not part of the plugin-facing `Agent` contract. + +`whenIdle()` is **retained** as the public quiescence-observation primitive (resolve once the agent settles out of `running`, resolve immediately when already idle, await the loop exit when disposed). It is not a stop verb; it is how a non-owner observes the stop *completing* without disposing the agent, and it has live consumers (the ACP bridge's settle points). + +Delete public `abort()`, the tests that exercise it as standalone API, and the docs that describe step-only abort as an embedding feature. Empty-queue abort tests migrate to `cancel(reason)` where they still prove cancellation behavior; tests whose subject is the loop's internal `AbortController` behavior drive that controller directly via an in-package typed cast to the private field; tests that only pin the removed no-arg `abort()` default go away with the method. The disposer remains async and still waits for the loop to stop. + +## Acceptance criteria + +- `Agent` exposes no public `abort()`; `cancel()`, `whenIdle()`, and `steer()` remain part of the surface. +- ACP cancellation continues to call `cancel()`. +- Agent teardown continues to await quiescence through handle disposal, and `whenIdle()` still resolves on quiescence for non-owner observers. +- Tests cover cancellation and disposal as the two supported stop paths. + +## What we give up + +A future plugin cannot abort only the current model/tool step while preserving queued prompts through the public interface. If that use case becomes real, it should return with a named consumer and a narrower contract. Today it is latent generality that keeps a private loop mechanic public. + +## Related + +This RFC only removes the redundant stop verb. Mid-turn steering remains an intentional message path; quiescence observation remains via `whenIdle()`. The resulting public surface is `send()`, `steer()`, `inject()`, `cancel()`, `whenIdle()`, status, options, session, and identity. diff --git a/docs/rfc/proposed/simplification/2026-06-20-public-agent-stop-surface.md b/docs/rfc/proposed/simplification/2026-06-20-public-agent-stop-surface.md deleted file mode 100644 index 6c67413a49..0000000000 --- a/docs/rfc/proposed/simplification/2026-06-20-public-agent-stop-surface.md +++ /dev/null @@ -1,32 +0,0 @@ -# RFC: Keep one public stop primitive - -Status: proposed - -## Problem - -The public `Agent` handle exposes three ways to reason about stopping work: `abort(reason?)`, `cancel(reason?)`, and `whenIdle()`. `abort()` kills only the in-flight step and leaves queued work alone; `cancel()` clears queued and steering work, aborts the running step, and handles the pre-step race; `whenIdle()` exposes the loop's private quiescence waiter to any consumer. In production, ACP uses `cancel()` for `session/cancel`, while lifecycle owners tear down agents through `AgentHandle.dispose()`. No production caller needs bare `abort()` or `whenIdle()`. - -The `abort()`/`cancel()` distinction is real — `abort()` preserves queued prompts and steering while `cancel()` drops them — but no shipping code calls the public `abort()` verb. The loop's own stop paths (`cancel()` and disposal) abort the current `AbortController` directly rather than routing through `Agent.abort()`. Most tests that call `abort()` interrupt an empty queue and can switch to `cancel(reason)`; the one steering re-delivery test that deliberately depends on queue preservation should drive the in-flight `AbortController` directly, because `cancel()` would drop the queued steering it is trying to prove survives a step abort. The no-argument `abort()` default reason (`'aborted'`) is also deleted with the verb rather than preserved by accident; `cancel()` keeps its own `'cancelled'` default. - -The extra surface area makes the loop carry public semantics that are mostly teardown internals. `whenIdle()` needs waiter state, special disposed-agent behavior, and a loop-exit promise so it resolves after quiescence rather than merely after a status flip. `abort()` has to be documented as distinct from queue-aware cancellation even though a UI cancellation almost always wants the broader operation. - -## Proposal - -Keep `cancel()` as the only public stop primitive on `Agent`. Lifecycle owners use `AgentHandle.dispose()` to stop and unregister an agent; non-owners use `cancel()` to abandon current and queued work. The implementation can keep private abort controllers and quiescence promises, but they are not part of the plugin-facing `Agent` contract. - -Delete public `abort()` and `whenIdle()`, the tests that exercise them as standalone API, and the docs that describe step-only abort as an embedding feature. Empty-queue abort tests migrate to `cancel(reason)` where they still prove cancellation behavior; tests whose subject is the loop's internal `AbortController` behavior drive that controller directly; tests that only pin the removed no-arg `abort()` default go away with the method. The disposer remains async and still waits for the loop to stop; that guarantee moves entirely onto `AgentHandle.dispose()`. - -## Acceptance criteria - -- `Agent` exposes no public `abort()` or `whenIdle()`; `steer()` remains part of the message surface. -- ACP cancellation continues to call `cancel()`. -- Agent teardown continues to await quiescence through handle disposal. -- Tests cover cancellation and disposal as the two supported stop paths. - -## What we give up - -A future plugin cannot abort only the current model/tool step while preserving queued prompts through the public interface. If that use case becomes real, it should return with a named consumer and a narrower contract. Today it is latent generality that keeps private loop mechanics public. - -## Related - -This RFC only removes the stop/quiescence methods. Mid-turn steering remains an intentional message path; the resulting public surface is `send()`, `steer()`, `inject()`, `cancel()`, status, options, session, and identity. diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 4a417dbdce..9cdaa1973b 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -1,7 +1,8 @@ /** * Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the * broad verb — it clears queued + steering work, aborts an in-flight step, and - * drops a turn about to start — whereas `abort()` kills only the current step. + * drops a turn about to start — whereas a bare step abort (the loop's private + * `AbortController`) kills only the current step and leaves the queue intact. * These tests exercise every window where a cancel can land (idle, pre-step, * mid-step, continuation) and the marker's arm/reset rules that keep a cancel * from leaking to a later prompt or hanging `whenIdle()`. @@ -172,7 +173,7 @@ describe('Agent.cancel()', () => { // A turn-start listener fires BEFORE any AbortController is installed for the // step. Cancelling there must still drop the step (the turn-scoped marker, - // not abort(), is what catches this) — no model step runs. + // not the step AbortController, is what catches this) — no model step runs. let streamed = false ctx.on('agent/stream-chunk', () => { streamed = true }) const dispose = ctx.on('agent/turn-start', (subject) => { diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 2964726b94..d7662cfebf 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -488,7 +488,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // Validate the PERSISTED cwd BEFORE resuming — `list()` is a // metadata-only read (no full-log parse), so this rejects a session we // can't honor WITHOUT ever constructing/registering an agent (a - // post-resume reject would leak the registered agent — abort() does not + // post-resume reject would leak the registered agent — cancel() does not // unregister it — and wedge the id against re-load). The session's bash // workdir is derived from its persisted `header.cwd` and the request // `cwd` does NOT override it (resume takes no cwd), so a session with no From 436305b1c267094aaa4b73ea3bcf20993368e8f9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 09:37:52 +0800 Subject: [PATCH 3/6] fix review findings: correct teardown framing (dispose, not cancel+whenIdle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's second pass caught that the prior doc fix swapped one wrong primitive for another: framing teardown as cancel()+whenIdle() (or awaiting agent.whenIdle() on disposal) is still wrong. whenIdle() only OBSERVES quiescence; cancel() only stops queued/in-flight work. Neither unregisters the agent or detaches the session. Real teardown is AgentHandle.dispose(), whose disposer does `stop(); await agent.done` — stop the loop, await its exit, and unregister (packages/core/agent-loop/src/index.ts:271). Copying the old framing would reintroduce the orphaned-agent/session leak the AgentHandle seam exists to prevent. - docs/architecture.md: whenIdle() is a non-owner quiescence-observation hook, explicitly NOT teardown; teardown is `await AgentHandle.dispose()`. - docs/cookbook/extension-cookbook.md (prose + the ts comment): tear agents down via AgentHandle.dispose(), not agent.whenIdle(). - docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md: the lifecycle/disposal paragraph routes teardown through the handle's dispose(). --- docs/architecture.md | 2 +- docs/cookbook/extension-cookbook.md | 4 ++-- .../proposed/feature/2026-06-14-acp-agent-client-protocol.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index c7c84ce3b4..a24f4bdcd0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,7 +113,7 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told - `steer(content)` — mid-turn injection, drained **between steps**; behaves like `send` when idle - `inject(content)` — in-session context (`context/message` event); the next request sees it (Claude Code attachment / system-reminder analog). An inject made while the agent is *running* joins the open turn; an inject while *idle* is wrapped in a one-shot turn (`turn/start{trigger:injection}` → `context/message` → `turn/end`) so every event stays turn-enclosed (see [the turn-enclosure invariant](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). - `cancel(reason)` — the single public stop primitive: clears queued + steering work, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. A UI/ACP `session/cancel` maps to it. -- `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). The teardown signal: `cancel()` then `await whenIdle()` guarantees the in-flight turn has fully stopped. Observes the transition without disposing the agent. +- `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). A non-owner's quiescence-observation hook: it lets a consumer await the current work settling **without** disposing the agent. It is NOT teardown — it does not stop queued work, unregister the agent, or detach the session; a lifecycle owner tears an agent down with `await AgentHandle.dispose()` (which stops the loop, awaits its exit, and unregisters). - `session`, `status`, `options` **TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds the child Session with the parent's event log, spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Inter-agent channels beyond these primitives are deliberately deferred. diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 41fc85be0e..6c4498e254 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -56,7 +56,7 @@ export function apply(ctx: Context) { ## A client-driver plugin (external protocol bridge) -A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (the turn can end without its `agent/turn-end` event firing — fall back through the logged `turn/end` record), and on disposal reach quiescence (handle disposal aborts in-flight work then `await`s `agent.whenIdle()`), not just request it. +A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (the turn can end without its `agent/turn-end` event firing — fall back through the logged `turn/end` record), and tear each agent down through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters), not just `cancel()` — disposal must *reach* quiescence, not merely request it. `packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the deferred-permission-gate note. @@ -77,7 +77,7 @@ export function apply(ctx: Context) { } }) // Inbound "prompt": create/resume an agent and feed it; settle on turn end. - // Disposal awaits quiescence: handle disposal aborts, then await agent.whenIdle(). + // Teardown reaches quiescence via AgentHandle.dispose() (stop + await exit). } ``` diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md index e3af4abb3c..8244cab354 100644 --- a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md @@ -37,7 +37,7 @@ The mapping between ACP and existing harness seams — each row names the seam a The permission gate is the first real consumer of the `tools/execute` veto seam (the documented "single veto/sandbox/permission seam" plus the deferred "Permission system" TODO in [docs/architecture.md](../../../architecture.md)). It is a single global listener registered with `prepend: true` so it runs before any other tool wrapper. `ToolExecution.agent` is optional and the `Agent` interface carries no origin marker, so the bridge tracks ownership itself: it records each agent it creates in a `WeakMap` and the gate no-ops (calls `next()` immediately) for any `exec.agent` it does not own — non-ACP agents and the no-agent case pass straight through. For an owned agent it resolves the session, issues `session/request_permission`, and stores the pending resolver on that session's record so the outcome — or a `session/cancel`/connection-close — settles it exactly once. -Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and awaits quiescence — close the connection, settle/reject pending permissions, `agent.cancel()`, and wait for the agent to settle. The disposal-settle signal must come from the `dsh-agent` interface, not the loop: `agent.done` exists only on the concrete `ReactLoopAgent`, so the bridge instead observes `agent/status` reaching `idle`/`disposed` (or the RFC lifts a quiescence promise onto the `Agent` interface). Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn. +Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and must *reach* quiescence, not just request it — close the connection, settle/reject pending permissions, and dispose each owned agent through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters). Disposal must come through the `dsh-agent` handle seam, not the loop: `agent.done` exists only on the concrete `ReactLoopAgent`, so a bridge that wanted to wait on quiescence directly would instead observe `agent/status` reaching `idle`/`disposed` — but routing teardown through the handle's `dispose()` makes that unnecessary. Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn. **Dependency note (architecture rule).** [docs/architecture.md](../../../architecture.md) states "plugins depend on interface packages, never on `dsh-agent-loop`." Creating and resuming agents is currently only on the concrete `AgentLoop` (`ctx.agentLoop`), so this RFC proposes adding an **abstract create/resume factory** to the `dsh-agent` interface (registry-level `create({ sessionId, meta })` / `resume(...)`), implemented by the loop, so `dsh-acp` injects only `agents` (the interface) and the dependency rule holds. The alternative — injecting the concrete `agentLoop` and recording a documented exception in the architecture doc — is explicitly the non-preferred fallback. From c44ae5570c20324671de952659759b6e791a0f59 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:03:30 +0800 Subject: [PATCH 4/6] fix review findings: whenIdle() is observation, not the teardown await MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's confirmation pass found the teardown-framing error went deeper than the three prose spots already fixed: the whenIdle() JSDoc itself (and its mirrors) claimed "the quiescence signal a teardown awaits ... a lifecycle owner disposes the agent through its AgentHandle which ... awaits THIS". The disposer does not call whenIdle() — it does `stop(); await agent.done` directly (packages/core/agent-loop/src/index.ts:271). whenIdle() is the NON-OWNER observation hook; owner teardown awaits the loop-exit promise (done) through AgentHandle.dispose(). Reframe every copy accordingly: - packages/core/agent/src/types.ts: the Agent.whenIdle() contract JSDoc. - packages/core/agent-loop/src/agent.ts: the impl JSDoc. - packages/core/agent/README.md and docs/core-data-structures/core.md (the type-equiv mirror of the types.ts JSDoc — re-copied verbatim). - docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md:40 and :70: owner teardown via AgentHandle.dispose(); a non-owner observing quiescence uses the interface-level agent.whenIdle(), not hand-rolled agent/status. - Regenerate the cordis catalog (whenIdle source line moved). --- docs/cordis-catalog/events-and-services.md | 28 +++++++++---------- docs/core-data-structures/core.md | 18 ++++++------ .../2026-06-14-acp-agent-client-protocol.md | 4 +-- packages/core/agent-loop/src/agent.ts | 6 ++-- packages/core/agent/README.md | 2 +- packages/core/agent/src/types.ts | 18 ++++++------ 6 files changed, 37 insertions(+), 39 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index afa4356880..cfe4a5138e 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:137`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:135`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:143`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:141`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:187`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:148`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:193`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:200`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:161`](../../packages/core/agent/src/types.ts) ### `llm/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index ae6086b7b7..512b0fb838 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -253,12 +253,14 @@ interface Agent { /** * Resolve once the agent has reached quiescence after settling out of - * `running`, or immediately if it is already idle with no queued work. The - * quiescence signal a teardown awaits: a lifecycle owner disposes the agent - * through its `AgentHandle` (which aborts in-flight work then awaits this), so - * the caller proceeds only after queued/running work has fully stopped (a - * closing ACP connection, a disposing UI plugin) rather than returning while - * the driver is still streaming or about to start a queued turn. + * `running`, or immediately if it is already idle with no queued work. A + * non-owner's quiescence-observation hook: a consumer that does NOT own the + * agent's lifecycle (a closing ACP connection, a UI plugin) awaits this to + * proceed only after queued/running work has fully stopped, rather than + * returning while the driver is still streaming or about to start a queued + * turn. It does NOT tear the agent down — a lifecycle owner stops and + * unregisters the agent through its `AgentHandle.dispose()` (which awaits the + * loop-exit promise directly), separate from this. * * "Quiescence", not merely "status changed": a disposed agent emits * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop @@ -266,10 +268,6 @@ interface Agent { * to actually exit (the implementation chains the loop-exit promise), not just * observe the status flip. A mid-step disposal that never reaches `idle` still * unblocks the await this way. - * - * Distinct from disposal: `whenIdle()` observes the transition WITHOUT tearing - * the agent down. A consumer that owns the agent's lifecycle disposes it - * separately. */ whenIdle(): Promise diff --git a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md index 8244cab354..675e295c2c 100644 --- a/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md @@ -37,7 +37,7 @@ The mapping between ACP and existing harness seams — each row names the seam a The permission gate is the first real consumer of the `tools/execute` veto seam (the documented "single veto/sandbox/permission seam" plus the deferred "Permission system" TODO in [docs/architecture.md](../../../architecture.md)). It is a single global listener registered with `prepend: true` so it runs before any other tool wrapper. `ToolExecution.agent` is optional and the `Agent` interface carries no origin marker, so the bridge tracks ownership itself: it records each agent it creates in a `WeakMap` and the gate no-ops (calls `next()` immediately) for any `exec.agent` it does not own — non-ACP agents and the no-agent case pass straight through. For an owned agent it resolves the session, issues `session/request_permission`, and stores the pending resolver on that session's record so the outcome — or a `session/cancel`/connection-close — settles it exactly once. -Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and must *reach* quiescence, not just request it — close the connection, settle/reject pending permissions, and dispose each owned agent through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters). Disposal must come through the `dsh-agent` handle seam, not the loop: `agent.done` exists only on the concrete `ReactLoopAgent`, so a bridge that wanted to wait on quiescence directly would instead observe `agent/status` reaching `idle`/`disposed` — but routing teardown through the handle's `dispose()` makes that unnecessary. Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn. +Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and must *reach* quiescence, not just request it — close the connection, settle/reject pending permissions, and dispose each owned agent through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters). Owner teardown goes through that handle seam, not the loop's concrete `agent.done` (which exists only on `ReactLoopAgent`); a non-owner that merely wants to *observe* the current work settling without tearing the agent down awaits the interface-level `agent.whenIdle()`. Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn. **Dependency note (architecture rule).** [docs/architecture.md](../../../architecture.md) states "plugins depend on interface packages, never on `dsh-agent-loop`." Creating and resuming agents is currently only on the concrete `AgentLoop` (`ctx.agentLoop`), so this RFC proposes adding an **abstract create/resume factory** to the `dsh-agent` interface (registry-level `create({ sessionId, meta })` / `resume(...)`), implemented by the loop, so `dsh-acp` injects only `agents` (the interface) and the dependency rule holds. The alternative — injecting the concrete `agentLoop` and recording a documented exception in the architecture doc — is explicitly the non-preferred fallback. @@ -67,7 +67,7 @@ New third-party runtime dependency plus protocol drift: `@agentclientprotocol/sd Turn-settle and prompt-correlation hazards: honor "queued messages batch into one turn" and "`send()` does not synchronously flip to running" (see `stdio-chat.ts` and the defensive-patterns section of [docs/architecture.md](../../../architecture.md)); gate resolution on an observed running→idle transition and handle the empty-prompt / no-work branch so an RPC can't hang. -Permission-await and disposal hangs: a pending `request_permission` whose connection closes or whose turn aborts must settle exactly once; disposal must reach quiescence (observe the interface-level settle signal — `agent/status` reaching `idle`/`disposed`, since `agent.done` is `ReactLoopAgent`-only), not orphan awaits on a closed pipe. +Permission-await and disposal hangs: a pending `request_permission` whose connection closes or whose turn aborts must settle exactly once; disposal must reach quiescence — tear each owned agent down through `AgentHandle.dispose()` (which stops the loop and awaits its exit), rather than orphaning awaits on a closed pipe. The 100% per-file coverage gate (repo policy) makes a branch-heavy protocol bridge real work. Accepted deliberately, surfaced so it isn't a surprise at PR time. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 12f15868dc..402a9a8416 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -228,8 +228,10 @@ export class ReactLoopAgent implements Agent { * internal waiter (see {@link idleWaiters}) released on the next * running→idle/disposed transition, resolving on `idle` directly (the turn * fully ended) or chaining {@link done} on `disposed` (wait for the loop to - * actually exit). Implements the {@link Agent.whenIdle} contract used by - * teardown (handle disposal aborts in-flight work, then awaits `whenIdle()`). + * actually exit). Implements the {@link Agent.whenIdle} contract: a non-owner + * quiescence-observation hook, distinct from teardown (a lifecycle owner stops + * and unregisters via `AgentHandle.dispose()`, which awaits {@link done} + * directly, not through this). */ whenIdle(): Promise { if (this._status === 'disposed') return this.done diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 28a8cd0cf0..d0ec0ee614 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -57,7 +57,7 @@ The handle every plugin programs against: - `agent.steer(content, options?)` — steer a running turn (inject between steps); behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. -- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit), the signal a teardown awaits (a lifecycle owner disposes the handle, which aborts in-flight work then awaits this). Observes the transition without disposing the agent. +- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` ### Extension points diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index be0394adc5..ddb249b535 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -99,12 +99,14 @@ export interface Agent { /** * Resolve once the agent has reached quiescence after settling out of - * `running`, or immediately if it is already idle with no queued work. The - * quiescence signal a teardown awaits: a lifecycle owner disposes the agent - * through its `AgentHandle` (which aborts in-flight work then awaits this), so - * the caller proceeds only after queued/running work has fully stopped (a - * closing ACP connection, a disposing UI plugin) rather than returning while - * the driver is still streaming or about to start a queued turn. + * `running`, or immediately if it is already idle with no queued work. A + * non-owner's quiescence-observation hook: a consumer that does NOT own the + * agent's lifecycle (a closing ACP connection, a UI plugin) awaits this to + * proceed only after queued/running work has fully stopped, rather than + * returning while the driver is still streaming or about to start a queued + * turn. It does NOT tear the agent down — a lifecycle owner stops and + * unregisters the agent through its `AgentHandle.dispose()` (which awaits the + * loop-exit promise directly), separate from this. * * "Quiescence", not merely "status changed": a disposed agent emits * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop @@ -112,10 +114,6 @@ export interface Agent { * to actually exit (the implementation chains the loop-exit promise), not just * observe the status flip. A mid-step disposal that never reaches `idle` still * unblocks the await this way. - * - * Distinct from disposal: `whenIdle()` observes the transition WITHOUT tearing - * the agent down. A consumer that owns the agent's lifecycle disposes it - * separately. */ whenIdle(): Promise From 9e2833d15a67596421b7a7dfeae032f3b730692b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:21:32 +0800 Subject: [PATCH 5/6] fix review findings: drop the false "closing ACP connection" whenIdle() example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whenIdle() JSDoc cited "a closing ACP connection" as a non-owner that awaits whenIdle(). That is false against the code: ACP OWNS its agent handles and tears them down via rec.dispose()/handle.dispose() (quiesce() at packages/ui/acp/src/index.ts:666-686), never whenIdle(). The only whenIdle() consumers are tests (acp dispose/turns/edges specs, agent specs) — which is genuinely why the primitive stays (a test harness programs against the seam), but the contract doc must not claim a production ACP path uses it. Replace the parenthetical with truthful non-owning observers (a test awaiting a turn to settle, a monitor) and state explicitly that an OWNER does not need whenIdle() because AgentHandle.dispose() already awaits the loop-exit promise. - packages/core/agent/src/types.ts: the Agent.whenIdle() contract JSDoc. - docs/core-data-structures/core.md: the type-equiv mirror (re-copied verbatim). - Regenerate the cordis catalog (whenIdle source line shifted). --- docs/cordis-catalog/events-and-services.md | 28 +++++++++++----------- docs/core-data-structures/core.md | 13 +++++----- packages/core/agent/src/types.ts | 13 +++++----- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index cfe4a5138e..277a05fde2 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:135`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:136`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:141`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:142`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:219`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:155`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:187`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:148`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:149`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:193`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:194`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:174`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:207`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:200`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:161`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) ### `llm/*` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 512b0fb838..b9600c6f89 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -255,12 +255,13 @@ interface Agent { * Resolve once the agent has reached quiescence after settling out of * `running`, or immediately if it is already idle with no queued work. A * non-owner's quiescence-observation hook: a consumer that does NOT own the - * agent's lifecycle (a closing ACP connection, a UI plugin) awaits this to - * proceed only after queued/running work has fully stopped, rather than - * returning while the driver is still streaming or about to start a queued - * turn. It does NOT tear the agent down — a lifecycle owner stops and - * unregisters the agent through its `AgentHandle.dispose()` (which awaits the - * loop-exit promise directly), separate from this. + * agent's lifecycle awaits this to proceed only after queued/running work has + * fully stopped, rather than returning while the driver is still streaming or + * about to start a queued turn — without itself tearing the agent down. (A + * lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the + * loop-exit promise directly as part of stopping and unregistering. So this is + * for a non-owning observer — e.g. a test awaiting a turn to settle, or a + * monitor — that wants the settle signal but must not dispose the agent.) * * "Quiescence", not merely "status changed": a disposed agent emits * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index ddb249b535..6d27bf7256 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -101,12 +101,13 @@ export interface Agent { * Resolve once the agent has reached quiescence after settling out of * `running`, or immediately if it is already idle with no queued work. A * non-owner's quiescence-observation hook: a consumer that does NOT own the - * agent's lifecycle (a closing ACP connection, a UI plugin) awaits this to - * proceed only after queued/running work has fully stopped, rather than - * returning while the driver is still streaming or about to start a queued - * turn. It does NOT tear the agent down — a lifecycle owner stops and - * unregisters the agent through its `AgentHandle.dispose()` (which awaits the - * loop-exit promise directly), separate from this. + * agent's lifecycle awaits this to proceed only after queued/running work has + * fully stopped, rather than returning while the driver is still streaming or + * about to start a queued turn — without itself tearing the agent down. (A + * lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the + * loop-exit promise directly as part of stopping and unregistering. So this is + * for a non-owning observer — e.g. a test awaiting a turn to settle, or a + * monitor — that wants the settle signal but must not dispose the agent.) * * "Quiescence", not merely "status changed": a disposed agent emits * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop From d0e1b02a7f5dc2a3bf3913df2a5bfeefecc0899a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:36:04 +0800 Subject: [PATCH 6/6] fix review findings: correct whenIdle live-consumer claim in the stop-surface RFC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retained-whenIdle paragraph claimed "live consumers (the ACP bridge's settle points)", but `packages/ui/acp/src` has no whenIdle() call — the bridge owns its agents and tears them down via AgentHandle.dispose(). whenIdle()'s live consumers are ACP and agent TESTS awaiting settlement through the public seam. State that. --- .../simplification/2026-06-20-public-agent-stop-surface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md index a737acbbd0..67fe9b07fc 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md +++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md @@ -16,7 +16,7 @@ The extra surface area made the loop carry a public verb that is mostly a teardo Keep `cancel()` as the only public *stop* primitive on `Agent`. Lifecycle owners use `AgentHandle.dispose()` to stop and unregister an agent; non-owners use `cancel()` to abandon current and queued work. The implementation keeps a private abort controller, but it is not part of the plugin-facing `Agent` contract. -`whenIdle()` is **retained** as the public quiescence-observation primitive (resolve once the agent settles out of `running`, resolve immediately when already idle, await the loop exit when disposed). It is not a stop verb; it is how a non-owner observes the stop *completing* without disposing the agent, and it has live consumers (the ACP bridge's settle points). +`whenIdle()` is **retained** as the public quiescence-observation primitive (resolve once the agent settles out of `running`, resolve immediately when already idle, await the loop exit when disposed). It is not a stop verb; it is how a non-owner observes the stop *completing* without disposing the agent. Its live consumers are ACP and agent tests that await settlement through this public seam (`packages/ui/acp/tests`, `packages/core/agent-loop/tests`); the production ACP bridge owns its agents and tears them down through `AgentHandle.dispose()`, so `packages/ui/acp/src` itself has no `whenIdle()` call. Delete public `abort()`, the tests that exercise it as standalone API, and the docs that describe step-only abort as an embedding feature. Empty-queue abort tests migrate to `cancel(reason)` where they still prove cancellation behavior; tests whose subject is the loop's internal `AbortController` behavior drive that controller directly via an in-package typed cast to the private field; tests that only pin the removed no-arg `abort()` default go away with the method. The disposer remains async and still waits for the loop to stop.