فهرست منبع

simplify(agent): drop the unused public Agent.abort(), keep whenIdle()

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
Tianyi Cui 3 ماه پیش
والد
کامیت
f6bd1468f2

+ 2 - 2
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.
 

+ 2 - 2
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().
 }
 ```
 

+ 16 - 18
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<AgentHandle>
 ```
 
-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<BashRunResult>
 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

+ 6 - 10
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

+ 1 - 1
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
 

+ 1 - 1
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<Agent, sessionId>` 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.
 

+ 1 - 1
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
 

+ 1 - 5
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<void> {
     if (this._status === 'disposed') return this.done

+ 4 - 4
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 */

+ 1 - 17
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' })
-  })
 })

+ 3 - 3
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' }])

+ 12 - 3
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

+ 2 - 3
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

+ 6 - 10
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

+ 0 - 1
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() },
   }