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
This commit is contained in:
Tianyi Cui
2026-06-21 05:50:39 +08:00
parent d6a2ab30c8
commit f6bd1468f2
15 changed files with 58 additions and 81 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -13,7 +13,6 @@ function stubAgent(rawId: string): Agent {
send() {},
steer() {},
inject() {},
abort() {},
cancel() {},
whenIdle() { return Promise.resolve() },
}