From c238992fbbba0b35f7bf2848712960a39e49ea0e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 16 Jul 2026 18:12:34 +0800 Subject: [PATCH 1/5] feat(core): make turn cancellation explicit --- docs/architecture.md | 4 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 44 +-- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.md | 30 +- docs/core-data-structures/session.md | 5 +- docs/core-data-structures/system-prompt.md | 3 +- docs/event-producer-consumer.md | 30 +- docs/persistence-catalog.md | 30 +- docs/rfc/INDEX.md | 1 + ...-18-agent-lifecycle-and-ownership-seams.md | 4 +- ...07-16-explicit-turn-cancellation.i18n.yaml | 6 + .../2026-07-16-explicit-turn-cancellation.md | 55 ++++ ...026-07-16-explicit-turn-cancellation.zh.md | 55 ++++ .../2026-06-20-public-agent-stop-surface.md | 8 +- .../tests/snapshots/cancel/session.jsonl | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 20 +- packages/core/agent-loop/README.md | 6 +- packages/core/agent-loop/src/agent.ts | 61 ++-- packages/core/agent-loop/src/cancellation.ts | 31 ++ packages/core/agent-loop/src/inbox.ts | 2 +- packages/core/agent-loop/src/loop.ts | 236 ++++++++------- .../agent-loop/tests/agent-execution.spec.ts | 76 +++++ packages/core/agent-loop/tests/agent.spec.ts | 2 +- packages/core/agent-loop/tests/cancel.spec.ts | 268 +++++++++++++++--- .../tests/contract-regressions.spec.ts | 51 ++-- .../agent-loop/tests/coverage-edges.spec.ts | 4 +- .../agent-loop/tests/interception.spec.ts | 8 +- packages/core/agent-loop/tests/loop.spec.ts | 14 +- .../tests/request-reconstruction.spec.ts | 6 +- packages/core/agent-loop/tests/resume.spec.ts | 2 +- .../core/agent-loop/tests/turn-stop.spec.ts | 2 +- packages/core/agent/README.md | 4 +- packages/core/agent/src/dispatch.ts | 5 +- packages/core/agent/src/types.ts | 100 ++++++- packages/core/agent/tests/agent.spec.ts | 6 +- packages/core/session/README.md | 2 + packages/core/session/src/types.ts | 3 +- packages/core/session/tests/fork.spec.ts | 2 +- packages/core/session/tests/session.spec.ts | 10 + packages/core/system-prompt/README.md | 4 +- packages/core/system-prompt/src/index.ts | 4 + packages/guard/repeat-tool-guard/src/index.ts | 2 +- packages/hooks/hook-protocol/src/runner.ts | 2 +- packages/hooks/hooks-claude/src/index.ts | 8 +- packages/hooks/hooks-codex/src/index.ts | 8 +- .../subagent/subagent-inprocess/src/index.ts | 2 +- .../subagent-inprocess/src/structured.ts | 2 +- .../tests/structured.spec.ts | 4 +- .../tests/subagent-inprocess.spec.ts | 11 +- packages/ui/acp/src/index.ts | 10 +- packages/ui/acp/tests/codec.spec.ts | 2 +- packages/ui/acp/tests/turns.spec.ts | 4 + scripts/translation-pairing.manifest.json | 1 + scripts/type-equiv.manifest.json | 1 + 55 files changed, 884 insertions(+), 383 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml create mode 100644 docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.md create mode 100644 docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md create mode 100644 packages/core/agent-loop/src/cancellation.ts diff --git a/docs/architecture.md b/docs/architecture.md index d4faf07f4b..18675d6862 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 06547594ec..3981ea5dc6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ede449cb1c..180537eec1 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -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, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise +'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise): Promise ``` 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, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise +'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise): Promise ``` 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, turn: number, step: number, message: Message, next: () => Promise): Promise +'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise): Promise ``` 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, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise +'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise): Promise ``` 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, turn: number): ContinuationStop | undefined +'agent/turn-stop'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | 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, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` -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/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ac002b3e4b..001fd1e75c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -254,7 +254,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine async assemble(context: AssembleContext = {}): Promise ``` -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` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 133458f55b..5e98169659 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -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 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d8292c49b3..68421022ea 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -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 diff --git a/docs/core-data-structures/system-prompt.md b/docs/core-data-structures/system-prompt.md index 4b6f1e6625..0ffc243279 100644 --- a/docs/core-data-structures/system-prompt.md +++ b/docs/core-data-structures/system-prompt.md @@ -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 } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d8c924b24d..9ca0c378d9 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a27b19b9a7..ca2f1e4094 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -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) diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index bc355fa328..78a11d4619 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -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 diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index 665063399d..9305941629 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -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 diff --git a/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml new file mode 100644 index 0000000000..c9de5dd06a --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -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 diff --git a/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.md new file mode 100644 index 0000000000..3716895c14 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -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. diff --git a/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md new file mode 100644 index 0000000000..d2f8a4227d --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -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 匹配,陈旧的环境异步后代无法获得控制能力。不协作的进程内工作可能延迟取消,但所报告的静止状态仍然真实。 diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md index 7ed7d10211..57550971d6 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md +++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md @@ -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 diff --git a/examples/acp-agent/tests/snapshots/cancel/session.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.jsonl index 7b2f5adff1..2d1d940b73 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.jsonl @@ -6,4 +6,4 @@ {"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} {"type":"step/end","seq":6,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"aborted","reason":"session/cancel"}}} +{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"aborted"}}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index eb8a25d8b4..514ec43844 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -299,7 +299,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/prompt-submit', mode: 'waterfall', - signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise', + signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise): Promise', summary: 'Allow, rewrite, or block one drained prompt before it becomes a user message.', }, { @@ -311,7 +311,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/request', mode: 'waterfall', - signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise', + signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise): Promise', summary: 'Replace the frozen call configuration.', }, { @@ -335,19 +335,19 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/step-result', mode: 'waterfall', - signature: '\'agent/step-result\'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise', + signature: '\'agent/step-result\'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise): Promise', summary: 'Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).', }, { name: 'agent/turn-continuation', mode: 'waterfall', - signature: '\'agent/turn-continuation\'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise', + signature: '\'agent/turn-continuation\'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise): Promise', summary: 'Override whether the turn continues.', }, { name: 'agent/turn-stop', mode: 'serial', - signature: '\'agent/turn-stop\'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined', + signature: '\'agent/turn-stop\'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | ContinuationStop | undefined', summary: '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.', }, { @@ -512,7 +512,11 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: AgentId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: SendOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n}', + declaration: 'export interface Agent {\n readonly id: AgentId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: SendOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n}', + }, + { + name: 'AgentCancelCause', + declaration: 'export type AgentCancelCause = {\n readonly kind: \'user\';\n} | {\n readonly kind: \'parent\';\n};', }, { name: 'AgentExecution', @@ -572,7 +576,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AssembleContext', - declaration: 'export interface AssembleContext {\n scope?: ScopeKey;\n}', + declaration: 'export interface AssembleContext {\n scope?: ScopeKey;\n signal?: AbortSignal;\n}', }, { name: 'AssembledSection', @@ -1064,7 +1068,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TurnEndReasonMap', - declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason?: string;\n };\n error: {\n kind: \'error\';\n step: number;\n message: string;\n code?: string;\n };\n disposed: {\n kind: \'disposed\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n rejected: {\n kind: \'rejected\';\n reason: string;\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', + declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n };\n error: {\n kind: \'error\';\n step: number;\n message: string;\n code?: string;\n };\n disposed: {\n kind: \'disposed\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n rejected: {\n kind: \'rejected\';\n reason: string;\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}', }, { name: 'TurnTrigger', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index c961969edc..73162d6995 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -48,11 +48,13 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re ### Loop lifecycle (`loop.ts`) -The driver owns one agent for its lifetime and runs inside `ctx.agentExecution.run({ agent }, ...)`, so process-local asynchronous continuations can recover the initiating Agent. Creation, persistence load, and unpublished setup stay outside the child boundary; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. The [execution-context package](../agent-execution/README.md) owns propagation and detached-work rules. +The driver owns one agent for its lifetime and runs inside `ctx.agentExecution.run({ agent }, ...)`, so process-local asynchronous continuations can recover the initiating Agent. The ALS frame contains only `{ agent }`: creation, persistence load, and unpublished setup stay outside the child boundary, while turn, step, signal, and other control state remain explicit at every seam. The [execution-context package](../agent-execution/README.md) owns propagation and detached-work rules. The loop records turn, step, request, stream, and tool boundaries in the session log; live extension events coordinate policy around those durable facts. The [architecture turn flow](../../../docs/architecture.md#turn-flow) and generated [event catalog](../../../docs/cordis-catalog/events.md) are the authoritative sequence and signatures. -Plugin failure ends the current turn, not the loop. Cancellation clears pending work and aborts the current step without leaking to the next prompt. Terminal continuation stops remain authoritative through turn close and durability flush. +Plugin failure ends the current turn, not the loop. The loop creates one private turn cancellation holder before announcing `running`, passes its single signal through prompt handling, prompt assembly, every step, model and tool execution, continuation, terminal stop, turn end, and durability flush, then discards it. A replacement prompt accepted after cancellation receives a fresh holder, while all work in the cancelled turn observes the first typed runtime cause. The durable turn outcome is only `aborted`; disposal is a separate runtime interrupt and wins classification even if cancellation reached the signal first. + +Cancellation is cooperative: the loop checks for interruption between awaited boundaries but does not abandon an in-process listener, adapter, or tool Promise with `Promise.race`. `whenIdle()` and handle disposal therefore observe real quiescence. See the [explicit turn cancellation RFC](../../../docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.md). ### What belongs to plugins diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 7194d6c7e8..b984e4bfa0 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -7,12 +7,13 @@ */ import type { Context } from 'cordis' -import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent' +import { agentEvents, normalizeAgentCancelCause } from '@deepseek-ai/dsh-agent' +import type { AgentCancelCause, AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session' +import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts' import { Inbox, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' @@ -91,7 +92,7 @@ export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): /** * The concrete {@link Agent} implementation owned by the agent-loop plugin. * - * Owns the inbox (queued + steering FIFOs), the per-step AbortController, and + * Owns the inbox (queued + steering FIFOs), one turn cancellation holder, and * the loop driver. Everything observable happens through session events and * the agent/* event taxonomy — plugins never need this class. */ @@ -116,21 +117,18 @@ export class ReactLoopAgent implements Agent { } private _status: AgentStatus = 'idle' - private currentAbort: AbortController | undefined + /** Active turn owner, installed before the running notification and retained through flush. */ + private turnCancellation: TurnCancellation | undefined /** Whether runLoop has been installed into {@link done}. */ private driverStarted = false /** Whether registry publication began and status disposal is externally visible. */ private published = false /** - * Turn-scoped cancel marker, set by {@link cancel} and read/cleared by the - * driver loop (via the LoopHandle) at every point a turn could start or - * continue. Armed ONLY when there is something to cancel (a running turn, an - * in-flight step, or queued/steering work), so an idle no-op cancel cannot - * leave it set to wrongly drop a later prompt. + * Cause-less marker for queued work cancelled before the driver installs a + * turn owner. It never represents an active turn and cannot leak a cause into + * replacement work. */ - private cancelRequested = false - /** Pending cancellation reason, preserved even outside an active step signal. */ - private cancelReason = 'cancelled' + private preRunCancelled = false private disposed: Promise private resolveDisposed!: () => void /** Resolves when the driver loop has fully exited (tests/disposal). */ @@ -272,24 +270,18 @@ export class ReactLoopAgent implements Agent { } } - cancel(reason?: string): void { - // Arm only for current work; an idle marker would cancel the next prompt. - if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) { - this.cancelRequested = true - // Capture the resolved reason for the marker-only windows (pre-step / - // continuation). The mid-step path reads it from abort.signal.reason - // below; the marker path reads it via the LoopHandle's cancelReason(). - this.cancelReason = reason ?? 'cancelled' - } + cancel(cause?: AgentCancelCause): void { + // Validate before the idle no-op so misuse fails consistently in every state. + const accepted = normalizeAgentCancelCause(cause ?? { kind: 'user' }) + const active = this.turnCancellation + if (active === undefined && !this.#inbox.hasQueued && !this.#inbox.hasSteering) return + if (active === undefined) this.preRunCancelled = true + else active.request(accepted) // Drop all pending queued + steering work (un-started prompts never run; the // cancelled turn's steering is not re-enqueued). Cleared directly even when // the loop is parked in waitForQueued — there is no turn to stop and nothing // left for the parked loop to run, so no wake is needed. this.#inbox.clear() - // Interrupt an in-flight step immediately (the running turn observes the - // abort and ends `aborted`). The marker covers the windows where no step is - // running (pre-step, continuation). - this.currentAbort?.abort(reason ?? 'cancelled') } /** @@ -330,13 +322,20 @@ export class ReactLoopAgent implements Agent { this.done = this.loopCtx.agentExecution.run({ agent: this }, () => runLoop(this.loopCtx, this, { inbox: this.#inbox, setStatus: (status) => { this.setStatus(status) }, - setAbort: controller => void (this.currentAbort = controller), + installTurnCancellation: () => { + const cancellation = new TurnCancellation() + this.turnCancellation = cancellation + return cancellation + }, + clearTurnCancellation: (cancellation) => { + /* v8 ignore else -- the internal driver clears only the exact holder returned by its latest install */ + if (this.turnCancellation === cancellation) this.turnCancellation = undefined + }, disposed: this.disposed, isDisposed: () => this._status === 'disposed', - isCancelled: () => this.cancelRequested, - cancelReason: () => this.cancelReason, - clearCancel: () => { this.cancelRequested = false }, - // Pre-step cancellation re-parks without emitting a status transition. + isPreRunCancelled: () => this.preRunCancelled, + clearPreRunCancel: () => { this.preRunCancelled = false }, + // Pre-run cancellation re-parks without emitting a status transition. settleIdle: () => { this.settleIdleWaiters() }, })) } @@ -354,7 +353,7 @@ export class ReactLoopAgent implements Agent { // internal state that must settle even if a listener throws below. Each // waiter chains `done`, so it resolves only once the loop actually exits. this.settleIdleWaiters() - this.currentAbort?.abort('disposed') + this.turnCancellation?.request(DISPOSED_INTERRUPT_REASON) // An unpublished rollback has no public status lifecycle to announce. // Once publication begins, disposed is part of the agent/status contract. if (this.published) { diff --git a/packages/core/agent-loop/src/cancellation.ts b/packages/core/agent-loop/src/cancellation.ts new file mode 100644 index 0000000000..e4054d8262 --- /dev/null +++ b/packages/core/agent-loop/src/cancellation.ts @@ -0,0 +1,31 @@ +/** Turn-scoped cancellation ownership for the concrete AgentLoop driver. @module dsh-agent-loop/cancellation */ + +import type { AgentCancelCause } from '@deepseek-ai/dsh-agent' + +/** Stable runtime-only reason used when lifecycle teardown interrupts a turn. */ +export const DISPOSED_INTERRUPT_REASON = Object.freeze({ kind: 'disposed' } as const) + +/** + * Owns the single controller shared by every asynchronous boundary of one turn. + * The first request wins because a later caller must not rewrite the cause + * observed by earlier listeners. + */ +export class TurnCancellation { + readonly #controller = new AbortController() + + /** The explicit signal passed through this turn's execution boundaries. */ + get signal(): AbortSignal { + return this.#controller.signal + } + + /** + * Abort the turn once. + * @param reason - a validated caller cause or lifecycle disposal marker. + * @returns whether this request established the signal reason. + */ + request(reason: AgentCancelCause | typeof DISPOSED_INTERRUPT_REASON): boolean { + if (this.signal.aborted) return false + this.#controller.abort(reason) + return true + } +} diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index abb588b919..29de25e723 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -29,7 +29,7 @@ export class Inbox { return this.queuedMessages.length > 0 } - /** True while steering messages are pending — read by `cancel()`'s arm gate and the loop's stop-override check. */ + /** True while steering messages are pending — read by cancellation and the loop's stop-override check. */ get hasSteering(): boolean { return this.steeringMessages.length > 0 } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 15cc0aa410..9611a75823 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -6,9 +6,9 @@ */ import type { Context } from 'cordis' -import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm' -import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm' -import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' +import type { FinishReason, GenerateOptions, LlmCallConfig, Message, TokenUsage } from '@deepseek-ai/dsh-llm' +import { assertNever, BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm' +import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' @@ -19,6 +19,7 @@ import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import type { ReactLoopAgent } from './agent.ts' import type { Inbox } from './inbox.ts' +import type { TurnCancellation } from './cancellation.ts' /** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */ type CodedError = Error & { code?: string } @@ -68,21 +69,65 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined { } } +/** Internal control-flow sentinel; durable classification comes only from the turn signal. */ +const TURN_INTERRUPTED = new Error('turn interrupted') + +/** Stop at an explicit cooperative boundary without stringifying the runtime reason. */ +function interruptionCheckpoint(signal: AbortSignal): void { + if (signal.aborted) throw TURN_INTERRUPTED +} + +/** Classify a supported turn interruption, with lifecycle disposal taking precedence. */ +function interruptionTurnEndReason(handle: LoopHandle, signal: AbortSignal): TurnEndReason | undefined { + if (handle.isDisposed()) return { kind: 'disposed' } + const reason = agentInterruptReasonOf(signal) + if (reason === undefined) return undefined + switch (reason.kind) { + case 'user': + case 'parent': + return { kind: 'aborted' } + /* v8 ignore next 2 -- the private holder requests disposed only after lifecycle state flips, which returns above */ + case 'disposed': + return { kind: 'disposed' } + /* v8 ignore next 2 -- AgentInterruptReason is closed and the public helper filters unsupported reasons */ + default: + return assertNever(reason, 'AgentInterruptReason') + } +} + +/** Append the durable assembled assistant message when it carries content or usage. */ +function appendAssistantMessage( + session: Session, + turn: number, + step: number, + message: Message, + usage: TokenUsage | undefined, + chunkSeqs: number[], +): void { + if (message.content.length === 0 && usage === undefined) return + session.append( + 'assistant/message', + { turn, step, content: message.content, ...usage === undefined ? {} : { usage } }, + { surfaceOp: 'append', ...(chunkSeqs.length > 0 ? { sourceEventSeqs: chunkSeqs } : {}) }, + ) +} + /** Mutable agent controls supplied to the loop driver. */ export interface LoopHandle { /** Native-private agent inbox handed to the driver only at internal startup. */ readonly inbox: Inbox setStatus(status: 'idle' | 'running'): void - setAbort(controller: AbortController | undefined): void + /** Install a fresh active-turn owner before the running notification. */ + installTurnCancellation(): TurnCancellation + /** Clear only the exact owner whose turn and durability flush settled. */ + clearTurnCancellation(cancellation: TurnCancellation): void /** Resolves when the agent is disposed — unblocks the idle wait. */ disposed: Promise isDisposed(): boolean - /** Whether cancellation is pending for the current loop iteration. */ - isCancelled(): boolean - /** Resolved pending-cancellation reason; meaningful only while {@link isCancelled} is true. */ - cancelReason(): string - /** Clear the cancel marker (called once per iteration after the turn returns). */ - clearCancel(): void + /** Whether queued work was cancelled before an active turn owner existed. */ + isPreRunCancelled(): boolean + /** Clear the cause-less pre-run marker without affecting replacement work. */ + clearPreRunCancel(): void /** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */ settleIdle(): void } @@ -92,7 +137,7 @@ export interface LoopHandle { * current turn without terminating the driver. * @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through. * @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options). - * @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads. + * @param handle - the bridge to status, turn cancellation ownership, disposal, and pre-run cancellation state. */ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise { // Per-instance prefix and request-header state; conversation history remains in the session log. @@ -108,31 +153,38 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH // Cancellation between wake and `running` skips only the cancelled work; // a replacement prompt still runs and owns the eventual idle transition. - if (handle.isCancelled()) { - handle.clearCancel() + if (handle.isPreRunCancelled()) { + handle.clearPreRunCancel() if (!handle.inbox.hasQueued) { handle.settleIdle() continue } } + let cancellation = handle.installTurnCancellation() handle.setStatus('running') - // A synchronous `running` listener can cancel before `runTurn`; balance the - // status only when no replacement prompt was queued by that listener. - if (handle.isCancelled()) { - handle.clearCancel() + if (handle.isDisposed()) { + handle.clearTurnCancellation(cancellation) + break + } + + // A synchronous running listener may cancel old work and enqueue a + // replacement. The replacement receives a fresh, non-aborted turn owner. + if (cancellation.signal.aborted) { + handle.clearTurnCancellation(cancellation) if (!handle.inbox.hasQueued) { handle.setStatus('idle') continue } + cancellation = handle.installTurnCancellation() } // Idle injection can add a turn, so derive the next number from the log. const turn = lastTurnNumber(session) + 1 let terminalStopped = false try { - terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission) + terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission, cancellation.signal) } catch (error: unknown) { // Pre-turn failure has no durable boundary to close; report it without appending outside a turn. const err = toError(error) @@ -140,11 +192,10 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH try { events.emit('agent/error', turn, 0, err) } catch { /* contained: a throwing agent/error listener must not kill the driver */ } + } finally { + handle.clearTurnCancellation(cancellation) } - // Reset per iteration, including when a prompt arrives during the flush window. - handle.clearCancel() - // Late steering becomes queued input unless terminal policy stopped the turn. for (const message of handle.inbox.drainSteering()) { if (!terminalStopped) handle.inbox.enqueue(message) @@ -156,6 +207,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH async function runTurn( ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog, + signal: AbortSignal, ): Promise { const { session } = agent @@ -202,6 +254,7 @@ async function runTurn( // matter what throws below; the catch + closeTurn guarantee it. A pre-commit // veto leaves no turn/start in the log and therefore owes no turn/end. session.append('turn/start', { turn, trigger }) + interruptionCheckpoint(signal) // Each drained queued message runs the `agent/prompt-submit` waterfall before // it becomes a `user/message` — a hook can rewrite the prompt or block it. // Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed; @@ -215,9 +268,10 @@ async function runTurn( let lastBlockReason = 'prompt blocked by hook' for (const message of queued) { const decision = await events.waterfall( - 'agent/prompt-submit', message.content, message.source, + 'agent/prompt-submit', message.content, message.source, signal, () => Promise.resolve({ kind: 'allow' }), ) + interruptionCheckpoint(signal) if (decision.kind === 'block') { lastBlockReason = decision.reason // Record the veto durably: `PromptDecision.reason` is the durable record @@ -253,53 +307,28 @@ async function runTurn( // the request. drainSteering(agent, handle.inbox, turn) - // The step's AbortController exists BEFORE any async pre-step work so a - // dispose() or cancel() — in a synchronous turn-start listener or an - // async listener whose effect fires before we block — always has an armed - // abort to cancel against. isDisposed below covers disposal, which does - // NOT set the cancel marker. Cleared on every exit path below. - const abort = new AbortController() - handle.setAbort(abort) - // Assemble once before pre-step so pressure checks and the request share the same prompt. - const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent)) + const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent, signal)) + interruptionCheckpoint(signal) const fullSystemPrompt = renderPrompt(assembly) - // Cancellation or disposal during assembly ends the turn before any step opens. - if (handle.isCancelled() || handle.isDisposed()) { - handle.setAbort(undefined) - reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } - break - } - // Compose the request-only prefix once per loop instance before pressure // checks. It precedes all derived history and is recorded only in the // request header, not as session history. if (transmission.sessionPrefix === undefined) { const emptyPrefix: Message[] = deepFreeze([]) const composed = await events.waterfall( - 'agent/session-prefix', emptyPrefix, abort.signal, + 'agent/session-prefix', emptyPrefix, signal, () => Promise.resolve(emptyPrefix), ) - // Never cache an interrupted composition; the next turn recomposes it. - if (handle.isCancelled() || handle.isDisposed()) { - handle.setAbort(undefined) - reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } - break - } + interruptionCheckpoint(signal) transmission.sessionPrefix = deepFreeze(structuredClone(composed)) } // Await surface mutations outside the step; pressure checks receive the pending prefix. - await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal) - - // Interruption landing during the pre-step seam: do not open an empty step. - if (handle.isCancelled() || handle.isDisposed()) { - handle.setAbort(undefined) - reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } - break - } + await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, signal) + interruptionCheckpoint(signal) // Snapshot the exact log prefix before step/start: the reconstruction // boundary. Appends after this synchronous snapshot join the next request. @@ -310,26 +339,15 @@ async function runTurn( // pre-commit veto throws before this assignment; post-commit observers // are contained inside Session.append(). stepOpen = true - - // Cancel landing in the step-start window: a synchronous `session/event` - // step/start listener can cancel after the step is already open. Check - // AFTER the step/start append and before `runStep`: drop the step, end the - // turn accordingly. closeStep balances the already-appended step/start. - if (handle.isCancelled() || handle.isDisposed()) { - handle.setAbort(undefined) - reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } - closeStep() - break - } + // A synchronous step/start observer can cancel after the step opened. + interruptionCheckpoint(signal) let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error } try { stepOutcome = await runStep( - ctx, events, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal) + ctx, events, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal) } catch (error: unknown) { stepOutcome = { error: toError(error) } - } finally { - handle.setAbort(undefined) } if ('error' in stepOutcome) { @@ -338,14 +356,9 @@ async function runTurn( // starts a fresh turn instead of being silently consumed. closeStep() const { error } = stepOutcome - if (handle.isDisposed()) { - reason = { kind: 'disposed' } - } else if (abort.signal.aborted) { - /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ - reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') } - } else { - failTurn(error) - } + const interruption = interruptionTurnEndReason(handle, signal) + if (interruption === undefined) failTurn(error) + else reason = interruption break } @@ -357,17 +370,20 @@ async function runTurn( const steered = drainSteering(agent, handle.inbox, turn) closeStep() + interruptionCheckpoint(signal) const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' } let decision: ContinuationDecision try { decision = await events.waterfall( - 'agent/turn-continuation', turn, defaultDecision, + 'agent/turn-continuation', turn, defaultDecision, signal, () => Promise.resolve(defaultDecision), ) + interruptionCheckpoint(signal) } catch (error: unknown) { - // A broken continuation plugin ends the turn, not the loop. - failTurn(toError(error)) + const interruption = interruptionTurnEndReason(handle, signal) + if (interruption === undefined) failTurn(toError(error)) + else reason = interruption break } @@ -383,12 +399,13 @@ async function runTurn( // Terminal policy is monotonic and runs after ordinary continuation folding. let terminalStop = false try { - const stop = await events.serial('agent/turn-stop', turn) + const stop = await events.serial('agent/turn-stop', turn, signal) + interruptionCheckpoint(signal) terminalStop = stop !== undefined } catch (error: unknown) { - // A broken terminal policy is an ordinary continuation failure: fail - // this turn closed while leaving the driver alive for later turns. - failTurn(toError(error)) + const interruption = interruptionTurnEndReason(handle, signal) + if (interruption === undefined) failTurn(toError(error)) + else reason = interruption break } if (terminalStop) { @@ -398,12 +415,6 @@ async function runTurn( shouldContinue = false } - // The marker catches cancellation after the step controller was cleared. - if (handle.isCancelled()) { - reason = { kind: 'aborted', reason: handle.cancelReason() } - break - } - if (!shouldContinue || handle.isDisposed()) { /* v8 ignore next -- disposal during continuation-decision window is a narrow race; error-path disposal is covered elsewhere */ if (handle.isDisposed()) reason = { kind: 'disposed' } @@ -418,12 +429,9 @@ async function runTurn( const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) if (!turnStartLogged) throw error closeStep() - // Preserve an established disposal reason; otherwise report the failure. - if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition - reason = { kind: 'disposed' } - } else { - failTurn(toError(error)) - } + const interruption = interruptionTurnEndReason(handle, signal) + if (interruption === undefined) failTurn(toError(error)) + else reason = interruption closeTurn() } @@ -480,7 +488,8 @@ async function runStep( : { model: options.model ?? '' })) // Listener replacements are recorded in the request header before dispatch. - const config = await events.waterfall('agent/request', turn, step, seedConfig, () => Promise.resolve(seedConfig)) + const config = await events.waterfall('agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig)) + interruptionCheckpoint(signal) if (!config.model) { throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`) } @@ -514,12 +523,12 @@ async function runStep( const assembler = new BlockAssembler() const chunkSeqs: number[] = [] for await (const chunk of ctx.llm.stream(request)) { - /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) + interruptionCheckpoint(signal) const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) chunkSeqs.push(chunkEvent.seq) assembler.push(chunk) } + interruptionCheckpoint(signal) // Normalize failure finish chunks into the same path as thrown stream errors. const stepError = finishError(assembler.finish) @@ -527,39 +536,26 @@ async function runStep( if (assembler.finish.kind === 'max-tokens') { let message: Message = withoutToolCalls(assembler.message()) - message = withoutToolCalls(await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message))) + message = withoutToolCalls(await events.waterfall('agent/step-result', turn, step, message, signal, () => Promise.resolve(message))) + interruptionCheckpoint(signal) // Preserve usage even when max-token truncation produced no content. - if (message.content.length > 0 || assembler.usage) { - // The finish chunk guarantees non-empty provenance here. - session.append( - 'assistant/message', - { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }, - { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, - ) - } + appendAssistantMessage(session, turn, step, message, assembler.usage, chunkSeqs) return { hadToolCalls: false, finish: assembler.finish } } // Record the post-waterfall message that tool dispatch uses. let message: Message = assembler.message() - message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message)) + message = await events.waterfall('agent/step-result', turn, step, message, signal, () => Promise.resolve(message)) + interruptionCheckpoint(signal) - // Empty messages exist only to carry usage; omit empty provenance. - if (message.content.length > 0 || assembler.usage) { - session.append( - 'assistant/message', - { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }, - { surfaceOp: 'append', ...(chunkSeqs.length > 0 ? { sourceEventSeqs: chunkSeqs } : {}) }, - ) - } + appendAssistantMessage(session, turn, step, message, assembler.usage, chunkSeqs) // Tool execution stays sequential; recheck abort around each normalized result. const toolCalls = message.content.filter(block => block.type === 'tool-call') // Buffer context until all results are appended to preserve call/result adjacency. const pendingContext: HookContext[] = [] for (const call of toolCalls) { - /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) + interruptionCheckpoint(signal) const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments }) let parsedArguments: unknown try { @@ -588,11 +584,7 @@ async function runStep( ...result.meta !== undefined ? { meta: result.meta } : {}, }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) if (result.additionalContext) pendingContext.push(result.additionalContext) - // The signal may flip while the tool is awaited. - /* 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 */ + interruptionCheckpoint(signal) } // Append buffered context after the complete result batch. diff --git a/packages/core/agent-loop/tests/agent-execution.spec.ts b/packages/core/agent-loop/tests/agent-execution.spec.ts index 98cebcbc97..c33fec7432 100644 --- a/packages/core/agent-loop/tests/agent-execution.spec.ts +++ b/packages/core/agent-loop/tests/agent-execution.spec.ts @@ -146,6 +146,82 @@ describe('AgentLoop execution context', () => { await ctx.fiber.dispose() }) + it('keeps ALS identity minimal while one explicit signal spans each turn seam', async () => { + const adapter = new MockAdapter([ + toolCallResponse('observe-call', 'observe', {}), + textResponse('first done'), + textResponse('second done'), + ]) + const { ctx } = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('signal-owner'), { model: 'mock' }) + let signals: AbortSignal[] = [] + const capture = (signal: AbortSignal | undefined): void => { + if (signal === undefined) throw new Error('turn seam omitted its explicit signal') + const execution = ctx.agentExecution.require() + expect(Object.keys(execution)).toEqual(['agent']) + expect(execution.agent).toBe(agent) + signals.push(signal) + } + + ctx.on('system-prompt/assemble', async (_assembly, context, next) => { + if (context.agent === agent) capture(context.signal) + return next() + }) + ctx.on('agent/prompt-submit', async (subject, _content, _source, signal, next) => { + if (subject === agent) capture(signal) + return next() + }) + ctx.on('agent/session-prefix', async (subject, _prefix, signal, next) => { + if (subject === agent) capture(signal) + return next() + }) + ctx.on('agent/pre-step', (subject, _turn, _step, _system, _prefix, signal) => { + if (subject === agent) capture(signal) + }) + ctx.on('agent/request', async (subject, _turn, _step, _config, signal, next) => { + if (subject === agent) capture(signal) + return next() + }) + ctx.on('agent/step-result', async (subject, _turn, _step, _message, signal, next) => { + if (subject === agent) capture(signal) + return next() + }) + ctx.on('agent/turn-continuation', async (subject, _turn, _decision, signal, next) => { + if (subject === agent) capture(signal) + return next() + }) + ctx.on('agent/turn-stop', (subject, _turn, signal) => { + if (subject === agent) capture(signal) + }) + ctx.tools.register(defineTool({ + name: 'observe', + description: 'observe explicit turn state', + parameters: {}, + execute: async (_args, exec) => { + capture(exec.signal) + return [{ type: 'text', text: 'observed' }] + }, + })) + + const firstIdle = waitForIdle(ctx, agent) + send(agent, 'first') + await firstIdle + const firstSignal = signals[0] + expect(firstSignal).toBeDefined() + expect(new Set([...signals, ...adapter.requests.slice(0, 2).map(request => request.signal!)])).toEqual(new Set([firstSignal])) + + signals = [] + const secondIdle = waitForIdle(ctx, agent) + send(agent, 'second') + await secondIdle + const secondSignal = signals[0] + expect(secondSignal).toBeDefined() + expect(new Set([...signals, adapter.requests[2]!.signal!])).toEqual(new Set([secondSignal])) + expect(secondSignal).not.toBe(firstSignal) + expect(ctx.agentExecution.current()).toBeUndefined() + await ctx.fiber.dispose() + }) + it('keeps child setup under the parent boundary, switches for the child driver, then restores the parent', async () => { const adapter = new MockAdapter([ toolCallResponse('spawn', 'spawn-child', {}), diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index bcc31a6172..defb913d44 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -329,7 +329,7 @@ describe('ReactLoopAgent', () => { expect(settled).toBe(false) await waitForStatus(ctx, agent, 'running') - agent.cancel('done') + agent.cancel({ kind: 'user' }) await idle expect(settled).toBe(true) expect(agent.status).toBe('idle') diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 7568ba0765..0dbac6fce6 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -1,9 +1,8 @@ /** * Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the broad verb — it - * clears queued + steering work, aborts an in-flight step, and drops a turn about to start — - * whereas a bare step abort (the loop's private `AbortController`) kills only the current step - * and leaves the queue intact. The suite covers every landing window plus marker - * reset and `whenIdle()` quiescence. + * clears queued + steering work, aborts the active turn, and drops work not yet claimed by the + * driver without leaking cancellation into a replacement prompt. The suite covers every landing + * window plus marker reset and `whenIdle()` quiescence. * @module dsh-agent-loop/tests/cancel */ @@ -12,11 +11,11 @@ import { Context } from 'cordis' import LlmService, { type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, textResponse } from './mock-adapter.ts' +import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter) { const ctx = new Context() @@ -60,7 +59,7 @@ describe('Agent.cancel()', () => { // The loop is parked at the idle wait with nothing queued. A cancel here must // NOT arm the marker — otherwise the next legitimate prompt would be dropped. - agent.cancel('nothing to cancel') + agent.cancel({ kind: 'user' }) send(agent, 'real prompt') await waitForIdle(ctx, agent) @@ -78,7 +77,7 @@ describe('Agent.cancel()', () => { // send() queues synchronously (status still idle, loop microtask not yet // resumed). Cancel in that pre-step window: the queued turn must not run. send(agent, 'drop me') - agent.cancel('pre-step') + agent.cancel({ kind: 'user' }) // Give the loop a chance to wake and process the cancel. await new Promise(r => setTimeout(r, 30)) @@ -98,7 +97,7 @@ describe('Agent.cancel()', () => { // drops the turn before it runs; the skip path must settle it directly. send(agent, 'q') const idle = agent.whenIdle() - agent.cancel('pre-step') + agent.cancel({ kind: 'user' }) // Must resolve (not hang). A timeout makes the failure a clear test failure. await Promise.race([ @@ -119,13 +118,13 @@ describe('Agent.cancel()', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - agent.cancel('mid-step') + agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'aborted', reason: 'mid-step' }]) + expect(reasons).toEqual([{ kind: 'aborted' }]) }) - it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => { + it('cancel() with no cause defaults to user when aborting an active turn', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -135,10 +134,10 @@ describe('Agent.cancel()', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) - agent.cancel() // no reason → default 'cancelled' + agent.cancel() await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled' }]) + expect(reasons).toEqual([{ kind: 'aborted' }]) }) it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => { @@ -149,7 +148,7 @@ describe('Agent.cancel()', () => { // First turn hangs; cancel it mid-step. send(agent, 'first') await new Promise(r => setTimeout(r, 30)) - agent.cancel('cancel first') + agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) // The marker must have been reset after the cancelled turn — a fresh prompt @@ -174,7 +173,7 @@ describe('Agent.cancel()', () => { let streamed = false ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => { - agent.cancel('from prefix composition') + agent.cancel({ kind: 'user' }) return next() }) @@ -185,7 +184,7 @@ describe('Agent.cancel()', () => { await waitForIdle(ctx, agent) expect(streamed).toBe(false) - expect(reasons).toEqual([{ kind: 'aborted', reason: 'from prefix composition' }]) + expect(reasons).toEqual([{ kind: 'aborted' }]) }) it('disposal from inside the agent/session-prefix waterfall ends the turn disposed (prefix-composition window)', async () => { @@ -239,7 +238,7 @@ describe('Agent.cancel()', () => { ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => { compositions += 1 if (compositions === 1) { - agent.cancel('mid-composition') + agent.cancel({ kind: 'user' }) return next() } return [opener, ...await next()] @@ -262,12 +261,11 @@ describe('Agent.cancel()', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // A turn/start listener fires before a step controller exists, so the - // turn-scoped marker—not step abort—must drop the pending step. + // The turn holder is already installed when turn/start is appended. let streamed = false ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) const dispose = ctx.on('session/event', (session, event) => { - if (session === agent.session && event.type === 'turn/start') agent.cancel('from turn-start') + if (session === agent.session && event.type === 'turn/start') agent.cancel({ kind: 'user' }) }) const reasons: TurnEndReason[] = [] @@ -277,11 +275,9 @@ describe('Agent.cancel()', () => { await waitForIdle(ctx, agent) dispose() - // No step streamed (the model never ran), and the turn ended aborted with - // the CALLER's reason — the marker carries `cancel(reason)` through even - // though no AbortController observed it in this window. + // The turn closes as aborted after its single cancellation holder fires. expect(streamed).toBe(false) - expect(reasons).toEqual([{ kind: 'aborted', reason: 'from turn-start' }]) + expect(reasons).toEqual([{ kind: 'aborted' }]) }) it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => { @@ -296,7 +292,7 @@ describe('Agent.cancel()', () => { let streamed = false ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) const dispose = ctx.on('session/event', (session, event) => { - if (session === agent.session && event.type === 'step/start') agent.cancel('from step-start') + if (session === agent.session && event.type === 'step/start') agent.cancel({ kind: 'user' }) }) const reasons: TurnEndReason[] = [] @@ -309,7 +305,7 @@ describe('Agent.cancel()', () => { // No step streamed, the turn ended aborted with the caller's reason, and the // log is balanced (the open step was closed by the cancel branch). expect(streamed).toBe(false) - expect(reasons).toEqual([{ kind: 'aborted', reason: 'from step-start' }]) + expect(reasons).toEqual([{ kind: 'aborted' }]) const types = agent.session.events.map(e => e.type) expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) }) @@ -353,10 +349,8 @@ describe('Agent.cancel()', () => { }) it('cancel during the continuation window ends the turn aborted and runs no further step', async () => { - // A continuation-waterfall listener cancels DURING the continuation decision - // (the finished step's AbortController is already cleared), and votes to - // continue — but the turn-scoped marker checked right after must end the turn - // `aborted` and run NO second step. + // A continuation-waterfall listener cancels during the continuation decision + // and votes to continue, but the turn signal remains authoritative. const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -369,11 +363,11 @@ describe('Agent.cancel()', () => { }) let continued = false - ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => { + ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => { if (subject === agent && !continued) { continued = true - agent.cancel('from continuation') - return { action: 'continue' as const } // vote to continue — the post-waterfall marker check must override + agent.cancel({ kind: 'user' }) + return { action: 'continue' as const } } return next() }) @@ -381,11 +375,9 @@ describe('Agent.cancel()', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // Only ONE step ran (the second was cancelled in the continuation window), - // and the turn ended aborted with the CALLER's reason (carried by the - // marker, since the finished step's AbortController was already cleared). + // Only one step ran and the turn ended with the coarse aborted outcome. expect(steps).toBe(1) - expect(reasons).toEqual([{ kind: 'aborted', reason: 'from continuation' }]) + expect(reasons).toEqual([{ kind: 'aborted' }]) }) it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => { @@ -398,7 +390,7 @@ describe('Agent.cancel()', () => { let streamed = false ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) const dispose = ctx.on('agent/status', (subject, status) => { - if (subject === agent && status === 'running') agent.cancel('from running listener') + if (subject === agent && status === 'running') agent.cancel({ kind: 'user' }) }) send(agent, 'go') @@ -411,6 +403,33 @@ describe('Agent.cancel()', () => { expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) }) + it('disposal from a synchronous running listener stops before opening a turn', async () => { + const adapter = new MockAdapter([textResponse('should not stream')]) + const ctx = await harness(adapter) + const handle = await ctx.agents.create({ + agentId: AgentId('dispose-running-listener'), + sessionId: SessionId('dispose-running-listener-session'), + agentOptions: { model: 'mock' }, + }) + const { agent } = handle + let disposalDone: Promise | undefined + const disposalStarted = Promise.withResolvers() + ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'running') { + disposalDone = handle.dispose() + disposalStarted.resolve(undefined) + } + }) + + agent.send([{ type: 'text', text: 'go' }]) + await disposalStarted.promise + await disposalDone + + expect(agent.status).toBe('disposed') + expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(adapter.requests).toHaveLength(0) + }) + it('window 2: whenIdle() does NOT resolve early when a running listener cancels then queues replacement work', async () => { // Cancellation must not settle idle while replacement work remains queued. const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) @@ -421,7 +440,7 @@ describe('Agent.cancel()', () => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject !== agent || status !== 'running' || replaced) return replaced = true - agent.cancel('drop A') + agent.cancel({ kind: 'user' }) send(agent, 'B') }) @@ -446,7 +465,7 @@ describe('Agent.cancel()', () => { send(agent, 'A') // queues A (status still idle, loop microtask pending) const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path) - agent.cancel('drop A') // arms marker, clears A + agent.cancel({ kind: 'user' }) // arms marker, clears A send(agent, 'B') // B races in before the loop resumes // whenIdle() must resolve only after B's turn fully ran — by which point B's user message @@ -469,7 +488,7 @@ describe('Agent.cancel()', () => { // Steer (joins the running turn's steering FIFO), then cancel: the steering // must be dropped, NOT re-enqueued as a new queued turn. agent.steer([{ type: 'text', text: 'steer text' }]) - agent.cancel('cancel with steering') + agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) // After the cancelled turn settles, the agent is idle with NO follow-up turn @@ -485,4 +504,169 @@ describe('Agent.cancel()', () => { .flatMap(b => b.type === 'text' ? [b.text] : []) expect(flat).not.toContain('steer text') }) + + it('keeps the first typed cause for an active turn and detaches the runtime reason', async () => { + const adapter = new MockAdapter(['hang']) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('typed-first-wins'), { model: 'mock' }) + const supplied: { kind: 'parent' | 'user' } = { kind: 'parent' } + + send(agent, 'go') + await new Promise(resolve => setTimeout(resolve, 30)) + agent.cancel(supplied) + supplied.kind = 'user' + agent.cancel({ kind: 'user' }) + await waitForIdle(ctx, agent) + + const runtimeReason: unknown = adapter.requests[0]?.signal?.reason + expect(runtimeReason).toEqual({ kind: 'parent' }) + expect(runtimeReason).not.toBe(supplied) + expect(Object.isFrozen(runtimeReason)).toBe(true) + const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) + }) + + it('rejects invalid causes synchronously while idle and running', async () => { + class Cause { + readonly kind = 'user' + } + const adapter = new MockAdapter(['hang']) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('invalid-cause'), { model: 'mock' }) + const controller = new AbortController() + const invalid: unknown[] = [ + 'user', + { kind: 'timeout' }, + { kind: 'user', detail: 'extra' }, + new Error('cancelled'), + controller.signal, + new Cause(), + ] + for (const value of invalid) expect(() => { agent.cancel(value as never) }).toThrow(TypeError) + + send(agent, 'go') + await new Promise(resolve => setTimeout(resolve, 30)) + for (const value of invalid) expect(() => { agent.cancel(value as never) }).toThrow(TypeError) + expect(agent.status).toBe('running') + agent.cancel() + await waitForIdle(ctx, agent) + }) + + it('records disposed when lifecycle teardown races an already-requested cancel', async () => { + const adapter = new MockAdapter(['hang']) + const ctx = await harness(adapter) + const handle = await ctx.agents.create({ + agentId: AgentId('cancel-dispose-race'), + sessionId: SessionId('cancel-dispose-race-session'), + agentOptions: { model: 'mock' }, + }) + const agent = handle.agent + + agent.send([{ type: 'text', text: 'go' }]) + await new Promise(resolve => setTimeout(resolve, 30)) + agent.cancel({ kind: 'user' }) + await handle.dispose() + + const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) + }) + + it.each([ + 'prompt-submit', + 'system-prompt', + 'session-prefix', + 'pre-step', + 'request', + 'step-result', + 'turn-continuation', + 'turn-stop', + 'tool', + ] as const)('lets a cooperative %s boundary settle from the explicit turn signal', async (stage) => { + const adapter = new MockAdapter(stage === 'tool' + ? [toolCallResponse('blocked-tool', 'blocked', {})] + : [textResponse('done')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId(`cooperative-${stage}`), { model: 'mock' }) + const started = Promise.withResolvers() + const blockUntilAbort = async (signal: AbortSignal): Promise => { + started.resolve(undefined) + if (signal.aborted) return + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + + switch (stage) { + case 'prompt-submit': + ctx.on('agent/prompt-submit', async (subject, _content, _source, signal, next) => { + if (subject === agent) await blockUntilAbort(signal) + return next() + }) + break + case 'system-prompt': + ctx.on('system-prompt/assemble', async (_assembly, context, next) => { + if (context.agent === agent) { + if (context.signal === undefined) throw new Error('turn assembly omitted its signal') + await blockUntilAbort(context.signal) + } + return next() + }) + break + case 'session-prefix': + ctx.on('agent/session-prefix', async (subject, _prefix, signal, next) => { + if (subject === agent) await blockUntilAbort(signal) + return next() + }) + break + case 'pre-step': + ctx.on('agent/pre-step', async (subject, _turn, _step, _system, _prefix, signal) => { + if (subject === agent) await blockUntilAbort(signal) + }) + break + case 'request': + ctx.on('agent/request', async (subject, _turn, _step, _config, signal, next) => { + if (subject === agent) await blockUntilAbort(signal) + return next() + }) + break + case 'step-result': + ctx.on('agent/step-result', async (subject, _turn, _step, _message, signal, next) => { + if (subject === agent) await blockUntilAbort(signal) + return next() + }) + break + case 'turn-continuation': + ctx.on('agent/turn-continuation', async (subject, _turn, _decision, signal, next) => { + if (subject === agent) await blockUntilAbort(signal) + return next() + }) + break + case 'turn-stop': + ctx.on('agent/turn-stop', async (subject, _turn, signal) => { + if (subject === agent) await blockUntilAbort(signal) + }) + break + case 'tool': + ctx.tools.register(defineTool({ + name: 'blocked', + description: 'wait for cancellation', + parameters: {}, + execute: async (_args, exec) => { + if (exec.signal === undefined) throw new Error('tool execution omitted its signal') + await blockUntilAbort(exec.signal) + return [{ type: 'text', text: 'cancelled' }] + }, + })) + break + } + + send(agent, 'go') + await started.promise + const idle = waitForIdle(ctx, agent) + agent.cancel({ kind: 'user' }) + await idle + const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) + await ctx.fiber.dispose() + }) }) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 70528a7b0b..bd959e870d 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -59,7 +59,7 @@ describe('session log records what agent/step-result actually produced', () => { // Plugin rewrites the message: replaces the text AND adds a tool call. let rewritten = false - ctx.on('agent/step-result', async (_agent, _turn, _step, _message, next) => { + ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _signal, next) => { if (rewritten) return next() rewritten = true return { @@ -92,7 +92,7 @@ describe('session log records what agent/step-result actually produced', () => { }) describe('abort during tool execution ends the turn', () => { - it('aborting the in-flight step inside a tool prevents both remaining tools and the next model step', async () => { + it('cancelling the active turn 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,11 +113,7 @@ describe('abort during tool execution ends the turn', () => { parameters: {}, async execute() { executed.push('aborter') - // 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') + agent.cancel({ kind: 'user' }) return [{ type: 'text', text: 'done' }] }, })) @@ -139,7 +135,7 @@ describe('abort during tool execution ends the turn', () => { expect(executed).toEqual(['aborter']) // second tool never ran expect(adapter.requests).toHaveLength(1) // no follow-up model call - expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }]) + expect(reasons).toEqual([{ kind: 'aborted' }]) }) }) @@ -153,7 +149,7 @@ describe('steering from late extension points is never stranded', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let steeredOnce = false - ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => { + ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, _signal, next) => { if (!steeredOnce) { steeredOnce = true agent.steer([{ type: 'text', text: 'one more thing' }]) @@ -227,25 +223,19 @@ describe('steering from late extension points is never stranded', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('too late for this turn') }) - it('steering queued during an aborted step is re-delivered, not silently consumed', async () => { - const adapter = new MockAdapter(['hang', textResponse('recovered')]) + it('steering queued before turn cancellation is discarded with the cancelled work', async () => { + const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) agent.steer([{ type: 'text', text: 'redirect' }]) - // 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') + agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) - // a new turn ran with the steering content delivered as a message - expect(adapter.requests).toHaveLength(2) - expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('redirect') + expect(adapter.requests).toHaveLength(1) + expect(JSON.stringify(agent.session.events)).not.toContain('redirect') }) }) @@ -383,7 +373,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model — router plugin decides - ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { + ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => { return { ...config, model: 'mock' } }) @@ -843,7 +833,7 @@ describe('turn and step boundary recovery', () => { it('disposal during a running turn ends the turn with reason disposed (balanced)', async () => { // The 'hang' adapter blocks in stream() until the signal aborts; disposing - // the agent's fiber mid-turn aborts the in-flight step. The turn must close + // the agent's fiber mid-turn aborts the active turn. The turn must close // balanced with reason disposed (no error event for a disposal). const adapter = new MockAdapter(['hang']) const ctx = await balancedHarness(adapter) @@ -1085,7 +1075,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream await ctx.plugin(Invariants) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({ + ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _signal, _next) => ({ role: 'assistant' as const, content: [{ type: 'text' as const, text: 'injected' }], })) @@ -1190,7 +1180,7 @@ describe('disposal and cancellation during pre-step assembly', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 50)) - agent.cancel('user cancelled during assembly') + agent.cancel({ kind: 'user' }) releaseAssemble() await waitForIdle(ctx, agent) @@ -1202,15 +1192,12 @@ describe('disposal and cancellation during pre-step assembly', () => { expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) const turnEnd = e.findLast(x => x.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ - kind: 'aborted', - reason: 'user cancelled during assembly', - }) + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) expect(e.some(x => x.type === 'assistant/message')).toBe(false) expect(adapter.requests).toHaveLength(0) - expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled during assembly' }]) + expect(reasons).toEqual([{ kind: 'aborted' }]) }) it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => { @@ -1297,7 +1284,7 @@ describe('disposal and cancellation during pre-step assembly', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) - agent.cancel('user cancelled') + agent.cancel({ kind: 'user' }) releasePreStep() await waitForIdle(ctx, agent) @@ -1308,10 +1295,10 @@ describe('disposal and cancellation during pre-step assembly', () => { expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) const turnEnd = e.findLast(x => x.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: 'user cancelled' }) + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) - expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled' }]) + expect(reasons).toEqual([{ kind: 'aborted' }]) }) it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => { diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 8c0884d0fa..a118d3a24e 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -157,7 +157,7 @@ describe('toError normalization', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false - ctx.on('agent/request', async (_agent, _turn, _step, _options, _next) => { + ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, _next) => { if (!threwOnce) { threwOnce = true throw { code: 500 } // non-Error throw, goes through runStep catch @@ -185,7 +185,7 @@ describe('coded error data emission', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let threwOnce = false - ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => { + ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, next) => { if (!threwOnce) { threwOnce = true throw new LlmError('server overloaded', 'RATE_LIMIT') diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 0c8f52c6c4..9aa3e7dcbf 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -62,7 +62,7 @@ describe('agent/prompt-submit', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) const seen: string[] = [] - ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => { + ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next) => { seen.push(content.map(b => (b.type === 'text' ? b.text : '')).join('')) return next() }) @@ -191,7 +191,7 @@ describe('agent/prompt-submit', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise => { + ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next): Promise => { const text = content.map(b => (b.type === 'text' ? b.text : '')).join('') return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next() }) @@ -498,7 +498,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let forced = false - ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise => { + ctx.on('agent/turn-continuation', async (_agent, _turn, _default, _signal, next): Promise => { if (!forced) { forced = true return { action: 'continue', reason: { content: [{ type: 'text', text: 'keep going on the goal' }], source: { kind: 'plugin', plugin: 'goal' } } } @@ -626,7 +626,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se ) }) // 2. PromptSubmit: block a forbidden prompt, annotate the rest. - ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise => { + ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next): Promise => { const text = content.map(b => (b.type === 'text' ? b.text : '')).join('') if (text.includes('rm -rf')) return { kind: 'block', reason: 'destructive prompt blocked' } return next() diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 79f9755220..c3695f2611 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -228,7 +228,7 @@ describe('agent loop', () => { assembly.variables['model'] = 'mock' return next() }) - ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { + ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => { return { ...config, model: 'mock' } }) const agent = ctx.agentLoop.create(AgentId('a-late-model'), {}) @@ -429,7 +429,7 @@ describe('agent loop', () => { let steps = 0 ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) - ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => { + ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, _signal, next) => { if (steps < 3) return { action: 'continue' as const } return next() }) @@ -469,7 +469,7 @@ describe('agent loop', () => { ctx.llm.registerAdapter(['other-model'], adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { + ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => { // The seed is frozen — config is not a mutable per-call knob; a switch // is proposed by returning a replacement, and the loop logs it. expect(Object.isFrozen(config)).toBe(true) @@ -601,10 +601,10 @@ describe('agent loop', () => { // wait until the stream is hanging, then cancel await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - agent.cancel('user interrupt') + agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) - expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }]) + expect(reasons).toEqual([{ kind: 'aborted' }]) }) it('surfaces max-tokens as the turn-end reason when the last step is cut off', async () => { @@ -641,7 +641,7 @@ describe('agent loop', () => { ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) // Force exactly one continuation (step 1 → step 2), then defer to default // (step 2 is a plain stop with no tool calls → stops). - ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => { + ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, _signal, next) => { if (steps < 2) return { action: 'continue' as const } return next() }) @@ -781,7 +781,7 @@ describe('agent loop', () => { ]]) const ctx = await harness(adapter) let stepResults = 0 - ctx.on('agent/step-result', async (_agent, _turn, _step, message, next) => { + ctx.on('agent/step-result', async (_agent, _turn, _step, message, _signal, next) => { stepResults += 1 expect(message.content).toEqual([{ type: 'text', text: 'partial text' }]) return next() diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 58c1017e81..ca56216c97 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -168,7 +168,7 @@ describe('request stability across the loop', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) let injected = false - ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => { + ctx.on('agent/request', async (_agent, _turn, _step, _config, _signal, next) => { if (!injected) { injected = true agent.inject([{ type: 'text', text: '[late context]' }], { source: { kind: 'plugin', plugin: 'test' } }) @@ -245,7 +245,7 @@ describe('request stability across the loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => { + ctx.on('agent/request', async (_agent, _turn, _step, _config, _signal, next) => { const config = await next() // next() resolves the SAME frozen seed — in-place shaping after // delegation is unrepresentable, so a "mutate what next() returned" @@ -282,7 +282,7 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'now with guidance' }) - ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => ({ ...config, temperature: 0.5, maxTokens: 99, stop: [''] })) + ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => ({ ...config, temperature: 0.5, maxTokens: 99, stop: [''] })) send(agent, 'again') await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 4b90453909..5530e909b8 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -198,7 +198,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { order.push('agent/created') }) ctx.on('agent/session-start', (agent) => { - expect(() => { agent.cancel('now live') }).not.toThrow() + expect(() => { agent.cancel({ kind: 'user' }) }).not.toThrow() order.push('agent/session-start') }) diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index 944c6fd265..6b1117ebe4 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -51,7 +51,7 @@ describe('agent/turn-stop', () => { agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) let steered = false - ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => { + ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => { const downstream = await next() if (subject === agent && !steered) { steered = true diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 5639f30daf..bdbd6c6a11 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -33,6 +33,8 @@ The loop plugin registers `AgentFactory`, keeping consumers independent of its c Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). +Every asynchronous turn seam receives the same explicit `AbortSignal` for that turn. Listeners may cooperate with cancellation but must not retain the signal to control another turn; ambient `ctx.agentExecution` identity carries no liveness or cancellation authority. The signal and typed cancellation contract are defined by the [explicit turn cancellation RFC](../../../docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.md). + Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md). ### Agent interface (`types.ts`) @@ -42,7 +44,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) -- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op. +- `agent.cancel(cause?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the active turn, and drops queued work not yet claimed by the driver. `AgentCancelCause` is the runtime-only `{ kind: 'user' } | { kind: 'parent' }`; omission means `user`, the first cause wins for an active turn, and ACP `session/cancel` maps to `user`. `normalizeAgentCancelCause()` provides the same strict detached-value boundary used by the concrete loop: validation is synchronous even while idle, accepts only an exact plain object, and returns a safe no-op when no work exists. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index 9d024d36be..8ce018c16b 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -115,8 +115,9 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { * Build the prompt assembly context with agent and scope set together, so * agent-scoped prompt and tool contributions cannot be silently omitted. * @param agent - the agent the assembly is for. + * @param signal - the current turn's explicit control signal, when assembly belongs to a turn. * @returns the context to pass to `assemble()`. */ -export function assembleContextFor(agent: Agent): AssembleContext { - return { agent, scope: agent } +export function assembleContextFor(agent: Agent, signal?: AbortSignal): AssembleContext { + return { agent, scope: agent, ...signal === undefined ? {} : { signal } } } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 3aad65a70e..f0c352a5b3 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -10,6 +10,7 @@ import type { Context } from 'cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-system-prompt' +import type { Session } from '@deepseek-ai/dsh-session' /** Identifies one live agent in the registry. */ export type AgentId = Branded<'AgentId'> @@ -22,8 +23,6 @@ export type AgentId = Branded<'AgentId'> export function AgentId(id: string): AgentId { return id as AgentId } -import type { Session } from '@deepseek-ai/dsh-session' - declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { /** Agent for this assembly; absent on diagnostics. When present, `scope` must identify the same agent. */ @@ -80,6 +79,72 @@ export type ContinuationStop = Extract /** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' +/** Stable runtime cause accepted by {@link Agent.cancel}. */ +export type AgentCancelCause = + | { readonly kind: 'user' } + | { readonly kind: 'parent' } + +/** + * Validate and detach a caller-supplied Agent cancellation cause. + * @param value - the candidate cancellation cause. + * @returns a fresh frozen cause suitable for the current turn signal. + * @throws {TypeError} when the value is not an exact supported cause. + */ +export function normalizeAgentCancelCause(value: unknown): AgentCancelCause { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('agent cancel cause must be an exact plain object with kind "user" or "parent"') + } + const prototype = Object.getPrototypeOf(value) as unknown + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError('agent cancel cause must be an exact plain object with kind "user" or "parent"') + } + const keys = Reflect.ownKeys(value) + if (keys.length !== 1 || keys[0] !== 'kind') { + throw new TypeError('agent cancel cause must contain exactly one field: kind') + } + const kind = (value as { readonly kind?: unknown }).kind + switch (kind) { + case 'user': + return Object.freeze({ kind: 'user' }) + case 'parent': + return Object.freeze({ kind: 'parent' }) + default: + throw new TypeError(`unsupported agent cancel cause kind: ${String(kind)}`) + } +} + +/** Runtime reason carried by the signal that controls one live turn. */ +export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' } + +/** + * Read a supported agent interruption from an explicitly supplied signal. + * Unknown reasons return `undefined`; this helper never consults ambient agent + * execution identity, which does not grant cancellation authority. + * + * @param signal - the current turn's explicit control signal. + * @returns its canonical supported reason, or `undefined` while live or when an + * unrelated controller supplied an unsupported reason. + */ +export function agentInterruptReasonOf(signal: AbortSignal): AgentInterruptReason | undefined { + if (!signal.aborted) return undefined + const reason: unknown = signal.reason + if (typeof reason === 'object' && reason !== null && !Array.isArray(reason)) { + const prototype = Object.getPrototypeOf(reason) as unknown + const keys = Reflect.ownKeys(reason) + if ((prototype === Object.prototype || prototype === null) + && keys.length === 1 && keys[0] === 'kind' + && (reason as { readonly kind?: unknown }).kind === 'disposed') { + return Object.freeze({ kind: 'disposed' }) + } + } + try { + return normalizeAgentCancelCause(reason) + } catch (error: unknown) { + if (error instanceof TypeError) return undefined + throw error + } +} + /** Public agent handle; the concrete driver belongs to `@deepseek-ai/dsh-agent-loop`. */ export interface Agent { readonly id: AgentId @@ -112,11 +177,13 @@ export interface Agent { /** * Clear queued and steering work, including work waiting to start, and abort - * the active step. The supplied reason is preserved across pre-step and active - * cancellation windows, and `whenIdle()` resolves after cancellation reaches - * quiescence. Idle cancellation is a no-op and does not arm a later cancel. + * 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 at idle quiescence; disposal waits for driver exit rather than only the status transition. */ whenIdle(): Promise @@ -202,14 +269,17 @@ declare module 'cordis' { 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void /** * Allow, rewrite, or block one drained prompt before it becomes a user - * message. Call `next()` for the unchanged default. + * 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. * @param agent - the agent draining its inbox. * @param content - the drained message's blocks, as queued. * @param source - the message's resolved source. + * @param signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise + 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise): Promise /** * Replace the frozen call configuration. Model-visible content must use * logged channels; this seam cannot mutate messages. Injection here joins @@ -218,10 +288,11 @@ declare module 'cordis' { * @param turn - the open turn number. * @param step - the step whose request this is. * @param config - the config the loop would use (frozen); return a replacement to switch. + * @param signal - the current turn's explicit abort signal; ambient agent identity does not imply liveness or cancellation authority. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise + 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise): Promise /** * Compose request-only messages placed before derived history. The frozen * result is computed once per loop instance, logged on its anchoring request @@ -233,7 +304,7 @@ declare module 'cordis' { * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @param agent - the agent whose session prefix is being composed. * @param prefix - the frozen seed; return an extended replacement. - * @param signal - aborts composition when the step is torn down. + * @param signal - the current turn's explicit abort signal. * @mode waterfall */ 'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise @@ -244,30 +315,33 @@ declare module 'cordis' { * @param turn - the open turn number. * @param step - the step that produced the message. * @param message - the assistant message as assembled from the stream. + * @param signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise + 'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise): Promise /** * Override whether the turn continues. The default continues after tool * calls or steering and stops otherwise; a continue reason becomes steering. * @param agent - the agent deciding whether to run another step. * @param turn - the turn being continued or stopped. * @param defaultDecision - what the loop would do absent an override. + * @param signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ - 'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise + 'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise): Promise /** * 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. * @param agent - the agent whose composed continuation outcome may be stopped. * @param turn - the turn at its terminal-stop checkpoint. + * @param signal - the current turn's explicit abort signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode serial */ - 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined + 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | ContinuationStop | undefined // ---- error notifications (emit) ---- /** diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 5d541d56a8..1056100b41 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -22,12 +22,12 @@ function stubAgent(rawId: string): Agent { } describe('AgentRegistry', () => { - it('keeps terminal stop decisions synchronous', () => { + it('allows terminal stop policy to cooperate asynchronously with turn cancellation', () => { type TurnStopListener = Events['agent/turn-stop'] type AsyncTurnStopListener = () => Promise - expectTypeOf().not.toExtend() - expectTypeOf>().toEqualTypeOf() + expectTypeOf().toExtend() + expectTypeOf>>().toEqualTypeOf() }) it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => { diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 7e201ab647..3739df158f 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -64,6 +64,8 @@ Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own t Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). +An interrupted live turn ends with the coarse `{ kind: 'aborted' }` outcome. Caller identity belongs to the Agent's runtime cancellation signal rather than the durable transcript; disposal remains the separate `{ kind: 'disposed' }` terminal state. + Every `SessionEvent` carries two optional top-level fields (structural metadata): - `sourceEventSeqs?: number[]` — seq numbers of provenance sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed nodes behind a compaction replace node). diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index f4f42062fd..0585117f5f 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -93,7 +93,8 @@ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap] */ export 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 diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index af143ea5ee..9dc8f84fb5 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -102,7 +102,7 @@ describe('SessionStore.fork', () => { const { ctx, sessions } = await setup() const reasons: TurnEndReason[] = [ { kind: 'completed' }, - { kind: 'aborted', reason: 'cancelled by user' }, + { kind: 'aborted' }, { kind: 'error', step: 1, message: 'model failed', code: 'MODEL' }, { kind: 'disposed' }, { kind: 'max-tokens' }, diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 2326e7b212..a47025a715 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -40,6 +40,16 @@ describe('Session', () => { expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' }) }) + it('round-trips the coarse aborted turn outcome', () => { + const session = new Session(SessionId('aborted')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) + const replayed = new Session(SessionId('aborted-replay'), structuredClone(session.events)) + expect(replayed.events).toEqual(session.events) + const turnEnd = replayed.events.findLast(event => event.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) + }) + it('renders context and steering messages as tagged synthetic user content', () => { const session = new Session(SessionId('s2')) session.append('context/message', { diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index e8975bf17e..79ee30bbb0 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -16,7 +16,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem - `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber. - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. -- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name. +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name. ### Live events @@ -24,7 +24,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem ### Key types -- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) here, and `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent)`). Providers must tolerate absent fields (a bare `assemble()` carries an empty, scope-less context). +- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame. - `PromptSection` — `{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. - `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. - `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned. diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 5eb66d95f8..12171f1b2e 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -20,6 +20,8 @@ declare module 'cordis' { * 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. * @param assembly - the mutable assembly built from registered providers. * @param context - the caller's per-assembly context. * @mode waterfall @@ -41,6 +43,8 @@ export interface AssembleContext { * only global providers and subject-less listeners participate. */ scope?: ScopeKey + /** Explicit control signal for the turn that requested this assembly, when any. */ + signal?: AbortSignal } /** One contributed section of the system prompt (registry input). */ diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index ca4e6b5c0e..e227785472 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -230,7 +230,7 @@ export function apply(ctx: Context, config: Config): void { // A user interjection changes the context; repetition across it is not a // loop. Pure reset hook: always delegates (attaching nothing, vetoing // nothing). - ctx.on('agent/prompt-submit', (agent, _content, _source, next): Promise => { + ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next): Promise => { chains.delete(agent.id) return next() }) diff --git a/packages/hooks/hook-protocol/src/runner.ts b/packages/hooks/hook-protocol/src/runner.ts index fefb6936c9..1e7de698d0 100644 --- a/packages/hooks/hook-protocol/src/runner.ts +++ b/packages/hooks/hook-protocol/src/runner.ts @@ -27,7 +27,7 @@ export interface RunHookOptions { env?: Record /** Working directory for the hook (defaults to the executor's own default when omitted). */ cwd?: string - /** Abort signal — cancels the hook run when fired (the parent step aborts). */ + /** Explicit owning-operation signal; firing it cancels the hook run. */ signal?: AbortSignal /** Whether to append a trailing newline to the stdin payload (CC yes, Codex no). */ trailingNewline: boolean diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 08a2d26c9d..f2006daef8 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -210,9 +210,9 @@ export function apply(ctx: Context, config: Config): void { // --- UserPromptSubmit → PromptDecision. The prompt text is the payload; no // matcher subject (CC ignores matchers for this event). --- - ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise => { + ctx.on('agent/prompt-submit', async (agent, content, _source, signal, next): Promise => { const turn = lastTurn(agent) - const merged = await runPoint('UserPromptSubmit', '', promptPayload(agent, content), { agent, turn }) + const merged = await runPoint('UserPromptSubmit', '', promptPayload(agent, content), { agent, turn, signal }) if (merged.decision === 'deny') { return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } } @@ -261,8 +261,8 @@ export function apply(ctx: Context, config: Config): void { // A blocking Stop hook forces continuation with its reason. // TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile. - ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise => { - const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn }) + ctx.on('agent/turn-continuation', async (agent, turn, _default, signal, next): Promise => { + const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn, signal }) if (merged.decision === 'deny') { // A blocking Stop hook forces continuation. const text = merged.reason ?? 'continue: blocked by Stop hook' diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 924b181151..d84c96a0fd 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -183,9 +183,9 @@ export function apply(ctx: Context, config: Config): void { }) // UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask. - ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise => { + ctx.on('agent/prompt-submit', async (agent, content, _source, signal, next): Promise => { const turn = lastTurn(agent) - const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true }) + const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true, signal }) /* jscpd:ignore-start */ if (merged.decision === 'deny') return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } // Context alone is not a veto: DELEGATE so a later prompt-submit listener can @@ -236,8 +236,8 @@ export function apply(ctx: Context, config: Config): void { // TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can // avoid continuing the same turn indefinitely. It is always false here, so an // unconditionally blocking hook force-continues every step until it self-limits. - ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise => { - const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn }) + ctx.on('agent/turn-continuation', async (agent, turn, _default, signal, next): Promise => { + const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal }) /* jscpd:ignore-end */ if (merged.decision === 'deny') { // A blocking Stop hook forces continuation; a block with no reason (exit 2, diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index f6de7200cf..a1a5e9e7f8 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -153,7 +153,7 @@ export async function startInProcessRun( const onAbort = (): void => { flags.cancelled = true - child.cancel('subagent request aborted') + child.cancel({ kind: 'parent' }) } request.signal.addEventListener('abort', onAbort, { once: true }) diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 09aa2d24b7..811d754094 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -96,7 +96,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut // Stop the child's turn once its output is captured. This monotonic serial // checkpoint runs after the ordinary continuation waterfall, its reason, // and late-steering folding, so no ordering trick can resume a finished run. - childCtx.on('agent/turn-stop', function (this: unknown): ContinuationStop | undefined { + childCtx.on('agent/turn-stop', function (this: unknown, _agent, _turn, _signal): ContinuationStop | undefined { return captured === undefined ? undefined : { action: 'stop' } }) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index e8d19fe4c5..2971da0d61 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -221,7 +221,7 @@ describe('in-process structured output', () => { ctx.on('agent/session-start', (child) => { if (child === parent) return wrapperInstalled = true - child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, next): Promise => { + child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, _signal, next): Promise => { const downstream = await next() expect(downstream).toEqual({ action: 'stop' }) return { action: 'continue' } @@ -247,7 +247,7 @@ describe('in-process structured output', () => { const run = await ctx.subagents.start('spawn', structuredRequest(parent)) ctx.on('agent/session-start', (child) => { if (child.id !== run.id) return - child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, next): Promise => { + child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, _signal, next): Promise => { const downstream = await next() expect(downstream).toEqual({ action: 'stop' }) subject.steer([{ type: 'text', text: 'late steering after downstream stop' }]) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index cecad34bfc..720c22af2a 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -25,9 +25,10 @@ async function setup(script: Script) { await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) - ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const adapter = new MockAdapter(script) + ctx.llm.registerAdapter(['mock'], adapter) const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) - return { ctx, parent } + return { ctx, parent, adapter } } function request(parent: Agent, signal = new AbortController().signal) { @@ -102,12 +103,16 @@ describe('startInProcessRun', () => { }) it('uses the request signal after publication and dispose as cancellation paths', async () => { - const { parent } = await setup(['hang', 'hang']) + const { parent, adapter } = await setup(['hang', 'hang']) const controller = new AbortController() const signalled = await startInProcessRun(request(parent, controller.signal), {}) await new Promise(resolve => setTimeout(resolve, 30)) controller.abort('stop child') await expect(signalled.result).resolves.toMatchObject({ stopReason: 'aborted' }) + expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' }) + const child = parent.ctx.agents.get(signalled.id) + const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) await signalled.dispose() const disposed = await startInProcessRun(request(parent), {}) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index c464ffd22b..26d5bab371 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -495,7 +495,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // invariants and persistence observe the events in log order; the first flush // clears pending state. Promptless injection turns leave the switch pending, // with no request or execution under stale settings. - ctx.on('agent/prompt-submit', (agent, _content, _source, next) => { + ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) => { const sessionId = bySession.get(agent) const rec = sessionId === undefined ? undefined : sessions.get(sessionId) if (rec !== undefined) flushPendingSwitches(rec) @@ -706,7 +706,7 @@ export function apply(ctx: Context, config: AcpConfig): void { cancel(params: CancelNotification): Promise { const rec = sessions.get(SessionId(params.sessionId)) if (rec === undefined) return Promise.resolve() - // session/cancel maps to the queue-aware agent.cancel(reason): it aborts + // session/cancel maps to the queue-aware user cancel cause: it aborts // a RUNNING step, clears the queued + steering FIFOs, and drops a // turn that is about to start (the pre-step window) — so a queued-but- // not-yet-started prompt never runs, and a prompt accepted right after @@ -717,7 +717,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // settle it, because cancel() may drop the turn before any turn/end is // emitted, and removing this direct settle would move the RPC's // resolution onto a later observer path, changing its timing. - rec.agent.cancel('session/cancel') + rec.agent.cancel({ kind: 'user' }) settlePrompt(rec, 'cancelled') return Promise.resolve() }, @@ -778,7 +778,7 @@ export function apply(ctx: Context, config: AcpConfig): void { * Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach * quiescence"): for each session settle any pending prompt `cancelled`, then * run that session's {@link AgentHandle} `dispose()` — which stops the loop - * (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the + * (sets `disposed`, aborts the active turn), AWAITS the loop's exit (the * final `turn/end` + `session/flush` are captured while the store-owned publication hooks are still * attached), unregisters the agent, and removes its session from the store. * The per-session disposes run in parallel. Idempotent — clears the `sessions` @@ -817,7 +817,7 @@ export function apply(ctx: Context, config: AcpConfig): void { await Promise.all(recs.map(async (rec) => { settlePrompt(rec, 'cancelled') // Per-agent dispose (the AgentHandle disposer): unregister this agent, - // stop its loop (sets disposed + aborts the in-flight step), await + // stop its loop (sets disposed + aborts the active turn), await // quiescence (the loop exit + final flush), and remove its session — so // a bare client disconnect leaves NO registered agent and NO // session-store entry, not just an idled-but-still-registered one. diff --git a/packages/ui/acp/tests/codec.spec.ts b/packages/ui/acp/tests/codec.spec.ts index b4f0c10792..31ffb9ed48 100644 --- a/packages/ui/acp/tests/codec.spec.ts +++ b/packages/ui/acp/tests/codec.spec.ts @@ -15,7 +15,7 @@ describe('turnEndToStopReason', () => { it('maps every known TurnEndReason kind to a legal StopReason', () => { expect(turnEndToStopReason({ kind: 'completed' })).toBe('end_turn') expect(turnEndToStopReason({ kind: 'max-tokens' })).toBe('max_tokens') - expect(turnEndToStopReason({ kind: 'aborted', reason: 'x' })).toBe('cancelled') + expect(turnEndToStopReason({ kind: 'aborted' })).toBe('cancelled') expect(turnEndToStopReason({ kind: 'disposed' })).toBe('cancelled') expect(turnEndToStopReason({ kind: 'rejected', reason: 'blocked by hook' })).toBe('cancelled') expect(turnEndToStopReason({ kind: 'error', step: 1, message: 'boom' })).toBe('end_turn') diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index 78591ffa76..fc911480cd 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -316,6 +316,10 @@ describe('acp bridge — turn outcomes', () => { await harness.client.cancel({ sessionId }) const res = await promptDone expect(res.stopReason).toBe('cancelled') + const agent = harness.ctx.agents.get(AgentId(sessionId))! + await agent.whenIdle() + const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) }) it('cancel right after prompt settles cancelled and leaves the agent idle, no leaked turn', async () => { diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 35a957e57d..7104376ed6 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -12,6 +12,7 @@ "docs/i18n/README.md", "docs/i18n/translation-rules.md", "docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md", + "docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.md", "docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md", "python/README.md", "python/sdk-runtime/README.md", diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 221372aa5c..3d6e758838 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -10,6 +10,7 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "AgentCancelCause", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "AgentExecution", "source": "packages/core/agent-execution/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "AgentExecutionService", "source": "packages/core/agent-execution/src/index.ts" }, From 55916f931d10d1d894e7c26467f533065a2bfb9f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 16 Jul 2026 19:16:54 +0800 Subject: [PATCH 2/5] fix(core): preserve abort-observer replacements --- ...07-16-explicit-turn-cancellation.i18n.yaml | 2 +- ...026-07-16-explicit-turn-cancellation.zh.md | 42 +++++++++---------- .../feature/2026-06-30-interception-seams.md | 2 +- packages/core/agent-loop/src/agent.ts | 9 ++-- packages/core/agent-loop/tests/cancel.spec.ts | 34 +++++++++++++++ packages/core/agent/README.md | 2 +- 6 files changed, 63 insertions(+), 28 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index c9de5dd06a..bac31649ab 100644 --- a/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -3,4 +3,4 @@ # 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 +2026-07-16-explicit-turn-cancellation.zh.md: 912f6ae7efcb5e0124c45e1a044b5cf140a6ab52 diff --git a/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index d2f8a4227d..912f6ae7ef 100644 --- a/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -1,4 +1,4 @@ -# RFC:显式的 turn 取消能力 +# RFC:显式轮次取消能力 Status: implemented @@ -6,50 +6,50 @@ Status: implemented ## 问题 -取消是一种生命周期短于 Agent 驱动的控制能力。自由文本字符串无法对调用方进行穷尽区分,步骤级 controller 也无法中断 prompt 提交、prompt 组装、continuation 或 turn 终止策略。持久化 `Error`、`AbortSignal.reason` 或后端私有对象还会把不稳定的运行时细节暴露给持久化 replay。 +取消是一种生命周期短于 Agent(智能体)驱动器的控制能力。自由文本字符串无法穷尽地区分调用方,步骤级控制器也无法中断提示词提交、提示词组装、继续决策或轮次终止策略。持久化 `Error`、`AbortSignal.reason` 或后端私有对象还会向持久化回放暴露不稳定的运行时细节。 -[Agent 执行上下文决策](2026-07-15-agent-execution-context.md)有意让 AsyncLocalStorage 帧保持为 `{ agent }`。若把 turn、步骤或 signal 状态加入这个与驱动同生命周期的帧,陈旧的异步后代就会看似仍对后续 turn 拥有权限。因此,取消需要一个 turn 归属方和显式传播,不能引入另一套环境上下文或公开 turn 包装类型。 +[Agent 执行上下文决策](2026-07-15-agent-execution-context.md)有意让 AsyncLocalStorage 帧保持为 `{ agent }`。若把轮次、步骤或 signal 状态加入这个与驱动器同生命周期的帧,陈旧的异步后代就会看似仍对后续轮次拥有权限。因此,取消需要一个轮次归属方并显式传播,且不创建另一套环境上下文或公开的轮次包装层。 ## 决策 -Agent 拥有仅用于运行时的 `AgentCancelCause` union:`{ kind: 'user' } | { kind: 'parent' }`;`agent.cancel()` 默认使用 `user`。规范化边界只接受恰好包含一个受支持 `kind` 的普通对象或 null-prototype 对象,并返回供当前 turn signal 使用的分离且冻结值。即使 Agent 处于 idle,字符串、额外字段或 symbol 字段、未知 kind、数组、class 实例、`Error` 和 `AbortSignal` 也会被同步拒绝。 +Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user' } | { kind: 'parent' }`;`agent.cancel()` 默认使用 `user`。规范化边界只接受恰好包含一个受支持 `kind` 的普通对象或原型为 null 的对象,并返回供当前轮次 signal 使用的、与调用方分离且已冻结的值。即使 Agent 处于空闲状态,字符串、额外字段或符号字段、未知 kind、数组、类实例、`Error` 和 `AbortSignal` 也会被同步拒绝。 -被中断的 live turn 以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录 turn 发生了什么,运行时 signal 则标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。未来若有审计需求,应使用独立的控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含 stack、signal、错误对象、自由文本取消原因或后端私有细节。 +正在运行的轮次被中断后,以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录轮次发生了什么,运行时 signal 标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。未来若有审计需求,应使用独立的控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含调用栈、signal、错误对象、自由文本取消原因或后端私有细节。 -AgentLoop 为每个预期 turn 私有地拥有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装 holder,使其中唯一的 `AbortController` 持续覆盖 prompt 处理、prompt 组装、每个步骤、模型与工具执行、continuation、`agent/turn-stop`、`turn/end` 和持久化 flush,随后清除 holder。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一 turn 会收到全新 signal。 +AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖提示词处理、提示词组装、每个步骤、模型与工具执行、继续决策、`agent/turn-stop`、`turn/end` 和持久化刷新,随后清除该持有者。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 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。 +对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。它会清除 `cancel()` 调用时已存在的排队工作和 steering(中途引导)工作,但不会预先取消未来的提示词。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 -显式事件签名保留 positional 形态,并把 `signal` 放在 waterfall 最后一个参数 `next` 之前。Prompt 提交、请求配置、步骤结果处理、continuation 和终止停止加入已有的 pre-step、session prefix、模型生成、工具执行、审批以及 subagent 或 workflow 请求显式 signal seam。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值。Listener 可以配合该 signal 取消,但不得保留它来控制另一 turn。 +显式事件签名保留位置参数形式,并把 `signal` 放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。提示词提交、请求配置、步骤结果处理、继续决策和终止停止加入已有的步骤前处理、会话前缀、模型生成、工具执行、审批以及 subagent 或工作流请求的显式 signal seam。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 -`ctx.agentExecution` 仍只提供身份。环境中的 Agent 并不代表存活、当前 turn 或取消权限,`agentInterruptReasonOf(signal)` 也只读取其显式参数。并发 Agent 会同时隔离各自的 ALS 身份和 turn signal;子 Agent 会遮蔽父 Agent 身份,而父请求 signal 仍通过 subagent seam 传递。 +`ctx.agentExecution` 仍只提供身份。环境中的 Agent 并不代表存活、当前轮次或取消权限,`agentInterruptReasonOf(signal)` 也只读取其显式参数。并发 Agent 会同时隔离各自的 ALS 身份和轮次 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。 +Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时的 `{ kind: 'disposed' }` 中断。若取消已经先占用控制器的中断原因,该原因便无法改写,因此终态分类会先检查生命周期状态:资源释放结果优先,之后受支持的 `user` 或 `parent` 取消原因形成粗粒度的中止结果,其他异常保留现有错误路径。ACP(Agent Client Protocol)取消映射为 `user`;进程内 spawn 和 fork 的传播映射为 `parent`。远程 ACP subagent 保持现有协议。 -取消仍然是协作式的。Loop 会在 await 边界前后检查中断,但不会用 `Promise.race` 放弃进程内 listener、adapter 或工具 Promise。忽略 signal 的工作必须真正结算,`whenIdle()`、handle dispose 和 scope teardown 才会报告静止状态。 +取消仍然是协作式的。AgentLoop 会在异步等待边界前后检查中断,但不会用 `Promise.race` 放弃进程内监听器、适配器或工具 Promise。忽略 signal 的工作必须真正结算,`whenIdle()`、句柄 dispose 和作用域清理才会报告静止状态。 ## 验证 -契约测试验证严格的运行时 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。 +契约测试验证严格的运行时取消原因校验、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在提示词提交、系统提示词组装、会话前缀、步骤前处理、请求、模型流、步骤结果、工具执行、继续决策和终止停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal。 -执行上下文测试断言所有 hook 仍只观察到 `{ agent }`,并发 Agent 保持独立的身份与 signal,嵌套子 Agent 创建只遮蔽身份。竞态测试覆盖 idle 取消、pre-run 取消、从 `running` listener 提交 replacement、重复取消以及 cancel 与 dispose 竞争下的静止状态。 +执行上下文测试断言所有钩子仍只观察到 `{ agent }`,并发 Agent 保持独立的身份与 signal,嵌套子 Agent 创建只遮蔽身份。竞态测试覆盖空闲状态取消、运行前取消、从 `running` 监听器提交替代提示词、重复取消以及取消与 dispose 竞争下的静止状态。 ## 考虑过的替代方案 -**把 signal 存入 ALS。** ALS 会在整个驱动生命周期内跟随异步后代,而取消权限在一个 turn 结束时就已终止。泄漏的回调可能观察到陈旧 signal,或者迫使实现替换可变帧,因此身份帧保持 `{ agent }`,控制能力继续显式传递。 +**把 signal 存入 ALS。** ALS 会在整个驱动器生命周期内跟随异步后代,而取消权限在一个轮次结束时就已终止。泄漏的回调可能观察到陈旧 signal,或者迫使实现替换为可变帧,因此身份帧保持 `{ agent }`,控制能力继续显式传递。 -**持久化自由文本 reason。** 字符串允许拼写漂移、阻碍穷尽 switch,还会鼓励消费方解析展示文本。运行时使用封闭的 discriminated union,终态记录只需要稳定的 aborted 结果。 +**持久化自由文本原因。** 字符串允许拼写漂移、阻碍穷尽分支判断,还会鼓励消费方解析展示文本。运行时使用封闭的可辨识联合类型,终态记录只需要稳定的中止结果。 -**在 `turn/end` 中持久化类型化调用方 cause。** 当前没有任何生产环境中的 replay、UI、ACP、telemetry 或 workflow 消费方区分 `user` 与 `parent`。把请求来源复制到终态结果会混淆两项事实,还会在没有消费方的情况下引入 Session 特有校验;未来的审计接口可以记录独立的取消请求事件。 +**在 `turn/end` 中持久化类型化调用方取消原因。** 当前没有任何生产环境中的回放、UI、ACP、遥测或工作流消费方区分 `user` 与 `parent`。把请求来源复制到终态结果会混淆两项事实,还会在没有消费方的情况下引入会话特有校验;未来的审计接口可以记录独立的取消请求事件。 -**现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose;timeout 或 supersession 只有在拥有明确归属策略和唯一终态含义时才应进入 union。 +**现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose;超时或替代只有在拥有明确归属策略和唯一终态含义时才应进入联合类型。 -**公开 turn 或步骤 context 包装类型。** 现有 positional seam 已经标识 Agent、turn 和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。 +**公开轮次或步骤上下文包装类型。** 现有位置参数 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。 -**在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就返回 idle 会破坏 teardown 与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。 +**在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。 ## 后果 -取消拥有一个运行时归属方、每个 turn 一个 signal,以及一套类型化的运行时调用方词汇。Session 保留其消费方实际使用的粗粒度 `aborted` 结果,与运行时对象保持隔离,也不再需要取消专用的规范化逻辑。协作式取消覆盖每个异步 turn seam,包括第一个步骤之前和最后一个步骤之后的工作。 +取消拥有一个运行时归属方、每个轮次一个 signal,以及一套类型化的运行时调用方词汇。会话保留其消费方实际使用的粗粒度 `aborted` 结果,与运行时对象保持隔离,也不再需要取消专用的规范化逻辑。协作式取消覆盖每个异步轮次 seam,包括第一个步骤之前和最后一个步骤之后的工作。 -显式 signal 会给多个公开事件增加参数,并要求插件有意识地转发取消。这是有意设计:权限在调用边界可见,生命周期与 turn 匹配,陈旧的环境异步后代无法获得控制能力。不协作的进程内工作可能延迟取消,但所报告的静止状态仍然真实。 +显式 signal 会给多个公开事件增加参数,并要求插件有意识地转发取消。这是有意设计:权限在调用边界可见,生命周期与轮次匹配,陈旧的环境异步后代无法获得控制能力。不协作的进程内工作可能延迟取消,但所报告的静止状态仍然真实。 diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index ea371ad55a..43169f67ff 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -14,7 +14,7 @@ The canonical surface separates transformable policy, around-dispatch control, a **Agent events** (`dsh-agent`): - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. -- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below). +- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `signal` belongs to that turn and `next` remains the final parameter. `allow` optionally rewrites the prompt `content` or attaches `additionalContext`; `block` drops the prompt and the loop appends a durable `prompt/blocked` in its place (see the dispatch note below). **`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index b984e4bfa0..94a6193c36 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -276,12 +276,13 @@ export class ReactLoopAgent implements Agent { const active = this.turnCancellation if (active === undefined && !this.#inbox.hasQueued && !this.#inbox.hasSteering) return if (active === undefined) this.preRunCancelled = true - else active.request(accepted) // Drop all pending queued + steering work (un-started prompts never run; the - // cancelled turn's steering is not re-enqueued). Cleared directly even when - // the loop is parked in waitForQueued — there is no turn to stop and nothing - // left for the parked loop to run, so no wake is needed. + // cancelled turn's steering is not re-enqueued). Clear before abort dispatch, + // whose synchronous observers may enqueue replacement work that must survive. + // This is direct even when the loop is parked in waitForQueued — there is no + // turn to stop and nothing left for the parked loop to run, so no wake is needed. this.#inbox.clear() + if (active !== undefined) active.request(accepted) } /** diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 0dbac6fce6..2241717b94 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -124,6 +124,40 @@ describe('Agent.cancel()', () => { expect(reasons).toEqual([{ kind: 'aborted' }]) }) + it('keeps replacement work queued synchronously by an abort observer', async () => { + const adapter = new MockAdapter(['hang', textResponse('replacement reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('abort-observer-replacement'), { model: 'mock' }) + + send(agent, 'original') + await expect.poll(() => adapter.requests.length).toBe(1) + const signal = adapter.requests[0]?.signal + if (signal === undefined) throw new Error('model request omitted its turn signal') + signal.addEventListener('abort', () => { send(agent, 'replacement') }, { once: true }) + const idle = waitForIdle(ctx, agent) + agent.cancel({ kind: 'user' }) + await Promise.race([ + idle, + new Promise((_resolve, reject) => { + setTimeout(() => { + reject(new Error(`replacement did not settle: ${JSON.stringify({ + status: agent.status, + requests: adapter.requests.length, + users: userTexts(agent), + events: agent.session.events.map(event => event.type), + })}`)) + }, 1000) + }), + ]) + + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['original', 'replacement']) + const reasons = agent.session.events + .filter(event => event.type === 'turn/end') + .map(event => event.type === 'turn/end' ? event.data.reason : undefined) + expect(reasons).toEqual([{ kind: 'aborted' }, { kind: 'completed' }]) + }) + it('cancel() with no cause defaults to user when aborting an active turn', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index bdbd6c6a11..e5726c22c0 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -44,7 +44,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) -- `agent.cancel(cause?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the active turn, and drops queued work not yet claimed by the driver. `AgentCancelCause` is the runtime-only `{ kind: 'user' } | { kind: 'parent' }`; omission means `user`, the first cause wins for an active turn, and ACP `session/cancel` maps to `user`. `normalizeAgentCancelCause()` provides the same strict detached-value boundary used by the concrete loop: validation is synchronous even while idle, accepts only an exact plain object, and returns a safe no-op when no work exists. +- `agent.cancel(cause?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the active turn, and drops queued work not yet claimed by the driver. `AgentCancelCause` is the runtime-only `{ kind: 'user' } | { kind: 'parent' }`; omission means `user`, the first cause wins for an active turn, and ACP `session/cancel` maps to `user`. `normalizeAgentCancelCause()` provides the same strict detached-value boundary used by the concrete loop: validation is synchronous even while idle, accepts only an exact plain object, and returns a frozen detached cause. After validation, `agent.cancel()` is a safe no-op when no work exists. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` From 3690c5566051b9165c1807c8d6b40f9142bd5345 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 20 Jul 2026 21:55:49 +0800 Subject: [PATCH 3/5] test(invariants): type agent event carrier rows --- .../invariants/tests/invariants.spec.ts | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index ae886e8874..b8a0cef6a9 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import type { Events } from 'cordis' import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope' import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -900,6 +901,9 @@ describe('request cross-check ordering (prepend)', () => { }) describe('scoped-dispatch invariants', () => { + type AgentEventName = Extract + type EventArgs = Events[K] extends (...args: infer Args) => unknown ? Args : never + async function scopedCtx() { const ctx = new Context() await ctx.plugin(SessionStore) @@ -919,22 +923,30 @@ describe('scoped-dispatch invariants', () => { // Real Session objects keep the synthetic Agent handles structurally valid. const agent = { id: 'a1', session: new Session(SessionId('a1-s')) } as unknown as Agent const other = { id: 'a2', session: new Session(SessionId('a2-s')) } as unknown as Agent - // One dispatch per table row keeps every subject extractor covered: the - // matching carrier passes, the foreign-keyed one throws. + // Typed Agent rows keep their representative payloads aligned with the + // declarations while every subject extractor sees both carrier outcomes. + const signal = new AbortController().signal + const config = { provider: 'p', model: 'm' } + const message = { role: 'assistant' as const, content: [] } + const agentRows = { + 'agent/created': [agent], + 'agent/disposed': [agent], + 'agent/status': [agent, 'idle'], + 'agent/queued': [agent, [], { source: { kind: 'user' }, steering: false }], + 'agent/session-start': [agent, 'startup'], + 'agent/pre-step': [agent, 1, 1, signal], + 'agent/post-step': [agent, 1, 1, signal], + 'agent/prompt-submit': [agent, [], { kind: 'user' }, signal, () => Promise.resolve({ kind: 'allow' })], + 'agent/request': [agent, 1, 1, config, signal, () => Promise.resolve(config)], + 'agent/request-error': [agent, 1, 1, new Error('request failed'), 0, signal, () => Promise.resolve({ action: 'fail' })], + 'agent/session-prefix': [agent, [], signal, () => Promise.resolve([])], + 'agent/step-result': [agent, 1, 1, message, signal, () => Promise.resolve(message)], + 'agent/turn-continuation': [agent, 1, { action: 'stop' }, signal, () => Promise.resolve({ action: 'stop' })], + 'agent/turn-stop': [agent, 1, signal], + 'agent/error': [agent, 1, 0, new Error('x')], + } satisfies { [K in AgentEventName]: EventArgs } const rows: [string, unknown[]][] = [ - ['agent/created', [agent]], - ['agent/disposed', [agent]], - ['agent/status', [agent, 'idle']], - ['agent/queued', [agent, [], { source: { kind: 'user' }, steering: false }]], - ['agent/session-start', [agent, 'startup']], - ['agent/pre-step', [agent, 1, 1, new AbortController().signal]], - ['agent/prompt-submit', [agent, [], { kind: 'user' }, new AbortController().signal, () => Promise.resolve({ kind: 'allow' })]], - ['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]], - ['agent/session-prefix', [agent, [], new AbortController().signal, () => Promise.resolve([])]], - ['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]], - ['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]], - ['agent/turn-stop', [agent, 1]], - ['agent/error', [agent, 1, 0, new Error('x')]], + ...Object.entries(agentRows), ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]], ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]], From 81cdebc5315dc7764d11992c363f88a27a4bdcd9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:08:44 +0800 Subject: [PATCH 4/5] fix(hooks): require invocation signals --- packages/hooks/hooks-claude/src/index.ts | 4 ++-- packages/hooks/hooks-codex/src/index.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 63cd88f8bf..5a5d33427d 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -132,7 +132,7 @@ export function apply(ctx: Context, config: Config): void { point: string, matchQuery: string, payload: unknown, - opts: { agent?: Agent; turn?: number; signal?: AbortSignal }, + opts: { agent?: Agent; turn?: number; readonly signal: AbortSignal }, ): Promise { const groups: MatcherGroup[] = parsed[point] ?? [] const outputs: HookOutput[] = [] @@ -159,7 +159,7 @@ export function apply(ctx: Context, config: Config): void { defaultTimeoutMs, ...hookEnv ? { env: hookEnv } : {}, ...workdir !== undefined ? { cwd: workdir } : {}, - ...opts.signal ? { signal: opts.signal } : {}, + signal: opts.signal, trailingNewline: true, // Discard a `hookSpecificOutput` block whose `hookEventName` names a // different event than the one firing (the schemas key it by event). diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 9dfd171d8e..7d05950957 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -106,7 +106,12 @@ export function apply(ctx: Context, config: Config): void { point: string, matchQuery: string, payload: unknown, - opts: { agent?: Agent; turn?: number; signal?: AbortSignal; plainStdoutAsContext?: boolean }, + opts: { + agent?: Agent + turn?: number + readonly signal: AbortSignal + plainStdoutAsContext?: boolean + }, ): Promise { const groups: MatcherGroup[] = parsed[point] ?? [] const outputs: HookOutput[] = [] @@ -129,7 +134,7 @@ export function apply(ctx: Context, config: Config): void { payload, defaultTimeoutMs, ...workdir !== undefined ? { cwd: workdir } : {}, - ...opts.signal ? { signal: opts.signal } : {}, + signal: opts.signal, trailingNewline: false, // Codex writes stdin without a trailing newline. // Discard a `hookSpecificOutput` block naming a different event. expectedEventName: point, From c6e1d35a99993127ea9742a651b8653bf50bceb4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:14:53 +0800 Subject: [PATCH 5/5] fix(core): close turn cancellation contract gaps --- ...07-16-explicit-turn-cancellation.i18n.yaml | 4 +- .../2026-07-16-explicit-turn-cancellation.md | 12 ++-- ...026-07-16-explicit-turn-cancellation.zh.md | 12 ++-- docs/architecture.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.md | 6 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/agent.ts | 6 +- packages/core/agent-loop/src/cancellation.ts | 4 +- packages/core/agent-loop/src/loop.ts | 12 ++-- packages/core/agent-loop/tests/cancel.spec.ts | 57 +++++++++------ packages/core/agent/README.md | 4 +- packages/core/agent/src/cancellation.ts | 59 ++++----------- packages/core/agent/src/index.ts | 2 +- packages/core/agent/src/types.ts | 4 +- packages/core/agent/tests/agent.spec.ts | 71 +++++-------------- packages/core/session/README.md | 2 +- packages/core/session/src/index.ts | 17 +++++ packages/core/session/tests/session.spec.ts | 16 +++++ packages/hooks/hook-protocol/README.md | 2 +- packages/hooks/hook-protocol/src/runner.ts | 4 +- .../hooks/hook-protocol/tests/runner.spec.ts | 27 ++++--- .../hooks/hooks-codex/tests/bridge.spec.ts | 26 +++++++ 23 files changed, 185 insertions(+), 168 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index e0839bb6aa..c36ec1d89f 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -2,5 +2,5 @@ # 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: b8cf16f1ae6a8430ae47f0f548d8aae1355a536d -2026-07-16-explicit-turn-cancellation.zh.md: ad6944cb88a253580dc8b2c217a2b9116419f926 +2026-07-16-explicit-turn-cancellation.md: 2803d140256a7a65f901e7c61d8cef32091e7cc9 +2026-07-16-explicit-turn-cancellation.zh.md: 3bebe8642ee65f91e6eb12447b2f732418906dca diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index b8cf16f1ae..2803d14025 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -12,15 +12,15 @@ The [initiating Agent scope decision](2026-07-15-agent-initiator-scope.md) inten ## 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. +Agent owns the runtime-only `AgentCancelCause` union `{ kind: 'user' } | { kind: 'parent' }`; `agent.cancel()` defaults to `user`. TypeScript enforces that vocabulary at this typed same-process seam, with no runtime validator, fallback, or special compatibility contract for untyped callers. An active `TurnCancellation` copies the typed discriminant into a fresh frozen signal reason; idle cancellation has no holder to mutate and does not arm later work. -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. +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. Session seed/load rejects legacy aborted records with a reason or any other extra field, so replay cannot reintroduce caller-owned cancellation detail. 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. +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, and `agent/turn-stop`, then clears the exact holder immediately before publishing `turn/end`. Terminal event observers and the following durability flush therefore cannot cancel already-completed turn work even though driver status may remain `running` until the flush settles. 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. +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. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. `ctx.agents` continues to carry only the initiating Agent. 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 initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. @@ -30,7 +30,7 @@ Cancellation remains cooperative. The loop checks interruption before and after ## 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. +Contract tests verify the typed caller union, frozen detachment, default and first-wins behavior, the coarse Session JSON round trip and legacy-record rejection, 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, a fresh signal across turns, and no cancellation authority during terminal publication or a blocked durability flush. A real hook bridge test cancels and reaps a blocked prompt hook before idle. Initiator-scope tests assert that every hook still observes the exact Agent and no ambient turn signal, concurrent Agents retain independent identities and signals, and a nested child driver shadows only identity. Race tests cover idle cancellation, pre-run cancellation, replacement submission from a `running` listener, repeated cancellation, and cancel-versus-dispose quiescence. @@ -50,6 +50,6 @@ Initiator-scope tests assert that every hook still observes the exact Agent and ## 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. +Cancellation has one runtime owner, one signal per live turn, and one typed runtime caller vocabulary. Session retains the coarse `aborted` outcome that its consumers actually use, rejects reason-bearing legacy forms, and stays isolated from runtime objects. Cooperative cancellation reaches every asynchronous turn seam, including work before the first step and after the last one, while terminal publication and persistence remain outside its authority. 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. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index ad6944cb88..3bebe8642e 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -12,15 +12,15 @@ Status: implemented ## 决策 -Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user' } | { kind: 'parent' }`;`agent.cancel()` 默认使用 `user`。规范化边界只接受恰好包含一个受支持 `kind` 的普通对象或原型为 null 的对象,并返回供当前轮次 signal 使用的、与调用方分离且已冻结的值。即使 Agent 处于空闲状态,字符串、额外字段或符号字段、未知 kind、数组、类实例、`Error` 和 `AbortSignal` 也会被同步拒绝。 +Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user' } | { kind: 'parent' }`;`agent.cancel()` 默认使用 `user`。TypeScript 在这份类型化同进程契约中强制执行该词汇,不提供运行时校验器、后备行为,也不为无类型调用方提供特殊兼容性契约。活跃的 `TurnCancellation` 会把类型化判别字段复制为一个全新且已冻结的 signal 原因;空闲状态下没有可修改的持有者,也不会让后续工作预先进入取消状态。 -正在运行的轮次被中断后,以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录轮次发生了什么,运行时 signal 标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。未来若有审计需求,应使用独立的控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含调用栈、signal、错误对象、自由文本取消原因或后端私有细节。 +正在运行的轮次被中断后,以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录轮次发生了什么,运行时 signal 标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。Session seed/load 会拒绝携带取消原因或任何其他额外字段的旧式中止记录,因此回放无法重新引入由调用方持有的取消细节。未来若有审计需求,应使用独立的控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含调用栈、signal、错误对象、自由文本取消原因或后端私有细节。 -AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖提示词处理、提示词组装、每个步骤、模型与工具执行、继续决策、`agent/turn-stop`、`turn/end` 和持久化刷新,随后清除该持有者。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。 +AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖提示词处理、提示词组装、每个步骤、模型与工具执行、继续决策和 `agent/turn-stop`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。 对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。它会清除 `cancel()` 调用时已存在的排队工作和 steering(中途引导)工作,但不会预先取消未来的提示词。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 -显式事件签名保留位置参数形式,并把 `signal` 放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。提示词提交、请求配置、步骤结果处理、继续决策和终止停止加入已有的步骤前处理、会话前缀、模型生成、工具执行、审批以及 subagent 或工作流请求的显式 signal seam。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 +显式事件签名保留位置参数形式,并把 `signal` 放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。提示词提交、请求配置、步骤结果处理、继续决策和终止停止加入已有的步骤前处理、会话前缀、模型生成、工具执行、审批以及 subagent 或工作流请求的显式 signal seam。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 `ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限,`agentInterruptReasonOf(signal)` 也只读取其显式参数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 @@ -30,7 +30,7 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时 ## 验证 -契约测试验证严格的运行时取消原因校验、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在提示词提交、系统提示词组装、会话前缀、步骤前处理、请求、模型流、步骤结果、工具执行、继续决策和终止停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal。 +契约测试验证类型化调用方联合类型、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在提示词提交、系统提示词组装、会话前缀、步骤前处理、请求、模型流、步骤结果、工具执行、继续决策和终止停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。 发起方作用域测试断言所有钩子仍观察到同一个 Agent 且没有环境中的轮次 signal,并发 Agent 保持独立的身份与 signal,嵌套子驱动只遮蔽身份。竞态测试覆盖空闲状态取消、运行前取消、从 `running` 监听器提交替代提示词、重复取消以及取消与 dispose 竞争下的静止状态。 @@ -50,6 +50,6 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时 ## 后果 -取消拥有一个运行时归属方、每个轮次一个 signal,以及一套类型化的运行时调用方词汇。会话保留其消费方实际使用的粗粒度 `aborted` 结果,与运行时对象保持隔离,也不再需要取消专用的规范化逻辑。协作式取消覆盖每个异步轮次 seam,包括第一个步骤之前和最后一个步骤之后的工作。 +取消拥有一个运行时归属方、每个活跃轮次一个 signal,以及一套类型化的运行时调用方词汇。会话保留其消费方实际使用的粗粒度 `aborted` 结果,拒绝携带原因的旧式形式,并与运行时对象保持隔离。协作式取消覆盖每个异步轮次 seam,包括第一个步骤之前和最后一个步骤之后的工作,而终态发布和持久化仍在其权限范围之外。 显式 signal 会给多个公开事件增加参数,并要求插件有意识地转发取消。这是有意设计:权限在调用边界可见,生命周期与轮次匹配,陈旧的环境异步后代无法获得控制能力。不协作的进程内工作可能延迟取消,但所报告的静止状态仍然真实。 diff --git a/docs/architecture.md b/docs/architecture.md index f378774f4c..647c631eab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,7 +117,7 @@ Pruning precedes summaries; overflow retries require durable progress. Bounded t The turn contains failures. Adapter failures close the step before `agent/request-error`, which receives exact `Error`, `LlmFailure`, and history. Retry opens another step; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit no message/tool. -Others use `agent/error`. Cancellation/disposal beat recovery; undispatched calls get synthetic `tool/call` plus `ABORTED_BEFORE_DISPATCH` results before `turn/end`. One turn-wide `AbortSignal` covers stages. `cancel()` validates `user | parent`, clears queues, and aborts it; durability records `aborted`. Disposal quiesces before unregistering ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +Others use `agent/error`. Cancellation/disposal beat recovery; undispatched calls get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The signal spans stages until retirement before `turn/end`; typed `cancel()` clears queues and aborts it with `user | parent`. Durability records `aborted`; disposal quiesces before unregistering ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). Every session event is turn-enclosed. Reloading preserves an interrupted tail and closes it with a synthetic `interrupted` turn end. Failures after durable turn close report only through `agent/error` because no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ecd88171fb..dd0c9aeb02 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -832,7 +832,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:553`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:570`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index fd8a7d3192..e657516427 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -397,8 +397,8 @@ interface Agent { * Clear all queued and steering work, including items 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. + * `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm a later + * cancel. The active turn snapshots and freezes the typed cause. * @param cause - the stable caller intent carried by the current turn signal. */ cancel(cause?: AgentCancelCause): void @@ -411,7 +411,7 @@ interface Agent { `AgentStatus` is `'idle' | 'running' | 'disposed'`, and `SessionId` is branded. `running` describes the driver-wide drain interval, which can span turn close, its durability checkpoint, and consecutive queued turns; it does not prove a turn is still open. `AgentOptions` is merge-extensible and currently includes `provider?` and `model?`; dispatch requires both after `agent/request`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. -The cause is runtime-only and becomes `AbortSignal.reason` on the turn's explicit signal. `agentInterruptReasonOf(signal)` recognizes `user`, `parent`, and lifecycle-only `disposed` without consulting ambient initiator state. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. +The cause is a TypeScript-enforced same-process input. An active holder copies its discriminant into the runtime-only `AbortSignal.reason`; it is retired before `turn/end` publication. `agentInterruptReasonOf(signal)` recognizes `user`, `parent`, and lifecycle-only `disposed` without consulting ambient initiator state. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index fab6abb97b..4c92b460ff 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -56,7 +56,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history. -Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, terminal stop, turn close, and flush; the next turn gets a fresh signal. `cancel()` strictly normalizes a runtime-only `user | parent` cause, clears pending work, and cooperatively aborts the holder without leaking to the next prompt; durable `turn/end` remains coarse `aborted`, and undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush. +Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, and terminal stop; it retires the holder immediately before publishing `turn/end`, while the driver may remain `running` through the durability flush. `cancel()` accepts the typed runtime-only `user | parent` cause, clears pending work, and cooperatively aborts the holder without leaking to the next prompt; durable `turn/end` remains coarse `aborted`, and undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index b13c1f1aca..3bad874d4a 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -7,7 +7,7 @@ */ import type { Context } from 'cordis' -import { agentEvents, normalizeAgentCancelCause } from '@deepseek-ai/dsh-agent' +import { agentEvents } from '@deepseek-ai/dsh-agent' import type { AgentCancelCause, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' @@ -325,7 +325,7 @@ export class ReactLoopAgent implements Agent { } cancel(cause?: AgentCancelCause): void { - const normalized = normalizeAgentCancelCause(cause ?? { kind: 'user' }) + const reason = cause ?? { kind: 'user' } const cancellation = this.turnCancellation const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering) if (preRun) { @@ -334,7 +334,7 @@ export class ReactLoopAgent implements Agent { // Clear work already present before abort observers run. A replacement // synchronously enqueued by an observer belongs to the next turn. this.#inbox.clear() - cancellation?.request(normalized) + cancellation?.request(reason) } /** diff --git a/packages/core/agent-loop/src/cancellation.ts b/packages/core/agent-loop/src/cancellation.ts index e4054d8262..c3f5430a20 100644 --- a/packages/core/agent-loop/src/cancellation.ts +++ b/packages/core/agent-loop/src/cancellation.ts @@ -20,12 +20,12 @@ export class TurnCancellation { /** * Abort the turn once. - * @param reason - a validated caller cause or lifecycle disposal marker. + * @param reason - a typed caller cause or lifecycle disposal marker. * @returns whether this request established the signal reason. */ request(reason: AgentCancelCause | typeof DISPOSED_INTERRUPT_REASON): boolean { if (this.signal.aborted) return false - this.#controller.abort(reason) + this.#controller.abort(Object.freeze({ kind: reason.kind })) return true } } diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index d6eec0a8c8..5961a60b15 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -124,7 +124,7 @@ export interface LoopHandle { setStatus(status: 'idle' | 'running'): void /** Install a fresh active-turn owner before the running notification. */ installTurnCancellation(): TurnCancellation - /** Clear only the exact owner whose turn and durability flush settled. */ + /** Clear only the exact owner whose turn reached its terminal event boundary. */ clearTurnCancellation(cancellation: TurnCancellation): void /** Resolves when the agent is disposed — unblocks the idle wait. */ disposed: Promise @@ -209,7 +209,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { const turn = lastTurnNumber(session) + 1 let terminalStopped = false try { - terminalStopped = await runTurn(ctx, events, handle, turn, transmission, cancellation.signal) + terminalStopped = await runTurn(ctx, events, handle, turn, transmission, cancellation) } catch (error: unknown) { // Pre-turn failure has no durable boundary to close; report it without appending outside a turn. const err = toError(error) @@ -232,10 +232,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { async function runTurn( ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog, - signal: AbortSignal, + cancellation: TurnCancellation, ): Promise { const agent = ctx.agents.requireInitiator() const { session } = agent + const { signal } = cancellation const drainSteering = (): boolean => { const messages = handle.inbox.drainSteering() for (const message of messages) { @@ -279,8 +280,11 @@ async function runTurn( } } - // Pre-commit validation failure escapes rather than masquerading as a committed boundary. + // Retire cancellation authority before publishing the terminal event. The + // following durability flush is quiescent turn work, but no longer part of + // the cancellable turn lifetime. const closeTurn = (): void => { + handle.clearTurnCancellation(cancellation) session.append('turn/end', { turn, reason }) } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index b722a5312d..ec94b5fc59 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -779,30 +779,43 @@ describe('Agent.cancel()', () => { expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) }) - it('rejects invalid causes synchronously while idle and running', async () => { - class Cause { - readonly kind = 'user' - } - const adapter = new MockAdapter(['hang']) + it('retires turn cancellation before terminal publication and a blocked durability flush', async () => { + const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('invalid-cause'), { provider: 'mock', model: 'mock' }) - const controller = new AbortController() - const invalid: unknown[] = [ - 'user', - { kind: 'timeout' }, - { kind: 'user', detail: 'extra' }, - new Error('cancelled'), - controller.signal, - new Cause(), - ] - for (const value of invalid) expect(() => { agent.cancel(value as never) }).toThrow(TypeError) + const agent = ctx.agentLoop.create(SessionId('terminal-cancellation-authority'), { provider: 'mock', model: 'mock' }) + const flushStarted = Promise.withResolvers() + const releaseFlush = Promise.withResolvers() + let abortedDuringTurnEnd: boolean | undefined - send(agent, 'go') - await expect.poll(() => adapter.requests.length).toBe(1) - for (const value of invalid) expect(() => { agent.cancel(value as never) }).toThrow(TypeError) - expect(agent.status).toBe('running') - agent.cancel() - await waitForIdle(ctx, agent) + ctx.on('session/event', (session, event) => { + if (session !== agent.session || event.type !== 'turn/end') return + const signal = adapter.requests[0]?.signal + if (signal === undefined) throw new Error('model request omitted its turn signal') + agent.cancel({ kind: 'user' }) + abortedDuringTurnEnd = signal.aborted + }) + ctx.on('session/flush', async (session) => { + if (session !== agent.session) return + flushStarted.resolve(undefined) + await releaseFlush.promise + }) + + send(agent, 'finish before persistence drains') + await flushStarted.promise + const signal = adapter.requests[0]?.signal + if (signal === undefined) throw new Error('model request omitted its turn signal') + const idle = agent.whenIdle() + agent.cancel({ kind: 'user' }) + + expect(abortedDuringTurnEnd).toBe(false) + expect(signal.aborted).toBe(false) + expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({ + data: { reason: { kind: 'completed' } }, + }) + + releaseFlush.resolve(undefined) + await idle + expect(agent.status).toBe('idle') }) it('records disposed when lifecycle teardown races an already-requested cancel', async () => { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 3c5fa3111c..8ae1bc71e9 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -44,7 +44,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. +Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. `PromptDecision.additionalContexts` is an array so every injected context keeps its own source and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source. @@ -57,7 +57,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale. - `agent.steer(content, options?)` — submit steering while the agent is `running`. An open turn records it at the next steering checkpoint before a request or continuation decision; policy can still stop before another step. After turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. The method uses the same synchronous snapshot-and-validation boundary as `send` and delegates to `send` when idle - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). -- `agent.cancel(cause?)` — cancel ALL pending work: an omitted cause means `{ kind: 'user' }`; the exact `user | parent` object is validated, detached, and frozen before queues are cleared and the current turn's shared signal is aborted. Invalid causes throw synchronously, repeated active-turn cancellation is first-wins, and idle cancellation is a safe no-op that does not arm the next turn. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. +- `agent.cancel(cause?)` — cancel ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins, and idle cancellation is a safe no-op that does not arm the next turn. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id` diff --git a/packages/core/agent/src/cancellation.ts b/packages/core/agent/src/cancellation.ts index 75a099f1c9..708009b456 100644 --- a/packages/core/agent/src/cancellation.ts +++ b/packages/core/agent/src/cancellation.ts @@ -1,35 +1,6 @@ -/** Public normalization helpers for explicit turn cancellation. @module @deepseek-ai/dsh-agent/cancellation */ +/** Runtime reason inspection for explicit turn cancellation. @module @deepseek-ai/dsh-agent/cancellation */ -import type { AgentCancelCause, AgentInterruptReason } from './types.ts' - -/** - * Validate and detach a caller-supplied Agent cancellation cause. - * @param value - the candidate cancellation cause. - * @returns a fresh frozen cause suitable for the current turn signal. - * @throws {TypeError} when the value is not an exact supported cause. - */ -export function normalizeAgentCancelCause(value: unknown): AgentCancelCause { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new TypeError('agent cancel cause must be an exact plain object with kind "user" or "parent"') - } - const prototype = Object.getPrototypeOf(value) as unknown - if (prototype !== Object.prototype && prototype !== null) { - throw new TypeError('agent cancel cause must be an exact plain object with kind "user" or "parent"') - } - const keys = Reflect.ownKeys(value) - if (keys.length !== 1 || keys[0] !== 'kind') { - throw new TypeError('agent cancel cause must contain exactly one field: kind') - } - const kind = (value as { readonly kind?: unknown }).kind - switch (kind) { - case 'user': - return Object.freeze({ kind: 'user' }) - case 'parent': - return Object.freeze({ kind: 'parent' }) - default: - throw new TypeError(`unsupported agent cancel cause kind: ${String(kind)}`) - } -} +import type { AgentInterruptReason } from './types.ts' /** * Read a supported agent interruption from an explicitly supplied signal. @@ -41,19 +12,19 @@ export function normalizeAgentCancelCause(value: unknown): AgentCancelCause { export function agentInterruptReasonOf(signal: AbortSignal): AgentInterruptReason | undefined { if (!signal.aborted) return undefined const reason: unknown = signal.reason - if (typeof reason === 'object' && reason !== null && !Array.isArray(reason)) { - const prototype = Object.getPrototypeOf(reason) as unknown - const keys = Reflect.ownKeys(reason) - if ((prototype === Object.prototype || prototype === null) - && keys.length === 1 && keys[0] === 'kind' - && (reason as { readonly kind?: unknown }).kind === 'disposed') { + if (typeof reason !== 'object' || reason === null || Array.isArray(reason)) return undefined + const prototype = Object.getPrototypeOf(reason) as unknown + const keys = Reflect.ownKeys(reason) + if ((prototype !== Object.prototype && prototype !== null) + || keys.length !== 1 || keys[0] !== 'kind') return undefined + switch ((reason as { readonly kind?: unknown }).kind) { + case 'user': + return Object.freeze({ kind: 'user' }) + case 'parent': + return Object.freeze({ kind: 'parent' }) + case 'disposed': return Object.freeze({ kind: 'disposed' }) - } - } - try { - return normalizeAgentCancelCause(reason) - } catch (error: unknown) { - if (error instanceof TypeError) return undefined - throw error + default: + return undefined } } diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 506e630f82..2f5b525525 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -15,7 +15,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' -export { agentInterruptReasonOf, normalizeAgentCancelCause } from './cancellation.ts' +export { agentInterruptReasonOf } from './cancellation.ts' export { agentEvents, assembleContextFor } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index e1258af3ac..15e3316a33 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -132,8 +132,8 @@ export interface Agent { * Clear all queued and steering work, including items 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. + * `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm a later + * cancel. The active turn snapshots and freezes the typed cause. * @param cause - the stable caller intent carried by the current turn signal. */ cancel(cause?: AgentCancelCause): void diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index b36c92c73f..5e3336b3d4 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -5,10 +5,9 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, agentInterruptReasonOf, - normalizeAgentCancelCause, } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' function stubAgent(rawId: string): Agent { const id = SessionId(rawId) @@ -187,41 +186,21 @@ describe('agentEvents()', () => { }) describe('explicit cancellation helpers', () => { - it('normalizes exact causes into detached frozen values', () => { - const user = { kind: 'user' as const } - const parent = Object.assign(Object.create(null) as object, { kind: 'parent' }) - - const normalizedUser = normalizeAgentCancelCause(user) - const normalizedParent = normalizeAgentCancelCause(parent) - - expect(normalizedUser).toEqual({ kind: 'user' }) - expect(normalizedUser).not.toBe(user) - expect(Object.isFrozen(normalizedUser)).toBe(true) - expect(normalizedParent).toEqual({ kind: 'parent' }) - expect(Object.getPrototypeOf(normalizedParent)).toBe(Object.prototype) - expect(Object.isFrozen(normalizedParent)).toBe(true) - }) - - it.each([ - undefined, - null, - 'user', - [], - new Error('user'), - { kind: 'user', detail: true }, - Object.assign({ kind: 'user' }, { [Symbol('extra')]: true }), - { kind: 'timeout' }, - ])('rejects unsupported cancellation cause %#', (cause) => { - expect(() => normalizeAgentCancelCause(cause)).toThrow(TypeError) + it('exposes the closed typed cancellation cause at the Agent seam', () => { + expectTypeOf[0]>().toEqualTypeOf() }) it('reads only supported reasons from an explicit signal', () => { + const read = (reason: unknown) => { + const controller = new AbortController() + controller.abort(reason) + return agentInterruptReasonOf(controller.signal) + } const live = new AbortController() expect(agentInterruptReasonOf(live.signal)).toBeUndefined() - const user = new AbortController() - user.abort({ kind: 'user' }) - expect(agentInterruptReasonOf(user.signal)).toEqual({ kind: 'user' }) + expect(read({ kind: 'user' })).toEqual({ kind: 'user' }) + expect(read({ kind: 'parent' })).toEqual({ kind: 'parent' }) const disposed = new AbortController() disposed.abort(Object.assign(Object.create(null) as object, { kind: 'disposed' })) @@ -229,29 +208,13 @@ describe('explicit cancellation helpers', () => { expect(disposedReason).toEqual({ kind: 'disposed' }) expect(Object.isFrozen(disposedReason)).toBe(true) - const unsupported = new AbortController() - unsupported.abort(new Error('private runtime reason')) - expect(agentInterruptReasonOf(unsupported.signal)).toBeUndefined() - - const primitive = new AbortController() - primitive.abort('private runtime reason') - expect(agentInterruptReasonOf(primitive.signal)).toBeUndefined() - }) - - it('does not swallow non-validation failures while reading a cause', () => { - let reads = 0 - const reason = Object.defineProperty({}, 'kind', { - enumerable: true, - get() { - reads += 1 - if (reads === 1) return 'user' - throw new Error('kind getter failed') - }, - }) - const controller = new AbortController() - controller.abort(reason) - - expect(() => agentInterruptReasonOf(controller.signal)).toThrow('kind getter failed') + expect(read(null)).toBeUndefined() + expect(read([])).toBeUndefined() + expect(read('private runtime reason')).toBeUndefined() + expect(read(new Error('private runtime reason'))).toBeUndefined() + expect(read({ kind: 'user', detail: true })).toBeUndefined() + expect(read({ other: 'user' })).toBeUndefined() + expect(read({ kind: 'timeout' })).toBeUndefined() }) }) diff --git a/packages/core/session/README.md b/packages/core/session/README.md index df635f0085..f848909444 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -80,7 +80,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Extension points - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. -- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model and assistant messages require provider/model provenance. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage. +- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model, assistant messages require provider/model provenance, and a coarse aborted outcome must contain only `{ kind: 'aborted' }` (legacy reason-bearing records are rejected). `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage. - Compaction: `dsh-compact-basic` appends a `user/message` replacement for summary checkpoints, while `dsh-compact-tool-result-prune` appends a content-only `tool/result` replacement. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership, replacement validation, and `replaceGeneration`. ## Model Experience diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 9b2eb74a37..4a8963bb4c 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -137,6 +137,7 @@ function assertSessionEventEnvelope(value: Record, index: numbe throw new Error(`seed event at index ${index} has an invalid event envelope`) } assertCurrentLlmShape(event, index) + assertCurrentTurnEndShape(event, index) } /** Reject pre-provider request headers and assistant messages at the seed/load boundary. */ @@ -154,6 +155,22 @@ function assertCurrentLlmShape(event: Record, index: number): v } } +/** Reject legacy aborted outcomes that persisted caller-owned reason detail. */ +function assertCurrentTurnEndShape(event: Record, index: number): void { + if (event['type'] !== 'turn/end') return + const data = event['data'] + /* v8 ignore next -- this migration recognizes only the legacy object shape; format-wide payload validation is separate. */ + if (typeof data !== 'object' || data === null) return + const reason = (data as Record)['reason'] + /* v8 ignore next -- non-object reasons cannot carry the legacy aborted detail this migration removes. */ + if (typeof reason !== 'object' || reason === null || Array.isArray(reason)) return + const record = reason as Record + if (record['kind'] === 'aborted' + && (Object.keys(record).length !== 1 || !Object.hasOwn(record, 'kind'))) { + throw new Error(`seed turn/end at index ${index} uses unsupported reason-bearing aborted format`) + } +} + /** Whether an unknown value carries the current provider/model pair. */ function hasProviderModel(value: unknown): boolean { if (typeof value !== 'object' || value === null) return false diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index fa0afc20e1..0aef29b9a9 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -58,6 +58,22 @@ describe('Session', () => { expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) }) + it('rejects legacy reason-bearing aborted outcomes at the seed/load boundary', () => { + const legacy = [ + { + type: 'turn/start', seq: 0, time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }, + { + type: 'turn/end', seq: 1, time: 2, + data: { turn: 1, reason: { kind: 'aborted', reason: 'legacy cancellation detail' } }, + }, + ] as unknown as SessionEvent[] + + expect(() => new Session(SessionId('legacy-aborted'), legacy)) + .toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format') + }) + it('renders context and steering messages as plain user content', () => { const session = new Session(SessionId('s2')) session.append('context/message', { diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index cae3f5e6b6..42a642caf0 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -18,7 +18,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud ## Primitives - **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws). -- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations. +- **`runHook(bash, hook, options, now)`** — require and forward the caller-owned `options.signal`, serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Cancellation therefore reaches the executor's process-group kill and join boundary. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations. - **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge. - **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order. - **`createDetachedRuns()`** — quiescence tracking for the emit-shaped points, which run detached (no seam awaits them). The bridge tracks each run chain — the hook run PLUS its continuation — and registers `drain()` as its effect disposer: drain fires the tracker's abort `signal` (so a still-running hook process is killed via `runHook`, not awaited out to its timeout), then resolves once every tracked chain has settled. `fiber.dispose()` resolving therefore means no detached hook work is left to fire into a disposed context ([defensive patterns](../../../docs/defensive-patterns.md): dispose must reach quiescence). diff --git a/packages/hooks/hook-protocol/src/runner.ts b/packages/hooks/hook-protocol/src/runner.ts index 1e7de698d0..802022085e 100644 --- a/packages/hooks/hook-protocol/src/runner.ts +++ b/packages/hooks/hook-protocol/src/runner.ts @@ -28,7 +28,7 @@ export interface RunHookOptions { /** Working directory for the hook (defaults to the executor's own default when omitted). */ cwd?: string /** Explicit owning-operation signal; firing it cancels the hook run. */ - signal?: AbortSignal + readonly signal: AbortSignal /** Whether to append a trailing newline to the stdin payload (CC yes, Codex no). */ trailingNewline: boolean /** @@ -78,9 +78,9 @@ export async function runHook( command: hook.command, timeoutMs, stdin, + signal: options.signal, ...options.cwd !== undefined ? { workdir: options.cwd } : {}, ...options.env !== undefined ? { env: options.env } : {}, - ...options.signal ? { signal: options.signal } : {}, } try { diff --git a/packages/hooks/hook-protocol/tests/runner.spec.ts b/packages/hooks/hook-protocol/tests/runner.spec.ts index c2990e10be..09e0e65275 100644 --- a/packages/hooks/hook-protocol/tests/runner.spec.ts +++ b/packages/hooks/hook-protocol/tests/runner.spec.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, expectTypeOf, it } from 'vitest' import type { BashExecRequest, BashExecSpec, BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash' import { DEFAULT_HOOK_TIMEOUT_MS, runHook } from '@deepseek-ai/dsh-hook-protocol' +import type { RunHookOptions } from '@deepseek-ai/dsh-hook-protocol' /** * A minimal stand-in for the bits of {@link BashExecutor} that {@link runHook} @@ -51,12 +52,18 @@ function result(over: Partial = {}): BashRunResult { } const clock = () => { let t = 0; return () => (t += 5) } // +5ms per call → duration 5 +const testSignal = (): AbortSignal => new AbortController().signal describe('runHook — payload + env + stdin plumbing', () => { + it('requires an explicit caller-owned abort signal', () => { + expectTypeOf().toEqualTypeOf() + }) + it('serializes the payload to stdin (with trailing newline when requested)', async () => { const { bash, specs } = recordingBash(async () => result({ stdout: { text: '', truncated: false } })) await runHook(bash, { command: 'my-hook.sh' }, { payload: { hook_event_name: 'PreToolUse', tool_name: 'Bash' }, + signal: testSignal(), defaultTimeoutMs: 60000, trailingNewline: true, }, clock()) @@ -66,14 +73,14 @@ describe('runHook — payload + env + stdin plumbing', () => { it('omits the trailing newline when trailingNewline is false (Codex)', async () => { const { bash, specs } = recordingBash(async () => result()) - await runHook(bash, { command: 'h' }, { payload: { a: 1 }, defaultTimeoutMs: 1000, trailingNewline: false }, clock()) + await runHook(bash, { command: 'h' }, { payload: { a: 1 }, signal: testSignal(), defaultTimeoutMs: 1000, trailingNewline: false }, clock()) expect(specs[0]!.stdin).toBe('{"a":1}') }) it('threads env and cwd into the request', async () => { const { bash, specs } = recordingBash(async () => result()) await runHook(bash, { command: 'h' }, { - payload: {}, env: { CLAUDE_PROJECT_DIR: '/proj' }, cwd: '/work', + payload: {}, env: { CLAUDE_PROJECT_DIR: '/proj' }, cwd: '/work', signal: testSignal(), defaultTimeoutMs: 1000, trailingNewline: true, }, clock()) expect(specs[0]!.env).toEqual({ CLAUDE_PROJECT_DIR: '/proj' }) @@ -82,13 +89,13 @@ describe('runHook — payload + env + stdin plumbing', () => { it('a per-hook timeoutSec (seconds) overrides the default (ms)', async () => { const { bash, specs } = recordingBash(async () => result()) - await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock()) + await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, signal: testSignal(), defaultTimeoutMs: 60000, trailingNewline: true }, clock()) expect(specs[0]!.timeoutMs).toBe(3000) }) it('falls back to the default timeout when the hook sets none', async () => { const { bash, specs } = recordingBash(async () => result()) - await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock()) + await runHook(bash, { command: 'h' }, { payload: {}, signal: testSignal(), defaultTimeoutMs: 60000, trailingNewline: true }, clock()) expect(specs[0]!.timeoutMs).toBe(60000) expect(DEFAULT_HOOK_TIMEOUT_MS).toBe(600_000) // the CC/Codex reference default (10 minutes) }) @@ -106,7 +113,7 @@ describe('runHook — outcome decoding + duration', () => { const { bash } = recordingBash(async () => result({ exitCode: 0, stdout: { text: JSON.stringify({ decision: 'block', reason: 'no' }), truncated: false }, })) - const { output, durationMs } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + const { output, durationMs } = await runHook(bash, { command: 'h' }, { payload: {}, signal: testSignal(), defaultTimeoutMs: 1000, trailingNewline: true }, clock()) expect(output.decision).toBe('block') expect(output.reason).toBe('no') expect(durationMs).toBe(5) @@ -114,7 +121,7 @@ describe('runHook — outcome decoding + duration', () => { it('a signal death (exitCode null) decodes as undefined exit (non-blocking error)', async () => { const { bash } = recordingBash(async () => result({ exitCode: null, signal: 'SIGKILL', stderr: { text: 'killed', truncated: false } })) - const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + const { output } = await runHook(bash, { command: 'h' }, { payload: {}, signal: testSignal(), defaultTimeoutMs: 1000, trailingNewline: true }, clock()) expect(output.exitCode).toBeUndefined() expect(output.decision).toBeUndefined() expect(output.stderr).toBe('killed') @@ -122,7 +129,7 @@ describe('runHook — outcome decoding + duration', () => { it('an executor rejection (infra fault) becomes a non-blocking error, never throws', async () => { const { bash } = recordingBash(async () => { throw new Error('bad workdir: ENOENT') }) - const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + const { output } = await runHook(bash, { command: 'h' }, { payload: {}, signal: testSignal(), defaultTimeoutMs: 1000, trailingNewline: true }, clock()) expect(output.exitCode).toBeUndefined() expect(output.stderr).toBe('bad workdir: ENOENT') expect(output.decision).toBeUndefined() @@ -130,7 +137,7 @@ describe('runHook — outcome decoding + duration', () => { it('a non-Error rejection is stringified onto stderr', async () => { const { bash } = recordingBash(async () => { throw 'plain string fault' }) - const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + const { output } = await runHook(bash, { command: 'h' }, { payload: {}, signal: testSignal(), defaultTimeoutMs: 1000, trailingNewline: true }, clock()) expect(output.stderr).toBe('plain string fault') }) @@ -140,7 +147,7 @@ describe('runHook — outcome decoding + duration', () => { stdout: { text: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } }), truncated: false }, })) const { output } = await runHook(bash, { command: 'h' }, { - payload: {}, defaultTimeoutMs: 1000, trailingNewline: true, expectedEventName: 'Stop', + payload: {}, signal: testSignal(), defaultTimeoutMs: 1000, trailingNewline: true, expectedEventName: 'Stop', }, clock()) // A PreToolUse block on a Stop hook is malformed → its decision is discarded. expect(output.hookEventName).toBe('PreToolUse') diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 2cb4cb0bc5..684650104a 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -105,6 +105,32 @@ describe('hooks-codex bridge', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal') }) + it('turn cancellation aborts and reaps a running UserPromptSubmit hook before idle', async () => { + const dir = configDir() + const pidFile = join(dir, 'pid') + const marker = join(dir, 'started') + const slow = script(dir, 'slow-prompt.sh', `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`) + writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: slow }] }] }) + + const adapter = new MockAdapter([textResponse('must not run')]) + const ctx = await harness(dir, adapter) + const agent = ctx.agentLoop.create(SessionId('cancel-prompt-hook'), { provider: 'mock', model: 'mock' }) + agent.send([{ type: 'text', text: 'cancel the hook' }]) + await waitFor(() => existsSync(marker)) + const pid = Number(readFileSync(pidFile, 'utf8').trim()) + + const idle = agent.whenIdle() + agent.cancel({ kind: 'user' }) + await idle + + expect(() => process.kill(pid, 0)).toThrow() + expect(adapter.requests).toHaveLength(0) + expect(events(agent).findLast(event => event.type === 'turn/end')).toMatchObject({ + data: { reason: { kind: 'aborted' } }, + }) + expect(events(agent).some(event => event.type === 'hook/result' && event.data.point === 'UserPromptSubmit')).toBe(true) + }) + it('only the five bridge-supported Codex events are honored — a SubagentStop entry is ignored', async () => { const dir = configDir() const s = script(dir, 'x.sh', '#!/usr/bin/env bash\nexit 2\n')