Jelajahi Sumber

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.
_Kerman 2 bulan lalu
induk
melakukan
338da9f2e0

+ 2 - 2
.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

+ 1 - 1
.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.
 

+ 1 - 1
.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 保持现有协议。
 

+ 1 - 1
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`
 

+ 2 - 2
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

+ 1 - 1
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.
 

+ 1 - 1
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。
 

+ 12 - 8
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) {

+ 0 - 30
packages/core/agent/src/cancellation.ts

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

+ 0 - 1
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'

+ 1 - 29
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<Parameters<Agent['cancel']>[0]>().toEqualTypeOf<AgentCancelCause>()
     expectTypeOf<Parameters<Events['agent/cancel-requested']>[1]>().toEqualTypeOf<AgentCancelCause>()
   })
-
-  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', () => {