diff --git a/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index c9de5dd06a..bac31649ab 100644 --- a/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-16-explicit-turn-cancellation.md: 3716895c145c24603a93dab99108489e19154490 -2026-07-16-explicit-turn-cancellation.zh.md: d2f8a4227d38a05d7ed1dd5135a20d3b4e94deed +2026-07-16-explicit-turn-cancellation.zh.md: 912f6ae7efcb5e0124c45e1a044b5cf140a6ab52 diff --git a/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index d2f8a4227d..912f6ae7ef 100644 --- a/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -1,4 +1,4 @@ -# RFC:显式的 turn 取消能力 +# RFC:显式轮次取消能力 Status: implemented @@ -6,50 +6,50 @@ Status: implemented ## 问题 -取消是一种生命周期短于 Agent 驱动的控制能力。自由文本字符串无法对调用方进行穷尽区分,步骤级 controller 也无法中断 prompt 提交、prompt 组装、continuation 或 turn 终止策略。持久化 `Error`、`AbortSignal.reason` 或后端私有对象还会把不稳定的运行时细节暴露给持久化 replay。 +取消是一种生命周期短于 Agent(智能体)驱动器的控制能力。自由文本字符串无法穷尽地区分调用方,步骤级控制器也无法中断提示词提交、提示词组装、继续决策或轮次终止策略。持久化 `Error`、`AbortSignal.reason` 或后端私有对象还会向持久化回放暴露不稳定的运行时细节。 -[Agent 执行上下文决策](2026-07-15-agent-execution-context.md)有意让 AsyncLocalStorage 帧保持为 `{ agent }`。若把 turn、步骤或 signal 状态加入这个与驱动同生命周期的帧,陈旧的异步后代就会看似仍对后续 turn 拥有权限。因此,取消需要一个 turn 归属方和显式传播,不能引入另一套环境上下文或公开 turn 包装类型。 +[Agent 执行上下文决策](2026-07-15-agent-execution-context.md)有意让 AsyncLocalStorage 帧保持为 `{ agent }`。若把轮次、步骤或 signal 状态加入这个与驱动器同生命周期的帧,陈旧的异步后代就会看似仍对后续轮次拥有权限。因此,取消需要一个轮次归属方并显式传播,且不创建另一套环境上下文或公开的轮次包装层。 ## 决策 -Agent 拥有仅用于运行时的 `AgentCancelCause` union:`{ kind: 'user' } | { kind: 'parent' }`;`agent.cancel()` 默认使用 `user`。规范化边界只接受恰好包含一个受支持 `kind` 的普通对象或 null-prototype 对象,并返回供当前 turn signal 使用的分离且冻结值。即使 Agent 处于 idle,字符串、额外字段或 symbol 字段、未知 kind、数组、class 实例、`Error` 和 `AbortSignal` 也会被同步拒绝。 +Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user' } | { kind: 'parent' }`;`agent.cancel()` 默认使用 `user`。规范化边界只接受恰好包含一个受支持 `kind` 的普通对象或原型为 null 的对象,并返回供当前轮次 signal 使用的、与调用方分离且已冻结的值。即使 Agent 处于空闲状态,字符串、额外字段或符号字段、未知 kind、数组、类实例、`Error` 和 `AbortSignal` 也会被同步拒绝。 -被中断的 live turn 以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录 turn 发生了什么,运行时 signal 则标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。未来若有审计需求,应使用独立的控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含 stack、signal、错误对象、自由文本取消原因或后端私有细节。 +正在运行的轮次被中断后,以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录轮次发生了什么,运行时 signal 标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。未来若有审计需求,应使用独立的控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含调用栈、signal、错误对象、自由文本取消原因或后端私有细节。 -AgentLoop 为每个预期 turn 私有地拥有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装 holder,使其中唯一的 `AbortController` 持续覆盖 prompt 处理、prompt 组装、每个步骤、模型与工具执行、continuation、`agent/turn-stop`、`turn/end` 和持久化 flush,随后清除 holder。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一 turn 会收到全新 signal。 +AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖提示词处理、提示词组装、每个步骤、模型与工具执行、继续决策、`agent/turn-stop`、`turn/end` 和持久化刷新,随后清除该持有者。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。 -对于 turn 被认领前取消的 queued work,驱动只保留一个不带 cause 的 pre-run marker。它会清除 `cancel()` 调用时已存在的 queued 和 steering work,但不会为未来 prompt 预设取消。若 `running` listener 同步取消旧工作并发送 replacement,驱动会丢弃已 aborted 的 holder,并为 replacement 创建全新 holder。同一 active holder 上的重复取消遵循 first-wins,后续调用仍可清除新进入队列的 pending work。 +对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。它会清除 `cancel()` 调用时已存在的排队工作和 steering(中途引导)工作,但不会预先取消未来的提示词。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 -显式事件签名保留 positional 形态,并把 `signal` 放在 waterfall 最后一个参数 `next` 之前。Prompt 提交、请求配置、步骤结果处理、continuation 和终止停止加入已有的 pre-step、session prefix、模型生成、工具执行、审批以及 subagent 或 workflow 请求显式 signal seam。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值。Listener 可以配合该 signal 取消,但不得保留它来控制另一 turn。 +显式事件签名保留位置参数形式,并把 `signal` 放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。提示词提交、请求配置、步骤结果处理、继续决策和终止停止加入已有的步骤前处理、会话前缀、模型生成、工具执行、审批以及 subagent 或工作流请求的显式 signal seam。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 -`ctx.agentExecution` 仍只提供身份。环境中的 Agent 并不代表存活、当前 turn 或取消权限,`agentInterruptReasonOf(signal)` 也只读取其显式参数。并发 Agent 会同时隔离各自的 ALS 身份和 turn signal;子 Agent 会遮蔽父 Agent 身份,而父请求 signal 仍通过 subagent seam 传递。 +`ctx.agentExecution` 仍只提供身份。环境中的 Agent 并不代表存活、当前轮次或取消权限,`agentInterruptReasonOf(signal)` 也只读取其显式参数。并发 Agent 会同时隔离各自的 ALS 身份和轮次 signal;子 Agent 会遮蔽父 Agent 身份,而父请求 signal 仍通过 subagent seam 传递。 -Agent dispose 会在 active holder 上请求仅用于运行时的 `{ kind: 'disposed' }` 中断。若取消已经先成为 controller reason,该 reason 无法改写,因此终态分类会先检查生命周期状态:disposed 优先,之后受支持的 `user` 或 `parent` cause 形成粗粒度 aborted 结果,其他异常保留现有 error 路径。ACP 取消映射为 `user`;进程内 spawn 和 fork 的传播映射为 `parent`。远程 ACP subagent 保持现有 wire protocol。 +Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时的 `{ kind: 'disposed' }` 中断。若取消已经先占用控制器的中断原因,该原因便无法改写,因此终态分类会先检查生命周期状态:资源释放结果优先,之后受支持的 `user` 或 `parent` 取消原因形成粗粒度的中止结果,其他异常保留现有错误路径。ACP(Agent Client Protocol)取消映射为 `user`;进程内 spawn 和 fork 的传播映射为 `parent`。远程 ACP subagent 保持现有协议。 -取消仍然是协作式的。Loop 会在 await 边界前后检查中断,但不会用 `Promise.race` 放弃进程内 listener、adapter 或工具 Promise。忽略 signal 的工作必须真正结算,`whenIdle()`、handle dispose 和 scope teardown 才会报告静止状态。 +取消仍然是协作式的。AgentLoop 会在异步等待边界前后检查中断,但不会用 `Promise.race` 放弃进程内监听器、适配器或工具 Promise。忽略 signal 的工作必须真正结算,`whenIdle()`、句柄 dispose 和作用域清理才会报告静止状态。 ## 验证 -契约测试验证严格的运行时 cause 校验、冻结分离、默认与 first-wins 行为、粗粒度 Session JSON 往返、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。Loop 测试让协作式 listener 在 prompt 提交、system-prompt 组装、session prefix、pre-step、请求、模型 stream、步骤结果、工具执行、continuation 和终止停止处等待 signal;并断言同一 turn 使用一个 signal,不同 turn 使用全新 signal。 +契约测试验证严格的运行时取消原因校验、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在提示词提交、系统提示词组装、会话前缀、步骤前处理、请求、模型流、步骤结果、工具执行、继续决策和终止停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal。 -执行上下文测试断言所有 hook 仍只观察到 `{ agent }`,并发 Agent 保持独立的身份与 signal,嵌套子 Agent 创建只遮蔽身份。竞态测试覆盖 idle 取消、pre-run 取消、从 `running` listener 提交 replacement、重复取消以及 cancel 与 dispose 竞争下的静止状态。 +执行上下文测试断言所有钩子仍只观察到 `{ agent }`,并发 Agent 保持独立的身份与 signal,嵌套子 Agent 创建只遮蔽身份。竞态测试覆盖空闲状态取消、运行前取消、从 `running` 监听器提交替代提示词、重复取消以及取消与 dispose 竞争下的静止状态。 ## 考虑过的替代方案 -**把 signal 存入 ALS。** ALS 会在整个驱动生命周期内跟随异步后代,而取消权限在一个 turn 结束时就已终止。泄漏的回调可能观察到陈旧 signal,或者迫使实现替换可变帧,因此身份帧保持 `{ agent }`,控制能力继续显式传递。 +**把 signal 存入 ALS。** ALS 会在整个驱动器生命周期内跟随异步后代,而取消权限在一个轮次结束时就已终止。泄漏的回调可能观察到陈旧 signal,或者迫使实现替换为可变帧,因此身份帧保持 `{ agent }`,控制能力继续显式传递。 -**持久化自由文本 reason。** 字符串允许拼写漂移、阻碍穷尽 switch,还会鼓励消费方解析展示文本。运行时使用封闭的 discriminated union,终态记录只需要稳定的 aborted 结果。 +**持久化自由文本原因。** 字符串允许拼写漂移、阻碍穷尽分支判断,还会鼓励消费方解析展示文本。运行时使用封闭的可辨识联合类型,终态记录只需要稳定的中止结果。 -**在 `turn/end` 中持久化类型化调用方 cause。** 当前没有任何生产环境中的 replay、UI、ACP、telemetry 或 workflow 消费方区分 `user` 与 `parent`。把请求来源复制到终态结果会混淆两项事实,还会在没有消费方的情况下引入 Session 特有校验;未来的审计接口可以记录独立的取消请求事件。 +**在 `turn/end` 中持久化类型化调用方取消原因。** 当前没有任何生产环境中的回放、UI、ACP、遥测或工作流消费方区分 `user` 与 `parent`。把请求来源复制到终态结果会混淆两项事实,还会在没有消费方的情况下引入会话特有校验;未来的审计接口可以记录独立的取消请求事件。 -**现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose;timeout 或 supersession 只有在拥有明确归属策略和唯一终态含义时才应进入 union。 +**现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose;超时或替代只有在拥有明确归属策略和唯一终态含义时才应进入联合类型。 -**公开 turn 或步骤 context 包装类型。** 现有 positional seam 已经标识 Agent、turn 和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。 +**公开轮次或步骤上下文包装类型。** 现有位置参数 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。 -**在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就返回 idle 会破坏 teardown 与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。 +**在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。 ## 后果 -取消拥有一个运行时归属方、每个 turn 一个 signal,以及一套类型化的运行时调用方词汇。Session 保留其消费方实际使用的粗粒度 `aborted` 结果,与运行时对象保持隔离,也不再需要取消专用的规范化逻辑。协作式取消覆盖每个异步 turn seam,包括第一个步骤之前和最后一个步骤之后的工作。 +取消拥有一个运行时归属方、每个轮次一个 signal,以及一套类型化的运行时调用方词汇。会话保留其消费方实际使用的粗粒度 `aborted` 结果,与运行时对象保持隔离,也不再需要取消专用的规范化逻辑。协作式取消覆盖每个异步轮次 seam,包括第一个步骤之前和最后一个步骤之后的工作。 -显式 signal 会给多个公开事件增加参数,并要求插件有意识地转发取消。这是有意设计:权限在调用边界可见,生命周期与 turn 匹配,陈旧的环境异步后代无法获得控制能力。不协作的进程内工作可能延迟取消,但所报告的静止状态仍然真实。 +显式 signal 会给多个公开事件增加参数,并要求插件有意识地转发取消。这是有意设计:权限在调用边界可见,生命周期与轮次匹配,陈旧的环境异步后代无法获得控制能力。不协作的进程内工作可能延迟取消,但所报告的静止状态仍然真实。 diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index ea371ad55a..43169f67ff 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -14,7 +14,7 @@ The canonical surface separates transformable policy, around-dispatch control, a **Agent events** (`dsh-agent`): - `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`. -- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below). +- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `signal` belongs to that turn and `next` remains the final parameter. `allow` optionally rewrites the prompt `content` or attaches `additionalContext`; `block` drops the prompt and the loop appends a durable `prompt/blocked` in its place (see the dispatch note below). **`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index b984e4bfa0..94a6193c36 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -276,12 +276,13 @@ export class ReactLoopAgent implements Agent { const active = this.turnCancellation if (active === undefined && !this.#inbox.hasQueued && !this.#inbox.hasSteering) return if (active === undefined) this.preRunCancelled = true - else active.request(accepted) // Drop all pending queued + steering work (un-started prompts never run; the - // cancelled turn's steering is not re-enqueued). Cleared directly even when - // the loop is parked in waitForQueued — there is no turn to stop and nothing - // left for the parked loop to run, so no wake is needed. + // cancelled turn's steering is not re-enqueued). Clear before abort dispatch, + // whose synchronous observers may enqueue replacement work that must survive. + // This is direct even when the loop is parked in waitForQueued — there is no + // turn to stop and nothing left for the parked loop to run, so no wake is needed. this.#inbox.clear() + if (active !== undefined) active.request(accepted) } /** diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 0dbac6fce6..2241717b94 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -124,6 +124,40 @@ describe('Agent.cancel()', () => { expect(reasons).toEqual([{ kind: 'aborted' }]) }) + it('keeps replacement work queued synchronously by an abort observer', async () => { + const adapter = new MockAdapter(['hang', textResponse('replacement reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(AgentId('abort-observer-replacement'), { model: 'mock' }) + + send(agent, 'original') + await expect.poll(() => adapter.requests.length).toBe(1) + const signal = adapter.requests[0]?.signal + if (signal === undefined) throw new Error('model request omitted its turn signal') + signal.addEventListener('abort', () => { send(agent, 'replacement') }, { once: true }) + const idle = waitForIdle(ctx, agent) + agent.cancel({ kind: 'user' }) + await Promise.race([ + idle, + new Promise((_resolve, reject) => { + setTimeout(() => { + reject(new Error(`replacement did not settle: ${JSON.stringify({ + status: agent.status, + requests: adapter.requests.length, + users: userTexts(agent), + events: agent.session.events.map(event => event.type), + })}`)) + }, 1000) + }), + ]) + + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['original', 'replacement']) + const reasons = agent.session.events + .filter(event => event.type === 'turn/end') + .map(event => event.type === 'turn/end' ? event.data.reason : undefined) + expect(reasons).toEqual([{ kind: 'aborted' }, { kind: 'completed' }]) + }) + it('cancel() with no cause defaults to user when aborting an active turn', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index bdbd6c6a11..e5726c22c0 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -44,7 +44,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content). - `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle - `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)) -- `agent.cancel(cause?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the active turn, and drops queued work not yet claimed by the driver. `AgentCancelCause` is the runtime-only `{ kind: 'user' } | { kind: 'parent' }`; omission means `user`, the first cause wins for an active turn, and ACP `session/cancel` maps to `user`. `normalizeAgentCancelCause()` provides the same strict detached-value boundary used by the concrete loop: validation is synchronous even while idle, accepts only an exact plain object, and returns a safe no-op when no work exists. +- `agent.cancel(cause?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the active turn, and drops queued work not yet claimed by the driver. `AgentCancelCause` is the runtime-only `{ kind: 'user' } | { kind: 'parent' }`; omission means `user`, the first cause wins for an active turn, and ACP `session/cancel` maps to `user`. `normalizeAgentCancelCause()` provides the same strict detached-value boundary used by the concrete loop: validation is synchronous even while idle, accepts only an exact plain object, and returns a frozen detached cause. After validation, `agent.cancel()` is a safe no-op when no work exists. - `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly. - `agent.session`, `agent.status`, `agent.options`, `agent.id`