feat(core): make turn cancellation explicit
This commit is contained in:
@@ -102,7 +102,7 @@ Post-tool context follows all results, preserving call/result adjacency. Steerin
|
||||
|
||||
### Failure Boundaries
|
||||
|
||||
The turn contains listener, adapter, and step failures: it records an error reason and emits `agent/error` without killing the driver. `cancel()` clears pending work, aborts active model/tool work when possible, and records the turn end. Disposal stops and drains the loop before unregistering the agent.
|
||||
The turn contains listener, adapter, and step failures: it records an error reason and emits `agent/error` without killing the driver. One explicit `AbortSignal` spans prompt submission, prompt assembly, all steps, continuation, turn close, and flush; `cancel()` clears pending work and carries a typed `user` or `parent` runtime cause, while the durable turn records only `aborted` and disposal remains a distinct higher-priority terminal state. Cooperative work must settle before the loop reports quiescence. See the [explicit turn cancellation decision](rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.md).
|
||||
|
||||
Every session event is turn-enclosed. Reload closes an interrupted tail with a synthetic `interrupted` end; failures after durable turn close only emit `agent/error`. A turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) defines each variant.
|
||||
|
||||
@@ -116,7 +116,7 @@ Every live agent owns a scoped `agent.ctx`; its registrations shadow globals, re
|
||||
|
||||
### Agent Execution Context
|
||||
|
||||
`AgentLoop` wraps each concrete driver in process-local `ctx.agentExecution`; child creation and setup stay outside its boundary, and explicit identities remain authoritative. See the [package contract](../packages/core/agent-execution/README.md) and [decision](rfc/implemented/architecture/2026-07-15-agent-execution-context.md).
|
||||
`AgentLoop` wraps each concrete driver in process-local `ctx.agentExecution`; its ALS frame contains only `{ agent }`. Child creation and setup stay outside the boundary, and turn, step, signal, cwd, and authority remain explicit. See the [package contract](../packages/core/agent-execution/README.md) and [decision](rfc/implemented/architecture/2026-07-15-agent-execution-context.md).
|
||||
|
||||
## State
|
||||
|
||||
|
||||
@@ -863,7 +863,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:143`](../packages/core/system-prompt/src/index.ts)
|
||||
Source: [`packages/core/system-prompt/src/index.ts:147`](../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-time-context`
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ A fully configured agent and live session were published. Setup is composition-o
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:139`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:206`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/disposed` — emit
|
||||
|
||||
@@ -35,7 +35,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef
|
||||
|
||||
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:215`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/error` — emit
|
||||
|
||||
@@ -47,7 +47,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:283`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:357`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/pre-step` — serial
|
||||
|
||||
@@ -59,19 +59,19 @@ Awaited serial checkpoint for session-surface mutation after prompt assembly and
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../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:269`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/prompt-submit` — waterfall
|
||||
|
||||
Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default.
|
||||
Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default. The signal controls only this turn; listeners may cooperate with it but must not retain it to control another turn.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
```
|
||||
|
||||
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:282`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/queued` — emit
|
||||
|
||||
@@ -83,19 +83,19 @@ Detached, frozen content entered the agent's inbox. Source defaults have already
|
||||
|
||||
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:167`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:234`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/request` — waterfall
|
||||
|
||||
Replace the frozen call configuration. Model-visible content must use logged channels; this seam cannot mutate messages. Injection here joins the next request because the current step boundary is already fixed.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../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:295`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-prefix` — waterfall
|
||||
|
||||
@@ -107,7 +107,7 @@ Compose request-only messages placed before derived history. The frozen result i
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:310`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-start` — emit
|
||||
|
||||
@@ -119,7 +119,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../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:247`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/status` — emit
|
||||
|
||||
@@ -131,43 +131,43 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/step-result` — waterfall
|
||||
|
||||
Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
|
||||
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise<Message>): Promise<Message>
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:322`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-continuation` — waterfall
|
||||
|
||||
Override whether the turn continues. The default continues after tool calls or steering and stops otherwise; a continue reason becomes steering.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-stop` — serial
|
||||
|
||||
Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): ContinuationStop | undefined
|
||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<ContinuationStop | undefined> | ContinuationStop | undefined
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:344`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
## `approval/*`
|
||||
|
||||
@@ -325,13 +325,13 @@ Source: [`packages/subagent/subagent/src/index.ts:99`](../../packages/subagent/s
|
||||
|
||||
### `system-prompt/assemble` — waterfall
|
||||
|
||||
Expert waterfall over the assembled sections, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative.
|
||||
Expert waterfall over the assembled sections, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns.
|
||||
|
||||
```ts cordis-catalog
|
||||
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||||
```
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:27`](../../packages/core/system-prompt/src/index.ts)
|
||||
Source: [`packages/core/system-prompt/src/index.ts:29`](../../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
### `system-prompt/change` — emit
|
||||
|
||||
@@ -341,7 +341,7 @@ Emitted when any prompt provider changes. This registry notification is unfilter
|
||||
'system-prompt/change'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:33`](../../packages/core/system-prompt/src/index.ts)
|
||||
Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
## `tools/*`
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine
|
||||
async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
|
||||
```
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:209`](../../packages/core/system-prompt/src/index.ts)
|
||||
Source: [`packages/core/system-prompt/src/index.ts:213`](../../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
## `ctx.tasks` — `TaskService`
|
||||
|
||||
|
||||
@@ -246,6 +246,14 @@ The fifteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`,
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
`AgentCancelCause` identifies the runtime caller without widening the durable turn outcome. The concrete Agent validates, detaches, and freezes this value before placing it on the current turn signal; Session replay records only that the turn was aborted.
|
||||
|
||||
```ts type-equiv
|
||||
type AgentCancelCause =
|
||||
| { readonly kind: 'user' }
|
||||
| { readonly kind: 'parent' }
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
interface Agent {
|
||||
readonly id: AgentId
|
||||
@@ -300,22 +308,14 @@ interface Agent {
|
||||
inject(content: ContentBlock[], options?: SendOptions): void
|
||||
|
||||
/**
|
||||
* 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);
|
||||
* - aborts the in-flight step if one is running (the turn ends `aborted`);
|
||||
* - drops a turn that is about to start (a `cancel()` landing in the
|
||||
* pre-step window — after a `send()` queued but before the loop flips to
|
||||
* `running`, or after `running` is emitted but before the first step) so
|
||||
* that queued prompt does not run and cannot be batched into the cancelled
|
||||
* turn.
|
||||
*
|
||||
* After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state.
|
||||
* `cancel()` on an idle agent with nothing queued or running is a safe no-op
|
||||
* — it does NOT arm anything that would drop a later legitimate prompt.
|
||||
* Clear queued and steering work, including work waiting to start, and abort
|
||||
* the active turn. The first cause wins for that turn, and `whenIdle()` resolves
|
||||
* after cancellation reaches quiescence. Omission means `{ kind: 'user' }`;
|
||||
* invalid causes throw synchronously even while idle. Idle cancellation is a
|
||||
* no-op after validation and does not arm a later cancel.
|
||||
* @param cause - the stable caller intent carried by the current turn signal.
|
||||
*/
|
||||
cancel(reason?: string): void
|
||||
cancel(cause?: AgentCancelCause): void
|
||||
|
||||
/**
|
||||
* Resolve once the agent has reached quiescence after settling out of
|
||||
|
||||
@@ -254,10 +254,13 @@ interface TurnTriggerMap {
|
||||
|
||||
## Why a turn ended: `TurnEndReasonMap`
|
||||
|
||||
`aborted` is intentionally a coarse durable outcome: it records that cancellation interrupted the live turn, not which runtime caller requested it. The runtime-only caller vocabulary belongs to [`AgentCancelCause`](core.md#the-agent-handle); a future audit requirement would use a separate control-request event rather than overloading the terminal result.
|
||||
|
||||
```ts type-equiv
|
||||
interface TurnEndReasonMap {
|
||||
completed: { kind: 'completed' }
|
||||
aborted: { kind: 'aborted'; reason?: string }
|
||||
/** A cancellation request interrupted the live turn. */
|
||||
aborted: { kind: 'aborted' }
|
||||
/**
|
||||
* The turn failed: a step threw or the model reported a failure. `step` is the
|
||||
* step number the failure occurred on (the operational error's location — the
|
||||
|
||||
@@ -6,11 +6,12 @@ Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system-
|
||||
|
||||
## Assembly context
|
||||
|
||||
`AssembleContext` identifies the scope layer one assembly resolves. It is merge-extensible: `dsh-agent` adds the optional live `agent` field, and `assembleContextFor(agent)` sets that field and `scope` together.
|
||||
`AssembleContext` identifies the scope layer one assembly resolves and may carry the explicit control signal for that request. It is merge-extensible: `dsh-agent` adds the optional live `agent` field, and `assembleContextFor(agent, signal)` sets the explicit fields together. A bare assembly has neither scope nor signal.
|
||||
|
||||
```ts type-equiv
|
||||
interface AssembleContext {
|
||||
scope?: ScopeKey
|
||||
signal?: AbortSignal
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -7,19 +7,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
|
||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio) |
|
||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:206`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:215`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:357`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:269`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:282`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:234`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:310`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio) |
|
||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:322`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:344`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:68`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
@@ -33,8 +33,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:88`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:99`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:98`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||
|
||||
@@ -57,7 +57,7 @@ Raw stream chunk — token-level replay fidelity.
|
||||
|
||||
Types: [StreamChunk](core-data-structures/llm-streaming.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `assistant/message` — surface
|
||||
|
||||
@@ -69,7 +69,7 @@ Assembled assistant message for one step (derived history uses this). Carries th
|
||||
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `bash/*`
|
||||
|
||||
@@ -129,7 +129,7 @@ In-session context injection (file-change notices, subdir AGENTS.md, skill conte
|
||||
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `hook/*`
|
||||
|
||||
@@ -177,7 +177,7 @@ Durable record of a prompt veto and its reason. It is log-only: the blocked prom
|
||||
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `request/*`
|
||||
|
||||
@@ -189,7 +189,7 @@ Full EpochHeader for the next request, appended inside its step before dispatch.
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `request/header-delta` — log-only
|
||||
|
||||
@@ -199,7 +199,7 @@ Log-only amendment to the folded EpochHeader. System and tools use their delta c
|
||||
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `steering/*`
|
||||
|
||||
@@ -213,7 +213,7 @@ Steering content injected between steps of a running turn.
|
||||
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `step/*`
|
||||
|
||||
@@ -225,7 +225,7 @@ Closes step `step` of turn `turn`.
|
||||
'step/end': { turn: number; step: number }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `step/start` — log-only
|
||||
|
||||
@@ -235,7 +235,7 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it
|
||||
'step/start': { turn: number; step: number }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:225`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `todo/*`
|
||||
|
||||
@@ -249,7 +249,7 @@ Whole-list snapshot; the latest write wins on replay. It is log-only UI state an
|
||||
|
||||
Types: [TodoItem](core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:272`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `tool/*`
|
||||
|
||||
@@ -263,7 +263,7 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st
|
||||
|
||||
Types: [CallId](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `tool/code-dispatch` — log-only
|
||||
|
||||
@@ -287,7 +287,7 @@ A completed tool call's model-facing result, plus an optional tool-private `meta
|
||||
|
||||
Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:265`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `turn/*`
|
||||
|
||||
@@ -301,7 +301,7 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai
|
||||
|
||||
Types: [TurnEndReason](core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `turn/start` — log-only
|
||||
|
||||
@@ -313,7 +313,7 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch
|
||||
|
||||
Types: [TurnTrigger](core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:217`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:218`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `user/*`
|
||||
|
||||
@@ -327,4 +327,4 @@ A user-visible prompt (queued message drained at turn start).
|
||||
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:230`](../packages/core/session/src/types.ts)
|
||||
|
||||
@@ -150,6 +150,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| [Single-file executable SDK runtime distribution (single-exe)](implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) | 2026-07-10 |
|
||||
| [Agent-scope runtime design and correctness](implemented/architecture/2026-07-12-agent-scope-runtime-design.md) | 2026-07-12 |
|
||||
| [Agent execution context over AsyncLocalStorage](implemented/architecture/2026-07-15-agent-execution-context.md) | 2026-07-15 |
|
||||
| [Explicit turn cancellation capability](implemented/architecture/2026-07-16-explicit-turn-cancellation.md) | 2026-07-16 |
|
||||
|
||||
### Process
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ Several ACP and tool-bash limitations were symptoms of the same missing seam: pl
|
||||
|
||||
Three seams: the queue-aware cancel, the `AgentHandle` disposer, and the bash owner token.
|
||||
|
||||
### 1. Queue-aware `Agent.cancel(reason?)`
|
||||
### 1. Queue-aware `Agent.cancel(cause?)`
|
||||
|
||||
`cancel()` is the single public stop primitive. It clears queued and steering input, aborts an in-flight step, and arms a turn-scoped marker checked at each turn boundary. A queued prompt therefore cannot start after cancellation or absorb later input. `whenIdle()` waits for post-cancel quiescence, and ACP `session/cancel` maps to this method. An idle cancel does not arm the marker.
|
||||
`cancel()` is the single public stop primitive. It clears queued and steering input and aborts the active turn through one private turn cancellation holder; a cause-less pre-run marker covers work not yet claimed by the driver without leaking into replacement input. The typed cause is `user` or `parent`, with omission and ACP `session/cancel` mapping to `user`. `whenIdle()` waits for actual post-cancel quiescence, and an idle cancel validates its cause without arming future work. See the [explicit turn cancellation contract](2026-07-16-explicit-turn-cancellation.md).
|
||||
|
||||
### 2. `AgentHandle` async disposer
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-16-explicit-turn-cancellation.md: 3716895c145c24603a93dab99108489e19154490
|
||||
2026-07-16-explicit-turn-cancellation.zh.md: d2f8a4227d38a05d7ed1dd5135a20d3b4e94deed
|
||||
@@ -0,0 +1,55 @@
|
||||
# RFC: Explicit turn cancellation capability
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-16-explicit-turn-cancellation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Cancellation is a control capability with a shorter lifetime than an Agent driver. A free-form string cannot distinguish callers exhaustively, and a step-local controller cannot interrupt prompt submission, prompt assembly, continuation, or terminal turn policy. Storing `Error`, `AbortSignal.reason`, or backend-private objects would also expose unstable runtime details to durable replay.
|
||||
|
||||
The [Agent execution context decision](2026-07-15-agent-execution-context.md) intentionally keeps the AsyncLocalStorage frame at `{ agent }`. Adding turn, step, or signal state to that driver-lifetime frame would make stale asynchronous descendants appear to retain authority over later turns. Cancellation therefore needs one turn owner and explicit propagation without creating another ambient context or public turn wrapper.
|
||||
|
||||
## Decision
|
||||
|
||||
Agent owns the runtime-only `AgentCancelCause` union `{ kind: 'user' } | { kind: 'parent' }`; `agent.cancel()` defaults to `user`. The normalization boundary accepts only an exact ordinary or null-prototype object with one supported `kind`, then returns a detached frozen value for the current turn signal. Strings, extra or symbol fields, unknown kinds, arrays, class instances, `Error`, and `AbortSignal` are rejected synchronously even when the Agent is idle.
|
||||
|
||||
An interrupted live turn ends with the coarse durable `{ kind: 'aborted' }` outcome. The terminal event records what happened to the turn, while the runtime signal identifies who requested cancellation; it does not duplicate `user` or `parent` into replay. A future audit requirement uses a separate control-request event so a request and its eventual outcome remain distinct. Durable events contain no stack, signal, error object, free-form cancellation text, or backend-private detail.
|
||||
|
||||
AgentLoop privately owns one `TurnCancellation` per prospective turn. It installs the holder before notifying `agent/status = running`, retains its single `AbortController` through prompt processing, prompt assembly, every step, model and tool execution, continuation, `agent/turn-stop`, `turn/end`, and durability flush, then clears it. Every participating method, event, and request value receives that same explicit signal; the next turn receives a fresh signal.
|
||||
|
||||
The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. It clears the queued and steering work that existed when `cancel()` ran without arming cancellation for future prompts. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work.
|
||||
|
||||
The explicit event signatures keep their positional form and place `signal` immediately before a waterfall's final `next`. Prompt submission, request configuration, step-result processing, continuation, and terminal stop join the pre-existing explicit signal seams for pre-step, session prefix, model generation, tool execution, approval, and subagent or workflow requests. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value. Listeners may cooperate with the signal but must not retain it to control another turn.
|
||||
|
||||
`ctx.agentExecution` remains identity-only. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority, and `agentInterruptReasonOf(signal)` reads only its explicit argument. Concurrent Agents isolate both their ALS identities and their turn signals; a child Agent shadows the parent identity while its parent request signal still travels through the subagent seam.
|
||||
|
||||
Agent disposal requests the runtime-only `{ kind: 'disposed' }` interruption on the active holder. If cancellation already won the controller reason, the reason cannot be rewritten, so terminal classification first checks lifecycle state: disposed wins, then a supported `user` or `parent` cause becomes the coarse aborted outcome, and unrelated exceptions retain the existing error path. ACP cancellation maps to `user`; in-process spawn and fork propagation map to `parent`. Remote ACP subagents retain their existing wire protocol.
|
||||
|
||||
Cancellation remains cooperative. The loop checks interruption before and after awaited boundaries but does not use `Promise.race` to abandon an in-process listener, adapter, or tool Promise. Work that ignores the signal must settle before `whenIdle()`, handle disposal, and scope teardown report quiescence.
|
||||
|
||||
## Verification
|
||||
|
||||
Contract tests verify strict runtime cause validation, frozen detachment, default and first-wins behavior, the coarse Session JSON round trip, ACP `user`, in-process subagent `parent`, and disposal precedence. Loop tests make cooperative listeners wait on the signal at prompt submission, system-prompt assembly, session prefix, pre-step, request, model stream, step result, tool execution, continuation, and terminal stop; they assert one signal within a turn and a fresh signal across turns.
|
||||
|
||||
Execution-context tests assert that every hook still observes exactly `{ agent }`, concurrent Agents retain independent identities and signals, and nested child creation shadows only identity. Race tests cover idle cancellation, pre-run cancellation, replacement submission from a `running` listener, repeated cancellation, and cancel-versus-dispose quiescence.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Store the signal in ALS.** ALS follows asynchronous descendants for the entire driver lifetime, while cancellation authority ends with one turn. A leaked callback could observe a stale signal or require mutable frame replacement, so the identity frame stays `{ agent }` and control remains explicit.
|
||||
|
||||
**Persist a free-form string reason.** Strings admit spelling drift, prevent exhaustive switching, and encourage consumers to parse presentation text. The runtime uses a closed discriminated union, while the terminal record needs only the stable aborted outcome.
|
||||
|
||||
**Persist the typed caller cause in `turn/end`.** No production replay, UI, ACP, telemetry, or workflow consumer distinguishes `user` from `parent`. Copying the request source into the terminal result would conflate two facts and add Session-specific validation without a consumer; a future audit surface can record a separate cancellation-request event.
|
||||
|
||||
**Define speculative `superseded`, `timeout`, and `shutdown` variants now.** No current Agent cancellation producer implements those semantics. `shutdown` is already lifecycle disposal, and timeout or supersession should enter the union only with an owning policy and unique terminal meaning.
|
||||
|
||||
**Expose public turn or step context wrappers.** Existing positional seams already identify Agent, turn, and step. A wrapper would widen every API, duplicate ownership, and tempt callers to treat a captured object as durable authority.
|
||||
|
||||
**Abandon uncooperative work after a grace period.** Returning idle while same-process work still runs breaks teardown and resource-ownership guarantees. Hard termination requires a worker or process isolation boundary and is outside this control seam.
|
||||
|
||||
## Consequences
|
||||
|
||||
Cancellation has one runtime owner, one signal per turn, and one typed runtime caller vocabulary. Session retains the coarse `aborted` outcome that its consumers actually use, stays isolated from runtime objects, and no longer needs cancellation-specific canonicalization. Cooperative cancellation reaches every asynchronous turn seam, including work before the first step and after the last one.
|
||||
|
||||
The explicit signal adds parameters to several public events and requires plugins to forward cancellation deliberately. This is intentional: authority is visible at the call boundary, lifetime matches the turn, and stale ambient descendants cannot acquire control. Uncooperative in-process work may delay cancellation, but the reported quiescent state remains truthful.
|
||||
@@ -0,0 +1,55 @@
|
||||
# RFC:显式的 turn 取消能力
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-16-explicit-turn-cancellation.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
取消是一种生命周期短于 Agent 驱动的控制能力。自由文本字符串无法对调用方进行穷尽区分,步骤级 controller 也无法中断 prompt 提交、prompt 组装、continuation 或 turn 终止策略。持久化 `Error`、`AbortSignal.reason` 或后端私有对象还会把不稳定的运行时细节暴露给持久化 replay。
|
||||
|
||||
[Agent 执行上下文决策](2026-07-15-agent-execution-context.md)有意让 AsyncLocalStorage 帧保持为 `{ agent }`。若把 turn、步骤或 signal 状态加入这个与驱动同生命周期的帧,陈旧的异步后代就会看似仍对后续 turn 拥有权限。因此,取消需要一个 turn 归属方和显式传播,不能引入另一套环境上下文或公开 turn 包装类型。
|
||||
|
||||
## 决策
|
||||
|
||||
Agent 拥有仅用于运行时的 `AgentCancelCause` union:`{ kind: 'user' } | { kind: 'parent' }`;`agent.cancel()` 默认使用 `user`。规范化边界只接受恰好包含一个受支持 `kind` 的普通对象或 null-prototype 对象,并返回供当前 turn signal 使用的分离且冻结值。即使 Agent 处于 idle,字符串、额外字段或 symbol 字段、未知 kind、数组、class 实例、`Error` 和 `AbortSignal` 也会被同步拒绝。
|
||||
|
||||
被中断的 live turn 以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录 turn 发生了什么,运行时 signal 则标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。未来若有审计需求,应使用独立的控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含 stack、signal、错误对象、自由文本取消原因或后端私有细节。
|
||||
|
||||
AgentLoop 为每个预期 turn 私有地拥有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装 holder,使其中唯一的 `AbortController` 持续覆盖 prompt 处理、prompt 组装、每个步骤、模型与工具执行、continuation、`agent/turn-stop`、`turn/end` 和持久化 flush,随后清除 holder。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一 turn 会收到全新 signal。
|
||||
|
||||
对于 turn 被认领前取消的 queued work,驱动只保留一个不带 cause 的 pre-run marker。它会清除 `cancel()` 调用时已存在的 queued 和 steering work,但不会为未来 prompt 预设取消。若 `running` listener 同步取消旧工作并发送 replacement,驱动会丢弃已 aborted 的 holder,并为 replacement 创建全新 holder。同一 active holder 上的重复取消遵循 first-wins,后续调用仍可清除新进入队列的 pending work。
|
||||
|
||||
显式事件签名保留 positional 形态,并把 `signal` 放在 waterfall 最后一个参数 `next` 之前。Prompt 提交、请求配置、步骤结果处理、continuation 和终止停止加入已有的 pre-step、session prefix、模型生成、工具执行、审批以及 subagent 或 workflow 请求显式 signal seam。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值。Listener 可以配合该 signal 取消,但不得保留它来控制另一 turn。
|
||||
|
||||
`ctx.agentExecution` 仍只提供身份。环境中的 Agent 并不代表存活、当前 turn 或取消权限,`agentInterruptReasonOf(signal)` 也只读取其显式参数。并发 Agent 会同时隔离各自的 ALS 身份和 turn signal;子 Agent 会遮蔽父 Agent 身份,而父请求 signal 仍通过 subagent seam 传递。
|
||||
|
||||
Agent dispose 会在 active holder 上请求仅用于运行时的 `{ kind: 'disposed' }` 中断。若取消已经先成为 controller reason,该 reason 无法改写,因此终态分类会先检查生命周期状态:disposed 优先,之后受支持的 `user` 或 `parent` cause 形成粗粒度 aborted 结果,其他异常保留现有 error 路径。ACP 取消映射为 `user`;进程内 spawn 和 fork 的传播映射为 `parent`。远程 ACP subagent 保持现有 wire protocol。
|
||||
|
||||
取消仍然是协作式的。Loop 会在 await 边界前后检查中断,但不会用 `Promise.race` 放弃进程内 listener、adapter 或工具 Promise。忽略 signal 的工作必须真正结算,`whenIdle()`、handle dispose 和 scope teardown 才会报告静止状态。
|
||||
|
||||
## 验证
|
||||
|
||||
契约测试验证严格的运行时 cause 校验、冻结分离、默认与 first-wins 行为、粗粒度 Session JSON 往返、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。Loop 测试让协作式 listener 在 prompt 提交、system-prompt 组装、session prefix、pre-step、请求、模型 stream、步骤结果、工具执行、continuation 和终止停止处等待 signal;并断言同一 turn 使用一个 signal,不同 turn 使用全新 signal。
|
||||
|
||||
执行上下文测试断言所有 hook 仍只观察到 `{ agent }`,并发 Agent 保持独立的身份与 signal,嵌套子 Agent 创建只遮蔽身份。竞态测试覆盖 idle 取消、pre-run 取消、从 `running` listener 提交 replacement、重复取消以及 cancel 与 dispose 竞争下的静止状态。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**把 signal 存入 ALS。** ALS 会在整个驱动生命周期内跟随异步后代,而取消权限在一个 turn 结束时就已终止。泄漏的回调可能观察到陈旧 signal,或者迫使实现替换可变帧,因此身份帧保持 `{ agent }`,控制能力继续显式传递。
|
||||
|
||||
**持久化自由文本 reason。** 字符串允许拼写漂移、阻碍穷尽 switch,还会鼓励消费方解析展示文本。运行时使用封闭的 discriminated union,终态记录只需要稳定的 aborted 结果。
|
||||
|
||||
**在 `turn/end` 中持久化类型化调用方 cause。** 当前没有任何生产环境中的 replay、UI、ACP、telemetry 或 workflow 消费方区分 `user` 与 `parent`。把请求来源复制到终态结果会混淆两项事实,还会在没有消费方的情况下引入 Session 特有校验;未来的审计接口可以记录独立的取消请求事件。
|
||||
|
||||
**现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose;timeout 或 supersession 只有在拥有明确归属策略和唯一终态含义时才应进入 union。
|
||||
|
||||
**公开 turn 或步骤 context 包装类型。** 现有 positional seam 已经标识 Agent、turn 和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。
|
||||
|
||||
**在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就返回 idle 会破坏 teardown 与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。
|
||||
|
||||
## 后果
|
||||
|
||||
取消拥有一个运行时归属方、每个 turn 一个 signal,以及一套类型化的运行时调用方词汇。Session 保留其消费方实际使用的粗粒度 `aborted` 结果,与运行时对象保持隔离,也不再需要取消专用的规范化逻辑。协作式取消覆盖每个异步 turn seam,包括第一个步骤之前和最后一个步骤之后的工作。
|
||||
|
||||
显式 signal 会给多个公开事件增加参数,并要求插件有意识地转发取消。这是有意设计:权限在调用边界可见,生命周期与 turn 匹配,陈旧的环境异步后代无法获得控制能力。不协作的进程内工作可能延迟取消,但所报告的静止状态仍然真实。
|
||||
@@ -6,19 +6,19 @@ Status: implemented
|
||||
|
||||
## 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 public `Agent` handle exposed two overlapping ways to stop in-flight work: step-only `abort()` and queue-aware `cancel()`. The former preserved queued input while the latter clears queued and steering work and aborts the active turn. In production, ACP uses `cancel()` for `session/cancel`, while lifecycle owners tear down agents through `AgentHandle.dispose()`. No production caller needs a bare step-only 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 behavioral distinction is real, but no shipping code needs the narrower operation. AgentLoop instead owns one private cancellation holder for the whole turn. `cancel(cause?)` carries a typed `user` or `parent` cause, defaults to `user`, and drops pending input; disposal remains a separate lifecycle interruption. The complete ownership and propagation contract lives in the [explicit turn cancellation RFC](../architecture/2026-07-16-explicit-turn-cancellation.md).
|
||||
|
||||
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.
|
||||
|
||||
## Decision
|
||||
|
||||
`cancel()` is 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.
|
||||
`cancel()` is 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 turn cancellation holder, 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. 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.
|
||||
|
||||
Public `abort()` is deleted, with the tests that exercised it as standalone API and the docs that described step-only abort as an embedding feature. Empty-queue abort tests migrated to `cancel(reason)` where they still prove cancellation behavior; tests whose subject is the loop's internal `AbortController` drive that controller directly via an in-package typed cast to the private field; tests that only pinned the removed no-arg `abort()` default went with the method. The disposer remains async and still waits for the loop to stop.
|
||||
Public `abort()` is absent, and the disposer remains async and waits for the loop to stop. Tests exercise cancellation through the public typed cause and explicit signal seams rather than reaching into the holder.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
Reference in New Issue
Block a user