DSH / Atlas
2026-07-16implementedarchitecture

Explicit turn cancellation capability

显式轮次取消能力

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 [initiating Agent scope d

English

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 initiating Agent scope decision intentionally carries only the exact Agent through AsyncLocalStorage. Adding turn, step, or signal state to that driver-lifetime boundary 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. TypeScript enforces that vocabulary at this typed same-process boundary, with no runtime validator, fallback, or special compatibility contract for untyped callers. An active TurnCancellation copies the typed discriminant into a fresh frozen signal reason; idle cancellation has no holder to mutate and does not arm later work.

An interrupted live turn ends with the coarse durable { kind: 'aborted' } outcome. The terminal event records what happened to the turn, while the runtime signal identifies who requested cancellation; it does not duplicate user or parent into replay. Session seed/load rejects legacy aborted records with a reason or any other extra field, so replay cannot reintroduce caller-owned cancellation detail. The process-local agent/cancel-requested notification is not durable; a future audit requirement uses a separate durable 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 inbox claim, agent/pre-step, prompt assembly, every step, model and tool execution, and agent/turn-stopping, then clears the exact holder immediately before publishing turn/end. Terminal event observers and the following durability flush therefore cannot cancel already-completed turn work even though driver status may remain running until the flush settles. Every participating method, event, and request value receives that same explicit signal; the next turn receives a fresh signal.

The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective cancel() emits the observe-only agent/cancel-requested notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer is latched and runs when the aborted activity converges to idle — a disposed cancel leaves it parked (cancel-convergence wake latch). 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 pass a single payload object: agent-scoped events carry agent and signal in the payload with next last, and the remaining APIs keep signal immediately before a waterfall's final next. PreStepContext and RequestFailureContext are retired, with their fields folded into the agent/pre-step and agent/request-error payloads (payload-object events). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. 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. 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.

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 the typed caller union, frozen detachment, default and first-wins behavior, the coarse Session JSON round trip and legacy-record rejection, ACP user, in-process subagent parent, and disposal precedence. Loop tests make cooperative listeners wait on the signal at pre-step, system-prompt assembly, request, model stream, request-error recovery, tool execution, and turn stopping; they assert one signal within a turn, a fresh signal across turns, and no cancellation authority during terminal publication or a blocked durability flush. A real hook bridge test cancels and reaps a blocked prompt hook before idle.

Initiator-scope tests assert that every hook still observes the exact Agent and no ambient turn signal, concurrent Agents retain independent identities and signals, and a nested child driver shadows only identity. Race tests cover idle cancellation, pre-run cancellation, replacement submission from a running listener, repeated cancellation, and cancel-versus-dispose quiescence.

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 ambient state, so the initiator scope continues to carry only the 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 trail 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 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 boundary.

Consequences

Cancellation has one runtime owner, one signal per live turn, and one typed runtime caller vocabulary. Session retains the coarse aborted outcome that its consumers actually use, rejects reason-bearing legacy forms, and stays isolated from runtime objects. Cooperative cancellation reaches every asynchronous turn extension point, including work before the first step and after the last one, while terminal publication and persistence remain outside its authority.

The explicit signal adds parameters to several public events and requires plugins to forward cancellation deliberately. This is intentional: authority is visible at the call boundary, lifetime matches the turn, and stale ambient descendants cannot acquire control. Uncooperative in-process work may delay cancellation, but the reported quiescent state remains truthful.

中文

问题

取消是一种生命周期短于 Agent(智能体)驱动器的控制能力。自由文本字符串无法完整区分所有调用方,步骤级控制器也无法中断提示词提交、提示词组装、继续决策或轮次终止策略。持久化 ErrorAbortSignal.reason 或后端私有对象还会向持久化回放暴露不稳定的运行时细节。

发起 Agent 作用域决策有意让 AsyncLocalStorage 只携带同一个 Agent。若把轮次、步骤或 signal 状态加入这个与驱动器同生命周期的边界,陈旧的异步后代就会看似仍对后续轮次拥有权限。因此,取消需要一个轮次归属方并显式传播,且不创建另一套环境上下文或公开的轮次包装层。

决策

Agent 拥有仅用于运行时的 AgentCancelCause 联合类型 { kind: 'user' } | { kind: 'parent' }agent.cancel() 默认使用 user。TypeScript 在这个类型化的同进程边界中强制执行该词汇,不提供运行时校验器、后备行为,也不为无类型调用方提供特殊兼容性约定。活跃的 TurnCancellation 会把类型化判别字段复制为一个全新且已冻结的 signal 原因;空闲状态下没有可修改的持有者,也不会让后续工作预先进入取消状态。

正在运行的轮次被中断后,以粗粒度的持久化结果 { kind: 'aborted' } 结束。终态事件记录轮次发生了什么,运行时 signal 标识谁请求了取消;回放不会重复保存 userparent。会话 seed/load 会拒绝携带取消原因或任何其他额外字段的旧式中止记录,因此回放无法重新引入由调用方持有的取消细节。仅限进程内的 agent/cancel-requested 通知不会持久化;未来若有审计需求,应使用独立的持久化控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含调用栈、signal、错误对象、自由文本取消原因或后端私有细节。

AgentLoop 为每个待启动轮次私有地持有一个 TurnCancellation。它在通知 agent/status = running 前安装该持有者,使其中唯一的 AbortController 持续覆盖 inbox 领取、agent/pre-step、提示词组装、每个步骤、模型与工具执行以及 agent/turn-stopping;随后在发布 turn/end 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 running,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。

对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 cancel() 会先发出仅供观察的 agent/cancel-requested 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——disposed 取消则将其停放(取消收敛窗口唤醒锁存)。若 running 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。

显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 agentsignalnext 位于最后;其余 API 保持 signal 紧邻 waterfall(瀑布式事件)的最终 next 之前。PreStepContextRequestFailureContext 已退役,其字段并入 agent/pre-stepagent/request-error 的 payload(payload-object 事件)。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 RunHookOptions.signal,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。SystemPrompt.assemble()AssembleContext 中携带 signal?: AbortSignal,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。

ctx.agents 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 cancel() 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。

Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时的 { kind: 'disposed' } 中断。若取消已经先占用控制器的中断原因,该原因便无法改写,因此终态分类会先检查生命周期状态:资源释放结果优先,之后受支持的 userparent 取消原因形成粗粒度的中止结果,其他异常保留现有错误路径。ACP(Agent Client Protocol)取消映射为 user;进程内 spawn 和 fork 的传播映射为 parent。远程 ACP subagent 保持现有协议。

取消仍然是协作式的。AgentLoop 会在异步等待边界前后检查中断,但不会用 Promise.race 放弃进程内监听器、适配器或工具 Promise。忽略 signal 的工作必须真正结算,whenIdle()、句柄 dispose 和作用域清理才会报告完全停稳。

验证

约定测试验证类型化调用方联合类型、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP user、进程内 subagent parent 以及 dispose 优先级。AgentLoop 测试让协作式监听器在 pre-step、系统提示词组装、请求、模型流、请求错误恢复、工具执行和轮次停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。

发起方作用域测试断言所有钩子仍观察到同一个 Agent 且没有环境中的轮次 signal,并发 Agent 保持独立的身份与 signal,嵌套子驱动只遮蔽身份。竞态测试覆盖空闲状态取消、运行前取消、从 running 监听器提交替代提示词、重复取消以及取消与 dispose 竞争下的完全停稳。

考虑过的替代方案

把 signal 存入 ALS。 ALS 会在整个驱动器生命周期内跟随异步后代,而取消权限在一个轮次结束时就已终止。泄漏的回调可能观察到陈旧 signal,或者迫使实现使用可变的环境状态,因此发起方作用域继续只携带 Agent,控制能力继续显式传递。

持久化自由文本原因。 字符串允许拼写漂移、阻碍穷尽分支判断,还会鼓励消费方解析展示文本。运行时使用封闭的可辨识联合类型,终态记录只需要稳定的中止结果。

turn/end 中持久化类型化调用方取消原因。 当前没有任何生产环境中的回放、UI、ACP、遥测或工作流消费方区分 userparent。把请求来源复制到终态结果会混淆两项事实,还会在没有消费方的情况下引入会话特有校验;未来的审计记录可以包含独立的取消请求事件。

现在就定义推测性的 supersededtimeoutshutdown 变体。 当前没有 Agent 取消生产方实现这些语义。shutdown 已经属于生命周期 dispose;超时或替代只有在拥有明确归属策略和唯一终态含义时才应进入联合类型。

公开轮次或步骤上下文包装类型。 现有 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。

在宽限期后放弃不协作的工作。 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制边界。

后果

取消拥有一个运行时归属方、每个活跃轮次一个 signal,以及一套类型化的运行时调用方词汇。会话保留其消费方实际使用的粗粒度 aborted 结果,拒绝携带原因的旧式形式,并与运行时对象保持隔离。协作式取消覆盖每个异步轮次扩展点,包括第一个步骤之前和最后一个步骤之后的工作,而终态发布和持久化仍在其权限范围之外。

显式 signal 会给多个公开事件增加参数,并要求插件有意识地转发取消。这是有意设计:权限在调用边界可见,生命周期与轮次匹配,陈旧的环境异步后代无法获得控制能力。不协作的进程内工作可能延迟取消,但所报告的完全停稳仍然真实。