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" },