From 338da9f2e01cbf2373e30d0a5a484d1b8f306479 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sun, 26 Jul 2026 20:37:07 +0800 Subject: [PATCH] refactor(agent): fold agentInterruptReasonOf into loop-private slot invariants The public classifier existed to defend an exported reader against arbitrary signals, but its only production caller is the loop reading its own machine-private turn signal, where cancel() is the sole aborter and always writes one frozen canonical cause. Delete the export and its 15-line structural validation: settle() states the slot invariant with one cast, the boolean call sites ask signal.aborted directly, and the retry veto drops entirely because a requested window already implies a live signal (cancel() retires the window before aborting). The abort(reason) channel and first-wins semantics are unchanged; only the reader's publicness is gone, and with it the paranoia it required. --- ...07-16-explicit-turn-cancellation.i18n.yaml | 4 +-- .../2026-07-16-explicit-turn-cancellation.md | 2 +- ...026-07-16-explicit-turn-cancellation.zh.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.i18n.yaml | 4 +-- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/core.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 20 ++++++++----- packages/core/agent/src/cancellation.ts | 30 ------------------- packages/core/agent/src/index.ts | 1 - packages/core/agent/tests/agent.spec.ts | 30 +------------------ 11 files changed, 22 insertions(+), 77 deletions(-) delete mode 100644 packages/core/agent/src/cancellation.ts diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 158a78acd8..11c3d9b5a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-16-explicit-turn-cancellation.md: 7ac743221084e663294954bfd048ba7ef1114f60 -2026-07-16-explicit-turn-cancellation.zh.md: 3dca6339787ebef749c0d6a15609376ede994a97 +2026-07-16-explicit-turn-cancellation.md: 15085a1da2cf183bace9957a4bedb3ea466aa472 +2026-07-16-explicit-turn-cancellation.zh.md: e945b0fea51bdbfee38048573c643b0fb8ecb685 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index 7ac7432210..15085a1da2 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -22,7 +22,7 @@ The driver keeps only a cause-less pre-run marker for queued work cancelled befo The explicit event signatures keep their positional form and place `signal` immediately before a waterfall's final `next`. Prompt submission, request configuration, step-result processing, continuation, and terminal stop join the pre-existing explicit signal seams for pre-step, session prefix, model generation, tool execution, approval, and subagent or workflow requests. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. -`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority, and `agentInterruptReasonOf(signal)` reads only its explicit argument. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. +`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. 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. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index 3dca633978..e945b0fea5 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -22,7 +22,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它 显式事件签名保留位置参数形式,并把 `signal` 放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。提示词提交、请求配置、步骤结果处理、继续决策和终止停止加入已有的步骤前处理、会话前缀、模型生成、工具执行、审批以及 subagent 或工作流请求的显式 signal seam。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 -`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限,`agentInterruptReasonOf(signal)` 也只读取其显式参数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 +`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时的 `{ kind: 'disposed' }` 中断。若取消已经先占用控制器的中断原因,该原因便无法改写,因此终态分类会先检查生命周期状态:资源释放结果优先,之后受支持的 `user` 或 `parent` 取消原因形成粗粒度的中止结果,其他异常保留现有错误路径。ACP(Agent Client Protocol)取消映射为 `user`;进程内 spawn 和 fork 的传播映射为 `parent`。远程 ACP subagent 保持现有协议。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 86d990222f..bf3a0141e9 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:221`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:220`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index c2f5475259..a943828ac0 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -core.md: e6e289315be286f8651ca7e948f9efa08c2919aa -core.zh.md: 3f904e4b2d9f4bbe98dc8a7a48674b3e12e0f3b3 +core.md: 3c4742253c884536ffde1df34ffc35d10369b92e +core.zh.md: 23398beb9fad565648eeaee4c8ec2b386117728a diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index e6e289315b..3c4742253c 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -534,7 +534,7 @@ interface Agent { `AgentStatus` is `'idle' | 'running'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `AgentOptions` is merge-extensible: core declares `provider?` and `model?` (dispatch requires both after `agent/request`). Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. -The cause is a TypeScript-enforced same-process input. An active `TurnCancellation` holder copies its discriminant into the runtime-only `AbortSignal.reason` and is retired before `turn/end` publication; the frozen `AbortSignal.reason` remains readable after that retirement. `agentInterruptReasonOf(signal)` recognizes `user`, `parent`, and lifecycle-only `disposed` without consulting ambient initiator state. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. +The cause is a TypeScript-enforced same-process input. An active `TurnCancellation` holder copies its discriminant into the runtime-only `AbortSignal.reason` and is retired before `turn/end` publication; the frozen `AbortSignal.reason` remains readable after that retirement. Only the loop reads the cause (`user`, `parent`, or lifecycle-only `disposed`) back off its own machine-private signal at settlement — there is no public reader, and a signal grants cooperating listeners no classification authority. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result. The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 3f904e4b2d..23398beb9f 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -542,7 +542,7 @@ interface Agent { `AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose(资源释放)会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。`AgentOptions` 可合并扩展:core 声明 `provider?` 与 `model?`(在 `agent/request` 后,分发要求两者都存在)。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。 -cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancellation` 持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`,并在发布 `turn/end` 前退役;冻结后的 `AbortSignal.reason` 仍可读取。`agentInterruptReasonOf(signal)` 无需查询环境中的 initiator 状态,即可识别 `user`、`parent` 与仅用于生命周期的 `disposed`。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 +cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancellation` 持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`,并在发布 `turn/end` 前退役;冻结后的 `AbortSignal.reason` 仍可读取。只有 loop 会在结算时从自己机器私有的 signal 上读回 cause(`user`、`parent` 或仅用于生命周期的 `disposed`)——不存在公开的读取器,signal 也不授予协作监听器任何分类权限。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。 [事件分类](../architecture.md#event)拥有 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)契约。轮次和步骤边界是持久会话事件,而不是 agent emit。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 05193b7180..33d96ae502 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -9,7 +9,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import { AgentMessageId, agentCarrier, agentInterruptReasonOf, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' +import { AgentMessageId, agentCarrier, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import type { @@ -234,7 +234,7 @@ export class ReactLoopAgent implements Agent { } } } catch (error: unknown) { - if (agentInterruptReasonOf(signal) === undefined) { + if (!signal.aborted) { this.loopCtx.logger.warn(`agent "${this.id}": prompt admission failed: ${errorChain(error)}`) } } @@ -317,7 +317,7 @@ export class ReactLoopAgent implements Agent { // and before its own step/end, so the step is always open here. this.stepOpen = false this.session.append('step/end', { turn, step }) - if (agentInterruptReasonOf(signal) === undefined) { + if (!signal.aborted) { const retryWindow = { requested: false } this.retryWindow = retryWindow let recoveryCompleted = false @@ -338,9 +338,10 @@ export class ReactLoopAgent implements Agent { // start, so unconditional retirement is exact. this.retryWindow = undefined } - retry = recoveryCompleted - && agentInterruptReasonOf(signal) === undefined - && retryWindow.requested + // A requested retry implies the signal is still live: cancel() + // retires the window before it aborts, and retry() refuses to + // arm a window whose signal already aborted. + retry = recoveryCompleted && retryWindow.requested } const settlement = this.settle(turn, step, outcome.error, signal, outcome.failure) reason = settlement.reason @@ -587,8 +588,11 @@ export class ReactLoopAgent implements Agent { signal: AbortSignal, failure?: LlmFailure, ): { reason: TurnEndReason; idle: IdleReason } { - const interrupt = agentInterruptReasonOf(signal) - if (interrupt !== undefined) { + if (signal.aborted) { + // Slot invariant, stated rather than re-validated: the turn controller + // is machine-private and cancel() is its only aborter, always with one + // frozen canonical cause as the reason. + const interrupt = signal.reason as AgentInterruptReason return { reason: { kind: interrupt.kind === 'disposed' ? 'disposed' : 'aborted' }, idle: { kind: 'aborted' } } } if (failure !== undefined) { diff --git a/packages/core/agent/src/cancellation.ts b/packages/core/agent/src/cancellation.ts deleted file mode 100644 index 708009b456..0000000000 --- a/packages/core/agent/src/cancellation.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** Runtime reason inspection for explicit turn cancellation. @module @deepseek-ai/dsh-agent/cancellation */ - -import type { AgentInterruptReason } from './types.ts' - -/** - * Read a supported agent interruption from an explicitly supplied signal. - * Unknown reasons return `undefined`; ambient initiator identity does not grant - * cancellation authority. - * @param signal - the current turn's explicit control signal. - * @returns its canonical reason, or `undefined` while live or unsupported. - */ -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)) return undefined - const prototype = Object.getPrototypeOf(reason) as unknown - const keys = Reflect.ownKeys(reason) - if ((prototype !== Object.prototype && prototype !== null) - || keys.length !== 1 || keys[0] !== 'kind') return undefined - switch ((reason as { readonly kind?: unknown }).kind) { - case 'user': - return Object.freeze({ kind: 'user' }) - case 'parent': - return Object.freeze({ kind: 'parent' }) - case 'disposed': - return Object.freeze({ kind: 'disposed' }) - default: - return undefined - } -} diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 61baa595f4..5942759a17 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -15,7 +15,6 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' -export { agentInterruptReasonOf } from './cancellation.ts' export * from './llm-target.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index c6311bb93a..fee3ace357 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -5,7 +5,6 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { AgentMessageId, agentEvents, - agentInterruptReasonOf, } from '@deepseek-ai/dsh-agent' import type { @@ -187,38 +186,11 @@ describe('agentEvents()', () => { }) }) -describe('explicit cancellation helpers', () => { +describe('explicit cancellation contract', () => { it('exposes the closed typed cancellation cause at the Agent seam', () => { expectTypeOf[0]>().toEqualTypeOf() expectTypeOf[1]>().toEqualTypeOf() }) - - it('reads only supported reasons from an explicit signal', () => { - const read = (reason: unknown) => { - const controller = new AbortController() - controller.abort(reason) - return agentInterruptReasonOf(controller.signal) - } - const live = new AbortController() - expect(agentInterruptReasonOf(live.signal)).toBeUndefined() - - expect(read({ kind: 'user' })).toEqual({ kind: 'user' }) - expect(read({ kind: 'parent' })).toEqual({ kind: 'parent' }) - - const disposed = new AbortController() - disposed.abort(Object.assign(Object.create(null) as object, { kind: 'disposed' })) - const disposedReason = agentInterruptReasonOf(disposed.signal) - expect(disposedReason).toEqual({ kind: 'disposed' }) - expect(Object.isFrozen(disposedReason)).toBe(true) - - expect(read(null)).toBeUndefined() - expect(read([])).toBeUndefined() - expect(read('private runtime reason')).toBeUndefined() - expect(read(new Error('private runtime reason'))).toBeUndefined() - expect(read({ kind: 'user', detail: true })).toBeUndefined() - expect(read({ other: 'user' })).toBeUndefined() - expect(read({ kind: 'timeout' })).toBeUndefined() - }) }) describe('AgentRegistry factory seam', () => {