diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index ef5bb5847a..3d84c8074b 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/.agents/notes/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?)` -A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later accepted prompt remains an independent queued turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. +A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the active turn if any, and keeps a cause-less pre-run marker so a prompt cancelled before claim never runs while a later prompt remains independent. An effective call emits `agent/cancel-requested` with the typed `user | parent` cause before clearing or aborting; idle cancellation emits nothing and cannot strand the next prompt. `whenIdle()` reaches post-cancel quiescence, and ACP `session/cancel` maps to `user`. The [explicit turn-cancellation decision](2026-07-16-explicit-turn-cancellation.md) owns the current cause, signal-lifetime, and cooperative-settlement contract. ### 2. `AgentHandle` async disposer diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md index 040a6c2fdc..82da8347a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md @@ -50,9 +50,9 @@ The plugin is `@deepseek-ai/dsh-timeout-policy`, a zero-config function/namespac searchTimeoutMs: 30000 ``` -Timeouts live on tool definitions rather than a free-text name map, eliminating misspelled unused policy. `defineTool` validates a positive finite budget. During dispatch the enforcer derives a deadline signal, restores the caller signal afterward, and converts its own expiry into `TOOL_TIMEOUT`; tools without a budget pass through unchanged. +Timeouts live on tool definitions rather than a free-text name map, eliminating misspelled unused policy. `defineTool` validates a positive finite budget. During dispatch the enforcer derives a deadline signal and assigns it to `exec.signal`; the registry fuses that deadline with the original caller signal before the body under the [tool-cancellation contract](2026-07-19-cooperative-tool-cancellation.md). The enforcer restores the caller signal afterward and converts its own expiry into `TOOL_TIMEOUT`; tools without a budget pass through unchanged. -Signal replacement is by **in-place mutation of `exec.signal`**, not by passing a new object to `next()`. Cordis's waterfall `next()` ignores any arguments handed to it and re-invokes downstream listeners with the shared payload array (`vendor/cordis/src/events.ts`), so the documented cordis idiom — mutate the shared object, then delegate — is the only mechanism that reaches dispatch. The plugin restores `exec.signal` to the caller's original in a `finally` so `tools/post-execute` never sees this plugin's (possibly already-aborted) deadline signal. +Signal replacement is by **in-place mutation of `exec.signal`**, not by passing a new object to `next()`. Cordis's waterfall `next()` ignores any arguments handed to it and re-invokes downstream listeners with the shared payload array (`vendor/cordis/src/events.ts`), so mutation is how the wrapper supplies its deadline to the registry. The registry re-fuses the captured caller signal immediately before the body, and the plugin restores `exec.signal` to the caller's original in a `finally` so `tools/post-execute` never sees the plugin's deadline signal. `timeout-policy` owns both uses of the `TOOL_TIMEOUT` code: the internal deadline code passed to `deadline()`/`timeoutOf()` (scoped so a nested outer deadline reads as an ordinary cancel) and the structured tool-result error code. Its replacement result is: @@ -104,6 +104,6 @@ A future model-facing grep/glob tool can be implemented on top of `ctx.bash` wit - `@deepseek-ai/dsh-tools` gains an around-dispatch surface after the interception seams deliberately split pre/post tool hooks. Its contract is narrow — wrap registry dispatch, not replace the pre-gate or post-result policy — and the base `next()` is dispatch-with-normalization so a wrapper never sees a raw tool throw. - Multiple `tools/execute` listeners compose by ordinary Cordis waterfall order: a listener that calls `next()` wraps downstream listeners plus dispatch; one that returns without `next()` short-circuits them. A deployment combining timeout with a future retry/sandbox/metrics wrapper chooses semantics by registration order ("timeout covers the whole retry" vs "timeout covers each attempt"). -- Opt-in by declaration is a deliberate misconfiguration risk: a tool can declare a `timeoutMs` without honoring `exec.signal`, and that tool will not stop on timeout. The plugin contract states that declaring a budget means cooperative; the web tools prove the pattern on tools that already forward the signal. +- Opt-in by declaration is a deliberate misconfiguration risk: a tool can declare a `timeoutMs` without honoring `exec.signal`, and that tool will not stop on timeout. The registry awaits that non-quiescent body rather than racing it, while the plugin contract states that declaring a budget means cooperative; the web tools prove the pattern on tools that already forward the signal. - During the transition `bash` and the migrated web tools use different timeout paths on purpose: `TOOL_TIMEOUT` is the model-facing tool-call budget, while `BASH_TIMEOUT` remains the bash backend timeout used by bash and hooks. - Deviation from the literal proposal, recorded per the implemented-Agent Note rule: the plugin package is `@deepseek-ai/dsh-timeout-policy` (not `tool-timeout`), signal replacement is in-place `exec.signal` mutation before `next()` (not `next({ ...exec, signal })`, which cordis ignores), and the per-tool budget is declared on the `ToolDefinition` (`timeoutMs`, set by the owning tool plugin from its config) rather than mapped by tool name in this plugin's config — so the enforcer is zero-config and a mistyped tool name is impossible. All three are described in `## Decision` above. 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 new file mode 100644 index 0000000000..158a78acd8 --- /dev/null +++ b/.agents/notes/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: 7ac743221084e663294954bfd048ba7ef1114f60 +2026-07-16-explicit-turn-cancellation.zh.md: 3dca6339787ebef749c0d6a15609376ede994a97 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 new file mode 100644 index 0000000000..7ac7432210 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -0,0 +1,55 @@ +# Agent Note: 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 [initiating Agent scope decision](2026-07-15-agent-initiator-scope.md) 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 seam, 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 prompt processing, prompt assembly, every step, model and tool execution, continuation, and `agent/turn-stop`, 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 belongs to the next turn. 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. 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. + +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 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, 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 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 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 seam, 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. 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 new file mode 100644 index 0000000000..3dca633978 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -0,0 +1,55 @@ +# Agent Note:显式轮次取消能力 + +Status: implemented + +[English](2026-07-16-explicit-turn-cancellation.md) | 中文 + +## 问题 + +取消是一种生命周期短于 Agent(智能体)驱动器的控制能力。自由文本字符串无法穷尽地区分调用方,步骤级控制器也无法中断提示词提交、提示词组装、继续决策或轮次终止策略。持久化 `Error`、`AbortSignal.reason` 或后端私有对象还会向持久化回放暴露不稳定的运行时细节。 + +[发起 Agent 作用域决策](2026-07-15-agent-initiator-scope.md)有意让 AsyncLocalStorage 只携带同一个 Agent。若把轮次、步骤或 signal 状态加入这个与驱动器同生命周期的边界,陈旧的异步后代就会看似仍对后续轮次拥有权限。因此,取消需要一个轮次归属方并显式传播,且不创建另一套环境上下文或公开的轮次包装层。 + +## 决策 + +Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user' } | { kind: 'parent' }`;`agent.cancel()` 默认使用 `user`。TypeScript 在这份类型化同进程契约中强制执行该词汇,不提供运行时校验器、后备行为,也不为无类型调用方提供特殊兼容性契约。活跃的 `TurnCancellation` 会把类型化判别字段复制为一个全新且已冻结的 signal 原因;空闲状态下没有可修改的持有者,也不会让后续工作预先进入取消状态。 + +正在运行的轮次被中断后,以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录轮次发生了什么,运行时 signal 标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。Session seed/load 会拒绝携带取消原因或任何其他额外字段的旧式中止记录,因此回放无法重新引入由调用方持有的取消细节。仅限进程内的 `agent/cancel-requested` 通知不会持久化;未来若有审计需求,应使用独立的持久化控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含调用栈、signal、错误对象、自由文本取消原因或后端私有细节。 + +AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖提示词处理、提示词组装、每个步骤、模型与工具执行、继续决策和 `agent/turn-stop`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。 + +对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 + +显式事件签名保留位置参数形式,并把 `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 传递。 + +Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时的 `{ kind: 'disposed' }` 中断。若取消已经先占用控制器的中断原因,该原因便无法改写,因此终态分类会先检查生命周期状态:资源释放结果优先,之后受支持的 `user` 或 `parent` 取消原因形成粗粒度的中止结果,其他异常保留现有错误路径。ACP(Agent Client Protocol)取消映射为 `user`;进程内 spawn 和 fork 的传播映射为 `parent`。远程 ACP subagent 保持现有协议。 + +取消仍然是协作式的。AgentLoop 会在异步等待边界前后检查中断,但不会用 `Promise.race` 放弃进程内监听器、适配器或工具 Promise。忽略 signal 的工作必须真正结算,`whenIdle()`、句柄 dispose 和作用域清理才会报告静止状态。 + +## 验证 + +契约测试验证类型化调用方联合类型、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在提示词提交、系统提示词组装、会话前缀、步骤前处理、请求、模型流、步骤结果、工具执行、继续决策和终止停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。 + +发起方作用域测试断言所有钩子仍观察到同一个 Agent 且没有环境中的轮次 signal,并发 Agent 保持独立的身份与 signal,嵌套子驱动只遮蔽身份。竞态测试覆盖空闲状态取消、运行前取消、从 `running` 监听器提交替代提示词、重复取消以及取消与 dispose 竞争下的静止状态。 + +## 考虑过的替代方案 + +**把 signal 存入 ALS。** ALS 会在整个驱动器生命周期内跟随异步后代,而取消权限在一个轮次结束时就已终止。泄漏的回调可能观察到陈旧 signal,或者迫使实现使用可变的环境状态,因此发起方作用域继续只携带 Agent,控制能力继续显式传递。 + +**持久化自由文本原因。** 字符串允许拼写漂移、阻碍穷尽分支判断,还会鼓励消费方解析展示文本。运行时使用封闭的可辨识联合类型,终态记录只需要稳定的中止结果。 + +**在 `turn/end` 中持久化类型化调用方取消原因。** 当前没有任何生产环境中的回放、UI、ACP、遥测或工作流消费方区分 `user` 与 `parent`。把请求来源复制到终态结果会混淆两项事实,还会在没有消费方的情况下引入会话特有校验;未来的审计接口可以记录独立的取消请求事件。 + +**现在就定义推测性的 `superseded`、`timeout` 和 `shutdown` 变体。** 当前没有 Agent 取消生产方实现这些语义。`shutdown` 已经属于生命周期 dispose;超时或替代只有在拥有明确归属策略和唯一终态含义时才应进入联合类型。 + +**公开轮次或步骤上下文包装类型。** 现有位置参数 seam 已经标识 Agent、轮次和步骤。包装类型会加宽所有 API、重复归属,并诱导调用方把捕获的对象当成持久权限。 + +**在宽限期后放弃不协作的工作。** 同进程工作仍在运行时就报告空闲状态,会破坏资源清理与资源归属保证。硬终止需要 worker 或进程隔离边界,不属于该控制 seam。 + +## 后果 + +取消拥有一个运行时归属方、每个活跃轮次一个 signal,以及一套类型化的运行时调用方词汇。会话保留其消费方实际使用的粗粒度 `aborted` 结果,拒绝携带原因的旧式形式,并与运行时对象保持隔离。协作式取消覆盖每个异步轮次 seam,包括第一个步骤之前和最后一个步骤之后的工作,而终态发布和持久化仍在其权限范围之外。 + +显式 signal 会给多个公开事件增加参数,并要求插件有意识地转发取消。这是有意设计:权限在调用边界可见,生命周期与轮次匹配,陈旧的环境异步后代无法获得控制能力。不协作的进程内工作可能延迟取消,但所报告的静止状态仍然真实。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml new file mode 100644 index 0000000000..b27a40cc7f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-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-19-cooperative-tool-cancellation.md: 559012f10d41963698cc932727125de1b9ccfef7 +2026-07-19-cooperative-tool-cancellation.zh.md: 6af8e57349bba026ab22f257014c084c5c3c3f54 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md new file mode 100644 index 0000000000..559012f10d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md @@ -0,0 +1,73 @@ +# Agent Note: Cooperative tool cancellation at the registry boundary + +Status: implemented + +English | [中文](2026-07-19-cooperative-tool-cancellation.zh.md) + +## Problem + +Every typed tool invocation needs a caller-owned cancellation signal. An optional `ToolExecutionInput.signal` lets direct callers omit ownership, makes `exec.signal` optional in every tool body, and encourages registry fallbacks that cannot represent the caller's actual lifetime. + +The pipeline also has different mutability needs at different stages. Tool implementations, pre-policy, post-policy, and result observers only borrow cancellation state, while an around-dispatch wrapper must temporarily replace the signal to add a deadline or another lexical cancellation scope. One mutable public type either grants mutation too broadly or prevents that composition. + +Cancellation can arrive before policy, during approval, inside an around-dispatch wait, after a tool body starts, or while post-policy waits. One undifferentiated `ABORTED` result cannot tell durable consumers whether body side effects were possible. Racing a tool promise against cancellation is not a safe fallback because abandoned same-process work continues after the registry reports completion. + +## Decision + +`ToolExecutionInput.signal` is a required readonly `AbortSignal`. `ToolExecution.signal` and `ToolRunContext.signal` are therefore required and readonly as well. Every typed caller supplies the signal it owns; the registry provides no overload, default controller, never-abort sentinel, or convenience execution path. + +`ToolDefinition.execute(args, exec)` keeps its existing signature. `defineTool()` contextually types `exec.signal` as a required `AbortSignal`, so every registered TypeScript tool can observe or forward cancellation without a cast. First-party direct callers and nested Code Mode dispatches pass their current operation signal explicitly. + +The registry trusts this typed same-process contract. It does not perform runtime `AbortSignal` validation or add hostile-input tests for an omitted or malformed signal. Validation remains at parser/config, model/tool JSON, durable/file, worker, process, and wire boundaries; untyped JavaScript that violates the TypeScript interface has no compatibility contract. + +### Mutability follows the pipeline stage + +`ToolDispatchExecution` is identical to `ToolExecution` except that its required `signal` is mutable. Only the `tools/execute` waterfall receives this type. Pre-policy, post-policy, result observers, guards, and tool implementations receive readonly views of a private registry-owned mutable run object. + +An around-dispatch wrapper may replace `exec.signal` for its delegated lifetime but cannot typefully delete it or assign `undefined`. The registry captures the required caller signal outside that mutable object, fuses every wrapper replacement with the caller signal immediately before body invocation, removes dispatch-scoped listeners after settlement, and restores the required upstream signal unconditionally. + +### Cancellation codes record whether dispatch occurred + +`dsh-tools` exports `TOOL_ABORTED = 'ABORTED'` and `TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'`. The registry records body invocation immediately before calling `ToolDefinition.execute()`. + +`ABORTED_BEFORE_DISPATCH` carries `{ name: 'AbortError' }` and model text `Error: tool call aborted before dispatch`. It applies whenever cancellation prevents body invocation, including pre-aborted entry, cancellation during pre-policy or approval, an aborted wrapper signal, a wrapper success overtaken by caller cancellation before delegation, and agent-loop siblings skipped after turn cancellation. + +`ABORTED` carries model text `Error: tool call aborted` and applies only after the body was invoked, including cancellation while an around wrapper or post-policy listener waits after body completion. A denial, wrapper failure, tool failure, or post-policy failure remains more specific than generic cancellation. A timeout owned by timeout-policy remains `TOOL_TIMEOUT`, and contexts deferred before a successful outcome is replaced remain attached. + +### Pre-aborted entry short-circuits after materialization + +The registry first creates the call token and losslessly snapshots and freezes the arguments. A materialization failure wins even when the caller signal is already aborted. After successful materialization, a pre-aborted signal skips `tools/pre-execute`, approval, `tools/execute`, `tools/post-execute`, and the tool body, then publishes exactly one frozen authoritative `tools/result` with `ABORTED_BEFORE_DISPATCH`. + +### Started work still reaches quiescence + +Once a tool body starts, the registry awaits it. Cancellation reaches the body through the fused signal but never races or abandons its promise. A cooperative implementation stops or forwards cancellation and settles after its owned work reaches quiescence; an uncooperative same-process implementation can keep the registry pending indefinitely. Process, worker, network, and provider layers retain responsibility for their own termination mechanisms. + +This decision requires cancellation at the tool invocation seam only. Making signals required on asynchronous capabilities reachable from tool bodies is a separate migration proposed in [Required cancellation through tool-reachable capability seams](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md). + +## Verification + +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/timeout/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership. + +No registry test can prove that arbitrary third-party same-process code observes the signal or stops in bounded time. Capability tests continue to prove cancellation and quiescence at the boundary that owns each side effect. + +## Alternatives considered + +**Keep the signal optional and synthesize a fallback.** Rejected because a registry-owned fallback has no caller lifetime to represent and preserves the exact omission the type should prevent. + +**Validate `AbortSignal` at runtime.** Rejected because this is a typed same-process seam, not a serialization boundary. Runtime checks would duplicate the static contract without making cooperative use enforceable. + +**Add `supportsCancellation` metadata, callback-arity checks, or signal-use linting.** Rejected because none proves that asynchronous work observes or correctly forwards cancellation. Availability is a type contract; behavior remains a tool and capability responsibility. + +**Expose one mutable execution type to every stage.** Rejected because observers and tool implementations only borrow the signal. Stage-specific types make replacement possible only where the pipeline owns that operation. + +**Forbid around wrappers from replacing the signal.** Rejected because deadlines and nested operational scopes need lexical derivation. Capturing and fusing the caller signal preserves composition without allowing detachment. + +**Race the tool promise against cancellation.** Rejected because it reports completion while side effects may remain live, violating the [quiescent-disposal rule](../../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it). + +## Consequences + +- TypeScript rejects every `ToolExecutionInput` that omits `signal`, every tool or observer mutation of a readonly signal, and every around-dispatch attempt to remove the signal. +- Durable consumers can distinguish calls whose body may have produced side effects (`ABORTED`) from calls that never entered the body (`ABORTED_BEFORE_DISPATCH`). +- The change is intentionally breaking under the repository's pre-release stance; no compatibility overload or runtime fallback remains. +- Cooperative tools stop promptly and reach quiescence; an implementation that ignores its signal remains observable as a pending call. +- Downstream capability interfaces remain unchanged until the linked proposed Agent Note is accepted and implemented. diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md new file mode 100644 index 0000000000..6af8e57349 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md @@ -0,0 +1,73 @@ +# Agent Note: 注册表边界上的协作式工具取消 + +Status: implemented + +[English](2026-07-19-cooperative-tool-cancellation.md) | 中文 + +## 问题 + +每次类型化工具调用都需要一个由调用方持有的取消信号。可选的 `ToolExecutionInput.signal` 允许直接调用方不承担所有权,使每个工具主体中的 `exec.signal` 都成为可选值,也会诱使注册表提供无法表达真实调用方生命周期的后备信号。 + +流水线各阶段对可变性的需求也不同。工具实现、前置策略、后置策略和结果观察者只借用取消状态,而环绕调度包装层必须临时替换信号,以加入截止时间或其他词法取消作用域。单一的可变公开类型要么把修改权限授予过多阶段,要么阻止这种组合。 + +取消可能发生在策略之前、审批期间、环绕调度等待期间、工具主体启动之后,或后置策略等待期间。单一的 `ABORTED` 结果无法让持久化结果的使用方判断工具主体是否可能产生过副作用。让工具 promise 与取消竞速也不是安全的后备方案,因为注册表报告完成后,被丢弃的同进程工作仍会继续运行。 + +## 决策 + +`ToolExecutionInput.signal` 是必填且只读的 `AbortSignal`,因此 `ToolExecution.signal` 和 `ToolRunContext.signal` 也都是必填且只读。每个类型化调用方显式提供自己持有的信号;注册表不提供重载、默认控制器、永不中止哨兵或便捷执行路径。 + +`ToolDefinition.execute(args, exec)` 保持现有签名。`defineTool()` 会把 `exec.signal` 上下文推断为必填的 `AbortSignal`,因此每个已注册的 TypeScript 工具都能在无需类型断言的情况下观察或转发取消。所有第一方直接调用方和 Code Mode 嵌套调度都会显式传入当前操作的信号。 + +注册表信任这份类型化同进程契约。它不在运行时校验 `AbortSignal`,也不为缺失或畸形信号添加敌意输入测试。校验仍位于解析器与配置、队列、模型与工具 JSON、持久化与文件、worker、进程和线协议边界;违反 TypeScript 接口的无类型 JavaScript 不享有兼容性契约。 + +### 可变性由流水线阶段决定 + +`ToolDispatchExecution` 与 `ToolExecution` 相同,唯一差异是其必填 `signal` 可修改。只有 `tools/execute` waterfall(瀑布式事件)接收这个类型。前置策略、后置策略、结果观察者、守卫和工具实现接收注册表私有可变运行对象的只读视图。 + +环绕调度包装层可以在委托期间替换 `exec.signal`,但无法通过类型系统删除它或赋值为 `undefined`。注册表在可变对象之外捕获必填的调用方信号,在工具主体调用前把每次包装层替换与调用方信号融合,在完成后移除仅属于本次调度的监听器,并无条件恢复必填的上游信号。 + +### 取消代码记录是否发生过调度 + +`dsh-tools` 导出 `TOOL_ABORTED = 'ABORTED'` 和 `TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'`。注册表在调用 `ToolDefinition.execute()` 的前一刻记录工具主体已经开始。 + +`ABORTED_BEFORE_DISPATCH` 携带 `{ name: 'AbortError' }` 和模型可见文本 `Error: tool call aborted before dispatch`。凡取消阻止工具主体调用时都使用该结果,包括进入时已中止、前置策略或审批期间取消、包装层信号已中止、包装层在委托前返回的成功结果被调用方取消抢先,以及轮次取消后 agent loop 跳过的同批调用。 + +`ABORTED` 携带模型可见文本 `Error: tool call aborted`,并且只在工具主体已经调用后使用,包括工具主体完成后环绕包装层或后置策略监听器等待期间发生的取消。拒绝、包装层失败、工具失败或后置策略失败比通用取消更具体。timeout-policy 自身拥有的超时仍为 `TOOL_TIMEOUT`,成功结果被取消替换前延后附加的上下文仍会保留。 + +### 进入时已中止会在物化后短路 + +注册表先创建调用 token,并对参数进行无损快照和冻结。即使调用方信号已经中止,参数物化失败仍优先返回。物化成功后,进入时已中止的信号会跳过 `tools/pre-execute`、审批、`tools/execute`、`tools/post-execute` 和工具主体,然后发布且只发布一次冻结的权威 `tools/result`,其代码为 `ABORTED_BEFORE_DISPATCH`。 + +### 已启动工作仍必须完全停稳 + +工具主体一旦启动,注册表就会等待它完成。取消通过融合信号到达工具主体,但注册表不会与其 promise 竞速或丢弃该 promise。协作式实现会停止自身工作或继续转发取消,并在所持有的工作完全停稳后完成;不协作的同进程实现可能让注册表无限期保持等待。进程、worker、网络和提供方层仍负责各自的终止机制。 + +这项决策只要求工具调用接缝携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的[工具可达能力接缝中的必填取消](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md)。 + +## 验证 + +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖未调度同批调用的持久化配对结果。[`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/timeout/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 + +任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。 + +## 考虑过的替代方案 + +**保留可选信号并生成后备值。** 不予采纳,因为注册表持有的后备信号不代表任何调用方生命周期,也会保留类型系统本应阻止的缺失情况。 + +**在运行时校验 `AbortSignal`。** 不予采纳,因为这是类型化同进程接缝,不是序列化边界。运行时检查只会重复静态契约,仍无法强制实现协作式使用信号。 + +**添加 `supportsCancellation` 元数据、回调参数数量检查或信号使用 lint。** 不予采纳,因为这些方法都无法证明异步工作会观察或正确转发取消。信号可用性属于类型契约;具体行为仍由工具和能力负责。 + +**向所有阶段公开同一个可变执行类型。** 不予采纳,因为观察者和工具实现只需要借用信号。按阶段划分类型可以把替换权限限制在流水线拥有该操作的位置。 + +**禁止环绕包装层替换信号。** 不予采纳,因为截止时间和嵌套运行时作用域需要词法派生信号。捕获并融合调用方信号既保留组合能力,也不允许切断调用方取消。 + +**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[资源释放必须完全停稳的规则](../../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it)。 + +## 后果 + +- TypeScript 会拒绝所有缺少 `signal` 的 `ToolExecutionInput`、工具或观察者对只读信号的修改,以及环绕调度删除信号的尝试。 +- 持久化结果的使用方可以区分工具主体可能产生过副作用的调用(`ABORTED`)和从未进入工具主体的调用(`ABORTED_BEFORE_DISPATCH`)。 +- 根据仓库的预发布原则,这项变更刻意保持破坏性;不保留兼容重载或运行时后备行为。 +- 协作式工具会及时停止并完全停稳;忽略信号的实现会表现为仍在等待的调用。 +- 下游能力接口保持不变,直到关联的提议 Agent Note 被接受并实现。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml new file mode 100644 index 0000000000..b1a81228cf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.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-24-single-harness-home-resolver.md: 10ed0e9f1fd6ac4630d92a66953fdf1d52b3b5f1 +2026-07-24-single-harness-home-resolver.zh.md: 1ce56281357595de134ddea285c8c2e0c1801ce9 diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md new file mode 100644 index 0000000000..10ed0e9f1f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md @@ -0,0 +1,41 @@ +# Agent Note: One harness home resolver + +Status: implemented + +English | [中文](2026-07-24-single-harness-home-resolver.zh.md) + +## Problem + +The harness had three inconsistent conventions for "where does DeepSeek Harness user data live": + +- `@deepseek-ai/dsh-home` resolved `configured ?? $DSH_HOME ?? ~/.dsh`. +- `@deepseek-ai/dsh-paths` shipped a **second** `resolveDshHome` with the same precedence plus tilde expansion — a near-duplicate of `dsh-home` that no gate flagged because the two lived in different packages and had already drifted (only one expanded tildes). +- `@deepseek-ai/dsh-telemetry`'s `globalConfigDir` used a *different* policy entirely: `DSH_CONFIG_HOME > $XDG_CONFIG_HOME/deepseek-harness > %APPDATA%/deepseek-harness > ~/.config/deepseek-harness`. + +So most of the product parked everything under one `~/.dsh` root while telemetry alone stored its anonymous id elsewhere, under a `deepseek-harness` namespace that contradicts the repo-wide `dsh` shorthand (`DSH_HOME`, `@deepseek-ai/dsh-*`, `~/.dsh`). Two resolvers plus a divergent third policy means no single home fact. + +## Decision + +One resolver owns the harness home, in `@deepseek-ai/dsh-paths`, single-root: + +``` +explicit configured path > $DSH_HOME > ~/.dsh +``` + +An empty or whitespace-only `$DSH_HOME` is treated as unset, matching the guard telemetry's old resolver carried: without it `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces workspace-context's bespoke default-vs-`$DSH_HOME` check. + +`@deepseek-ai/dsh-home` is deleted. Its three importers (`dsh-tool-bash`, `dsh-skill-local`, `dsh-agent-spine-demo`) now import `resolveDshHome` from `dsh-paths`. `dsh-telemetry`'s `globalConfigDir` delegates to `resolveDshHome`, dropping its second resolver, the `DSH_CONFIG_HOME` override, the XDG/`%APPDATA%` branches, and the `deepseek-harness` namespace; the anonymous id now lives directly under the harness home. + +## Alternatives considered + +**Leave the two `resolveDshHome` copies in place.** They had already drifted (one expands tildes, one didn't) and encode the same cross-cutting fact twice. Consolidation is the point of the `util/` layer; a duplicate resolver is a latent divergence bug. + +**Adopt XDG (honor `$XDG_CONFIG_HOME`, or split config/data/cache into separate trees).** Considered and dropped in favor of one obvious root. A single `$DSH_HOME || ~/.dsh` ground truth matches `~/.claude` / `~/.aws`, needs no per-kind reclassification of every `~/.dsh` consumer, and leaves no resolver asymmetry to reconcile. Telemetry aligning onto the same root — rather than keeping its own XDG path — is precisely the divergence this removes. + +**Keep telemetry's own config dir.** Its `deepseek-harness` namespace and separate XDG policy were the lone exception to the `dsh`/`~/.dsh` convention. Folding it onto the shared resolver is what makes "one home fact" true. The cost is that the anonymous id becomes scoped to `$DSH_HOME` rather than the machine: a project that points `DSH_HOME` at a repo-local path (or a command that loads a project `.env` before telemetry) gets a home-local id, so the id counts harness homes, not machines. This is accepted as the intended meaning of single-root — a relocated `$DSH_HOME` moves *all* harness state, telemetry identity included — and the module contract is stated as per-harness-home rather than per-machine. A machine-global identity that ignored `$DSH_HOME` would reintroduce exactly the second home policy this Note removes. + +## Consequences + +- One home fact, one resolver. `dsh-paths` is the sole owner; the `util/` group loses the `home` package. +- Telemetry's anonymous id moves from `~/.config/deepseek-harness/telemetry.json` to the harness home (`~/.dsh/telemetry.json` by default). Under the pre-release "backends reject old formats" stance this needs no migration: an orphaned old id simply regenerates once, and the id is anonymous by construction. +- Telemetry drops Windows `%APPDATA%` handling. `resolveDshHome` uses `os.homedir()`, which is correct on Windows; the harness does not special-case `%APPDATA%` for its single root. diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md new file mode 100644 index 0000000000..1ce5628135 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md @@ -0,0 +1,41 @@ +# Agent Note:单一 harness home 解析器 + +Status: implemented + +[English](2026-07-24-single-harness-home-resolver.md) | 中文 + +## 问题 + +对于"DeepSeek Harness 用户数据存放在哪里",harness 里存在三套互不一致的约定: + +- `@deepseek-ai/dsh-home` 按 `configured ?? $DSH_HOME ?? ~/.dsh` 解析。 +- `@deepseek-ai/dsh-paths` 又提供了**第二个** `resolveDshHome`,优先级相同但额外做了波浪号展开——它几乎是 `dsh-home` 的重复实现,却没有任何门禁发现,因为两者分属不同的包,而且早已漂移(只有一个会展开波浪号)。 +- `@deepseek-ai/dsh-telemetry` 的 `globalConfigDir` 采用了*完全不同*的策略:`DSH_CONFIG_HOME > $XDG_CONFIG_HOME/deepseek-harness > %APPDATA%/deepseek-harness > ~/.config/deepseek-harness`。 + +于是产品的大部分内容都停放在同一个 `~/.dsh` 根目录下,唯独 telemetry 把匿名 id 存到别处,落在一个 `deepseek-harness` 命名空间里,这与全仓库通行的 `dsh` 简写(`DSH_HOME`、`@deepseek-ai/dsh-*`、`~/.dsh`)相冲突。两个解析器再加上一个各行其是的第三套策略,意味着不存在单一的 home 事实。 + +## 决策 + +由一个解析器统一掌管 harness home,落在 `@deepseek-ai/dsh-paths`,采用单一根目录: + +``` +explicit configured path > $DSH_HOME > ~/.dsh +``` + +空或仅含空白的 `$DSH_HOME` 被当作未设置处理,这与 telemetry 旧解析器所带的保护一致:若无此保护,`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样面向用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 workspace-context 中自定义的"默认值 vs `$DSH_HOME`"判断。 + +`@deepseek-ai/dsh-home` 被删除。它的三个引用方(`dsh-tool-bash`、`dsh-skill-local`、`dsh-agent-spine-demo`)现在从 `dsh-paths` 导入 `resolveDshHome`。`dsh-telemetry` 的 `globalConfigDir` 转而委托给 `resolveDshHome`,去掉了它的第二个解析器、`DSH_CONFIG_HOME` 覆盖项、XDG/`%APPDATA%` 分支以及 `deepseek-harness` 命名空间;匿名 id 现在直接存放在 harness home 之下。 + +## 备选方案 + +**保留两份 `resolveDshHome` 副本。** 它们早已漂移(一个展开波浪号,一个不展开),并把同一条横切事实编码了两遍。`util/` 层的意义正是在于合并,重复的解析器是一个潜在的分歧 bug。 + +**采用 XDG(遵从 `$XDG_CONFIG_HOME`,或把 config/data/cache 拆分到各自的目录树)。** 经过考虑后放弃,转而采用一个显而易见的根目录。单一的 `$DSH_HOME || ~/.dsh` 基准事实与 `~/.claude` / `~/.aws` 一致,无需对每个 `~/.dsh` 消费方按类别重新归类,也不留下任何需要协调的解析器不对称。telemetry 对齐到同一根目录——而不是保留自己的 XDG 路径——正是本决策所要消除的那种分歧。 + +**保留 telemetry 自己的 config 目录。** 它的 `deepseek-harness` 命名空间和独立的 XDG 策略是唯一违背 `dsh`/`~/.dsh` 约定的例外。把它折叠到共享解析器上,才让"单一 home 事实"成真。代价是匿名 id 的作用域从机器变成了 `$DSH_HOME`:若某个项目把 `DSH_HOME` 指向仓库本地路径(或某条命令在 telemetry 之前加载了项目的 `.env`),得到的就是 home 本地的 id,因此该 id 统计的是 harness home,而非机器。这被接受为单一根目录的应有含义——重定位 `$DSH_HOME` 会移动*全部* harness 状态,telemetry 身份也在其中——模块契约据此表述为 per-harness-home 而非 per-machine。一个忽略 `$DSH_HOME` 的机器级全局身份,恰恰会重新引入本 Note 所要消除的那第二套 home 策略。 + +## 影响 + +- 单一 home 事实,单一解析器。`dsh-paths` 是唯一归属方;`util/` 组失去了 `home` 包。 +- telemetry 的匿名 id 从 `~/.config/deepseek-harness/telemetry.json` 移到 harness home(默认为 `~/.dsh/telemetry.json`)。在预发布的"后端拒绝旧格式"立场下,这无需迁移:一个遗留的旧 id 只会重新生成一次,而且该 id 本就是匿名构造的。 +- telemetry 去掉了 Windows `%APPDATA%` 处理。`resolveDshHome` 使用 `os.homedir()`,这在 Windows 上是正确的;harness 不会为它的单一根目录对 `%APPDATA%` 做特殊处理。 diff --git a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md b/.agents/notes/implemented/feature/2026-06-30-interception-seams.md index 2535c1c564..08edfae386 100644 --- a/.agents/notes/implemented/feature/2026-06-30-interception-seams.md +++ b/.agents/notes/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 for the turn's single claimed queued message before the `user/message` append. `allow` optionally rewrites the prompt `content` or attaches separately sourced `additionalContexts[]`; `block` appends a durable `prompt/blocked` and rejects that zero-step turn. +- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` — waterfall, fired for the turn's single claimed queued message before the `user/message` append. The explicit turn signal is placed before the final `next`; `allow` optionally rewrites the prompt `content` or attaches separately sourced `additionalContexts[]`, while `block` appends a durable `prompt/blocked` and rejects that zero-step turn. **`agent/turn-continuation`** receives and returns a `ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing content and source recorded as next-step steering in the same turn — the typed twin of the `/goal` step-end-steer pattern. It is not a `context/message`, so its type does not offer durable context metadata. @@ -24,7 +24,7 @@ Every call follows `tools/pre-execute` → guards → `tools/execute` → dispat - **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks. Deny skips `tools/execute` and core dispatch. Ask resolves through the optional approval seam: only `allowed-once` continues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every outcome still reaches post-policy and final observers. - **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids. -- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may add, replace, or remove only `exec.signal` before doing so, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch. +- **`tools/execute`** is the around-dispatch waterfall for timeout, retry, and metrics plugins. A wrapper delegates to core dispatch with `next()`, may replace and restore the required `exec.signal` before doing so but cannot remove it, and receives the already-normalized result of a thrown or unknown tool; returning its own valid result short-circuits dispatch. - **`tools/post-execute`** is the inspect/transform waterfall. Its `PostToolDecision` accepts, blocks with feedback, optionally replaces content, or attaches `additionalContexts`. The returned decision is the supported transform channel; after the waterfall, the registry materializes the complete outcome once before final observation. - **`tools/result`** is the synchronous contained notification after every transform, lossless-JSON materialization, and the outer error boundary. It receives the same frozen execution identity and an immutable snapshot of the authoritative result; observer failures are contained per listener and cannot change or reject `ToolRegistry.execute()`'s returned outcome. diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md index 988052fb2a..d4c5154ed2 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md @@ -31,7 +31,7 @@ The model-facing bash package owns a `ctx.bashEnv` registry. A contributor decla The registry rebuilds a trusted overlay for every foreground and background bash `ToolExecution`: -- `DSH_HOME` is always the absolute configured Harness home. The standalone [`@deepseek-ai/dsh-home`](../../../../packages/util/home/README.md) utility owns its precedence: explicit `dshHome`, then ambient `$DSH_HOME`, then `~/.dsh`. +- `DSH_HOME` is always the absolute configured Harness home. The standalone [`@deepseek-ai/dsh-paths`](../../../../packages/util/paths/README.md) utility owns its precedence: explicit `dshHome`, then ambient `$DSH_HOME`, then `~/.dsh`. - `DSH_SHELL=1` is always present and identifies a model bash child managed by DeepSeek Harness. - `DSH_SESSION_ID` is present when the execution has an agent and equals `agent.session.header.id`. - The built-in persistence translator contributes `DSH_SESSION_JSONL` only when `ctx.sessionPersistence.locate(header)` returns `kind: 'jsonl'`. @@ -54,7 +54,7 @@ A fresh session receives its id before the first turn, so its first bash call ca Resume reuses the loaded header and therefore the same id and location. Fork and spawn create new session ids and locations. Parent and child calls resolve from their own `ToolExecution.agent`; each command receives an immutable snapshot even when calls overlap. A persistence service replacement affects later collections because the translator queries `ctx.get('sessionPersistence')` at execution time; the registry itself is effect-scoped and HMR-safe. -`dshHome` is session-independent deployment context. Agent-core resolves one value through `@deepseek-ai/dsh-home` and routes it to both tool-bash and local skill discovery; standalone consumers call the same resolver. If top-level `dshHome` and `skills.local.dshHome` are both supplied and resolve differently, composition fails instead of exposing contradictory homes. Persistence may change independently without freezing its facts into the session prefix. +`dshHome` is session-independent deployment context. Agent-core resolves one value through `@deepseek-ai/dsh-paths` and routes it to both tool-bash and local skill discovery; standalone consumers call the same resolver. If top-level `dshHome` and `skills.local.dshHome` are both supplied and resolve differently, composition fails instead of exposing contradictory homes. Persistence may change independently without freezing its facts into the session prefix. ## Testing diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md index c85f644853..824efc804c 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md +++ b/.agents/notes/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/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.i18n.yaml new file mode 100644 index 0000000000..bbb2b78989 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.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-19-required-cancellation-through-tool-capability-seams.md: c2cfb09f27222136965058695e9b6b706ac688a9 +2026-07-19-required-cancellation-through-tool-capability-seams.zh.md: f7a1d303212dfab6da27feba2d6e7195ea07bd50 diff --git a/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md new file mode 100644 index 0000000000..c2cfb09f27 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md @@ -0,0 +1,65 @@ +# Agent Note: Required cancellation through tool-reachable capability seams + +Status: proposed + +English | [中文](2026-07-19-required-cancellation-through-tool-capability-seams.zh.md) + +## Problem + +The implemented [tool registry cancellation contract](../../implemented/architecture/2026-07-19-cooperative-tool-cancellation.md) makes `exec.signal` required in every tool body, but many asynchronous capability interfaces reached from those bodies still accept an optional signal. A tool can therefore satisfy its own type while accidentally dropping cancellation at the next same-process call. + +That gap is transitive. A filesystem tool may call path resolution and I/O, a web tool may call a provider, a bash tool may call an executor, and a composite tool may start or wait for tasks, subagents, or workflows. If any awaited operation controlling tool-owned work accepts omission, TypeScript cannot prove that cancellation remains available at the boundary that owns the side effect. + +Requiring signals on every asynchronous function in the repository would overreach. Some operations are not reachable from tools, some synchronous queries cannot wait or own ongoing work, and explicitly detached work has a new owner after a deliberate handoff. + +## Proposal + +Require an `AbortSignal` on every asynchronous same-process capability operation that is reachable from a tool body while the tool still owns or awaits the operation. The requirement may be a positional parameter or a required readonly request field according to the owning seam's existing shape, but omission must fail TypeScript compilation. + +Each direct caller supplies a signal it owns or propagates from its own required operation context. Implementations may derive a child deadline or cancellation scope, but the derived signal remains linked to the upstream signal for the delegated lifetime. Capability implementations do not synthesize never-abort signals, use ambient async-local cancellation, or validate `AbortSignal` at runtime solely to repeat the typed same-process contract. + +The migration begins with an inventory from every first-party `ToolDefinition.execute()` through the capability calls it awaits. It then changes each coherent interface/implementation/consumer seam together, including tests and generated API documentation. Separate PRs may migrate filesystem, bash/task, web/provider, workflow/subagent, code-runtime, and similar families so each change remains reviewable, but no migrated interface keeps an optional compatibility overload under the repository's pre-release policy. + +### Scope boundary + +The proposal includes asynchronous capability operations whose completion or cancellation remains part of the invoking tool's lifetime, including start operations before ownership transfer, foreground execution, reads and writes, provider requests, waits, and cleanup or disposal that the tool awaits. + +The proposal excludes synchronous registry lookup, availability checks, schema rendering, argument classification, and other operations that cannot retain asynchronous work. It also excludes work after an explicit detached-ownership handoff: once a task, workflow, worker, or child agent has been successfully published to a new lifecycle owner, that owner's controller governs the detached lifetime. The initiating start operation still requires the caller signal until the handoff commits, and any later tool call that waits for detached work requires its own invocation signal. + +Optional cancellation may remain on parser, config, model/tool JSON, durable/file format, worker, process, or wire inputs when the external protocol makes it optional. The owning boundary must resolve that input into a required same-process signal before calling a migrated capability seam. + +## Alternatives considered + +**Leave downstream signals optional because tool bodies now receive one.** Rejected because availability at the outer callback does not make propagation type-safe; omission remains legal at every optional capability call. + +**Enforce propagation with lint rules or callback inspection.** Rejected because syntax checks cannot reliably identify ownership, derived signals, abstraction layers, or correct quiescent settlement. Required interface parameters express the contract where TypeScript can check every caller. + +**Pass `ToolRunContext` through every capability.** Rejected because capabilities need cancellation, not tool identity, agent state, or context deferral. Passing the larger context couples reusable services to the tool registry and obscures the narrow seam. + +**Use an ambient async-local signal.** Rejected because hidden propagation makes ownership and detached handoff difficult to audit, complicates tests, and lets calls silently bind to the wrong lifetime. + +**Add default or never-abort signals at capability implementations.** Rejected because defaults erase the missing owner instead of exposing it at compile time. + +**Migrate every capability in the implemented tool-registry change.** Rejected because the transitive interface changes span independent capability families. Keeping this proposal separate preserves the implemented registry decision and lets each deep seam migrate with focused tests. + +## Acceptance criteria + +- An inventory maps every first-party tool body to the asynchronous capability operations it can reach before ownership handoff. +- Every in-scope capability interface requires `AbortSignal`, and compile-time contract tests prove omission fails. +- Interface, implementation, direct consumer, test helper, example, and generated API references migrate together without compatibility overloads or never-abort production sentinels. +- Derived deadlines and wrapper scopes remain linked to the caller signal, and integration tests prove cancellation reaches the side-effect owner and awaited work reaches quiescence. +- Synchronous queries and explicitly detached post-handoff work remain outside the requirement, with ownership transitions documented and tested where ambiguity exists. +- Runtime validation is added only at an actual untyped boundary, not to repeat a required TypeScript field or parameter. +- The top-level typecheck, coverage, snapshot, documentation, module-graph, build, hygiene, demo, and built-artifact gates pass after each coherent migration. + +## Risks + +**Large transitive blast radius.** A required parameter can expose many direct callers at once. Migrate by coherent capability family and use typecheck failures as the complete caller inventory. + +**Incorrect detached-work classification.** Excluding a start operation too early can detach work before publication is committed; requiring the parent signal forever can let a completed tool cancel legitimately detached work. Each handoff needs an explicit commit point, new owner, rollback behavior, and quiescent failure path. + +**Signal ownership confusion.** A capability that stores a borrowed signal beyond the delegated lifetime can bind work to a stale caller. Interfaces and tests must distinguish borrowed operation signals from controllers owned by long-lived services. + +**Mechanical compliance without cooperation.** A required parameter proves availability, not observation or forwarding. Integration tests at process, worker, socket, provider, and task boundaries remain necessary to prove behavior. + +**Over-scoping synchronous or unrelated APIs.** Requiring cancellation where no asynchronous work exists adds noise and weakens the signal of the contract. The inventory records why each operation is tool-reachable and lifetime-bearing before changing it. diff --git a/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.zh.md b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.zh.md new file mode 100644 index 0000000000..f7a1d30321 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.zh.md @@ -0,0 +1,65 @@ +# Agent Note: 工具可达能力接缝中的必填取消 + +Status: proposed + +[English](2026-07-19-required-cancellation-through-tool-capability-seams.md) | 中文 + +## 问题 + +已经实现的[工具注册表取消契约](../../implemented/architecture/2026-07-19-cooperative-tool-cancellation.md)让每个工具主体中的 `exec.signal` 成为必填值,但许多由工具主体调用的异步能力接口仍接受可选信号。因此,工具可以满足自身类型,却在下一次同进程调用时意外丢失取消。 + +这项缺口会沿调用链传递。文件系统工具可能调用路径解析和 I/O,Web 工具可能调用提供方,Bash 工具可能调用执行器,组合工具可能启动或等待任务、subagent 或工作流。只要某个控制工具所持有工作的等待操作允许省略信号,TypeScript 就无法证明取消仍能到达拥有副作用的边界。 + +要求仓库中所有异步函数都携带信号会过度扩张。有些操作无法从工具到达,有些同步查询不会等待或持有持续工作,而明确分离的工作在刻意交接后已经拥有新的所有者。 + +## 提议 + +所有能从工具主体到达、且在工具仍持有或等待该操作期间执行的异步同进程能力操作,都必须接收 `AbortSignal`。根据所属接缝的既有形态,这项要求可以表现为位置参数,也可以表现为必填的只读请求字段,但省略信号必须导致 TypeScript 编译失败。 + +每个直接调用方提供自己持有的信号,或从自身必填的操作上下文继续传递信号。实现可以派生子截止时间或取消作用域,但派生信号在委托期间仍须与上游信号关联。能力实现不得生成永不中止信号、使用环境式异步本地取消,也不得仅为重复类型化同进程契约而在运行时校验 `AbortSignal`。 + +迁移首先从每个第一方 `ToolDefinition.execute()` 出发,清点其等待的能力调用;随后把每个内聚的接口、实现和使用方接缝连同测试与生成的 API 文档一起修改。文件系统、Bash 与任务、Web 与提供方、工作流与 subagent、代码运行时等能力族可以通过独立 PR 迁移,以保持每项变更可审查;但根据仓库的预发布原则,已经迁移的接口不得保留可选兼容重载。 + +### 范围边界 + +本提议包含完成或取消仍属于当前工具生命周期的异步能力操作,包括所有权交接前的启动操作、前台执行、读写、提供方请求、等待,以及工具会等待的清理或释放操作。 + +本提议不包含同步注册表查询、可用性检查、schema 渲染、参数分类,以及其他无法保留异步工作的操作。明确交接所有权后的分离工作也不在范围内:任务、工作流、worker 或 subagent 成功发布给新的生命周期所有者后,其分离生命周期由新所有者的控制器管理。发起启动的操作在交接提交前仍须接收调用方信号;之后若另一次工具调用等待该分离工作,则必须使用该次调用自己的信号。 + +若外部协议本身允许省略取消,解析器、配置、模型与工具 JSON、持久化与文件格式、worker、进程或线协议输入仍可保留可选取消。所属边界必须先把该输入解析为必填的同进程信号,再调用已经迁移的能力接缝。 + +## 考虑过的替代方案 + +**因为工具主体已经收到信号,所以继续让下游信号保持可选。** 不予采纳,因为外层回调中存在信号并不能让传递过程具备类型安全;每个可选能力调用仍可合法省略它。 + +**通过 lint 规则或回调检查强制传递。** 不予采纳,因为语法检查无法可靠识别所有权、派生信号、抽象层或正确的完全停稳行为。必填接口参数可以在 TypeScript 能检查每个调用方的位置表达契约。 + +**把 `ToolRunContext` 传入所有能力。** 不予采纳,因为能力需要的是取消,而不是工具身份、agent 状态或上下文延后功能。传递更大的上下文会让可复用服务耦合到工具注册表,也会掩盖狭窄接缝。 + +**使用环境式异步本地信号。** 不予采纳,因为隐藏传递会让所有权和分离交接难以审计,使测试复杂化,并可能让调用静默绑定到错误的生命周期。 + +**在能力实现中加入默认或永不中止信号。** 不予采纳,因为默认值会抹去缺失的所有者,而不是在编译期暴露问题。 + +**在已经实现的工具注册表变更中迁移所有能力。** 不予采纳,因为传递性的接口修改横跨独立能力族。单独保留这项提议既能维持已实现的注册表决策,也能让每个深层接缝通过聚焦测试完成迁移。 + +## 验收标准 + +- 清单把每个第一方工具主体映射到所有权交接前可以到达的异步能力操作。 +- 每个范围内的能力接口都要求 `AbortSignal`,并由编译期契约测试证明省略信号会失败。 +- 接口、实现、直接使用方、测试辅助函数、示例和生成的 API 引用必须一起迁移,不保留兼容重载或生产环境永不中止哨兵。 +- 派生截止时间和包装层作用域仍与调用方信号关联,集成测试证明取消到达副作用所有者,且等待的工作完全停稳。 +- 同步查询和明确交接后的分离工作不受这项要求约束;存在歧义时,需要记录并测试所有权转换。 +- 只有真实的无类型边界才添加运行时校验,不得重复校验 TypeScript 已要求的字段或参数。 +- 每次内聚迁移后,顶层类型检查、覆盖率、快照、文档、模块图、构建、hygiene、演示和构建产物门禁全部通过。 + +## 风险 + +**传递性影响范围较大。** 一个必填参数可能同时暴露大量直接调用方。应按内聚能力族迁移,并把类型检查失败作为完整的调用方清单。 + +**错误划分分离工作。** 过早排除启动操作可能在发布提交前就让工作脱离控制;永久要求父信号又可能让已完成工具取消合法分离的工作。每次交接都需要明确提交点、新所有者、回滚行为和完全停稳的失败路径。 + +**信号所有权混淆。** 能力若在委托生命周期之外保存借用信号,可能让工作绑定到过期调用方。接口和测试必须区分借用的操作信号与长生命周期服务所持有的控制器。 + +**只有机械合规而没有协作行为。** 必填参数只能证明信号可用,不能证明实现会观察或转发它。进程、worker、套接字、提供方和任务边界仍需集成测试证明实际行为。 + +**把同步或无关 API 纳入范围。** 在不存在异步工作的地方要求取消只会增加噪声,并削弱契约的辨识度。修改前,清单需要记录每项操作为何可由工具到达并承载其生命周期。 diff --git a/AGENTS.md b/AGENTS.md index 770cef5736..cf1763d3d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **No hardcoded tunables in plugins**: deployment-varying choices are validated `Config` fields changeable from cordis.yml; a `DEFAULT_*` constant or test seam is not configurability. Protocol constants, external specs, and security invariants stay fixed. - **Misconfiguration fails loud** at load when self-contained, otherwise at the earliest resolvable point; never silently skip a missing referent. - **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string`. +- **Trust TypeScript at typed same-process seams.** Do not add runtime validation, fallback behavior, or hostile-input tests solely for values the static interface requires; validate at parser/config, queued, model/tool JSON, durable/file, worker, process, and wire boundaries. - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. diff --git a/docs/architecture.md b/docs/architecture.md index 09dad5bbfc..3844b981ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -118,9 +118,9 @@ Pruning precedes summaries; overflow retries require durable progress. Bounded t ### Failure Boundaries -The turn is the containment boundary. Adapter failures close the step, entering `agent/request-error` with the exact `Error`, `LlmFailure`, and retry history. Retry opens a numbered step; success clears history; exhaustion stores the failure on `turn/end`. Failed chunks commit no message or tool. +The turn contains failures. Adapter failures close the step before `agent/request-error`, which receives exact `Error`, `LlmFailure`, and history. Retry opens another step; success clears history; exhaustion stores failure on `turn/end`. Failed chunks commit no message/tool. -Other failures use `agent/error`. Cancellation beats recovery; undispatched calls get synthetic `ABORTED` results. Effective `cancel()` emits `agent/cancel-requested` before queue clearing or abort; observers cannot veto it, and idle calls emit nothing. Disposal awaits quiescence. +Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched model tool calls get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. One turn signal retires before `turn/end`. Effective `cancel()` emits its typed `user | parent` cause before clearing queues and aborting; observers cannot veto, idle calls emit nothing, and durability records only `aborted`. Disposal awaits quiescence before unregistering ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). Every session event is turn-enclosed. Reloading preserves an interrupted tail and closes it with a synthetic `interrupted` turn end. Failures after durable turn close report only through `agent/error` because no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. @@ -130,7 +130,7 @@ Every session event is turn-enclosed. Reloading preserves an interrupted tail an ### Agent Scope -Every live agent owns a scoped `agent.ctx`. Its registrations shadow globals, receive only that agent's dispatches, and unwind with it; async effects such as background-task cleanup are awaited. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. Typed resolvers derive carrier checks from merged `Events` signatures and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition controls](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs drivers inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`; other identities stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). +Every live agent owns a scoped `agent.ctx`. Its registrations shadow globals, receive only that agent's dispatches, and unwind with it; async effects such as background-task cleanup are awaited. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. Typed resolvers derive carrier checks from merged `Events` signatures and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition controls](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs drivers inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`; turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). ## State diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 54aad564c7..1f756793f0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1101,7 +1101,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` @@ -1400,7 +1400,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:397`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:434`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` @@ -1722,7 +1722,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) -- `@deepseek-ai/dsh-home` ([`packages/util/home/src/index.ts`](../packages/util/home/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 288ee96446..bd36cdfa42 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.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 -adding-a-tool.md: 94cb4fcfa9a0155fd57ef18f9a855a264c2eff84 -adding-a-tool.zh.md: 637dc3381765e57c2d0420164ac13fde7fb590fe +adding-a-tool.md: b3fa0bf7be48d1624ed216fb1a508dc78a2857f0 +adding-a-tool.zh.md: 720ac5fdbef55327f0c6b7bc263a1d109ea09cbc diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 94cb4fcfa9..b3fa0bf7be 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -25,7 +25,7 @@ export function apply(ctx: Context) { async execute(args, exec) { // args is TYPED from the schema: { path: string; limit?: number } // exec carries immutable identity + token; signal is the operational field - return [{ type: 'text', text: await readFile(args.path, 'utf8') }] + return [{ type: 'text', text: await readFile(args.path, { encoding: 'utf8', signal: exec.signal }) }] }, })) } @@ -37,7 +37,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w - **Args are validated for you.** `defineTool` validates model-generated `arguments` against the unified `ParameterSchemaSpec` before `execute` runs (types, required keys, literal constraints, exact-one unions, and nested values — [runtime arg validation](../../.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args match `InferArgs`. Explicit object nodes declare `additionalProperties: true | false`; the implicit parameter root stays open. You still hand-check constraints the DSL does not express, such as non-empty strings, positive numbers, or cross-field rules. Raw JSON-Schema tools registered directly own their input validation. - **Registration borrows your readonly definition.** A typed same-process contribution is not a serialization boundary; do not mutate its schema or replace callbacks after registration. `schemas()` materializes only the explicit model-facing projection. To hot-swap a tool, dispose its owning effect and register the replacement; mutable state inside the callback's closure remains ordinary plugin state. -- **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. An around-dispatch wrapper may add, replace, or remove only `exec.signal` to impose cancellation or a deadline. +- **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, the required caller-owned `signal`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. Only an around-dispatch wrapper receives a mutable view, and it may replace and restore the required `exec.signal` to impose a deadline but cannot remove it. - **Throwing or returning non-JSON data means `isError`.** The registry catches throws and materializes the final result before observers run. A malformed or non-JSON result becomes `{ isError: true }`, preventing a live success that cannot be logged. Throw for infrastructure failures; report domain failures in result text when the model must interpret them. - **Honor `exec.signal`.** Cancel in-flight work when it fires. - **Attach durable card data with `meta` (optional).** `execute` may return `{ content, meta }` instead of a bare `ContentBlock[]` — `meta` is a JSON-serializable payload the core treats as opaque, persisted on the `tool/result` event and handed back to your `presentResult` (so a card that needs more than `args`, like `write`/`edit`'s applied-hunk diff, survives a session replay). Keep UI-only data here, never in the model-facing `content`. @@ -45,7 +45,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w ## Long-running work -Gate `run_in_background` with producer config, reject a pre-aborted call, then register through `ctx.tasks.start({ kind, label, owner: exec.agent, run })`. The runtime validates ownership and control-surface availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. +Gate `run_in_background` with producer config, then register through `ctx.tasks.start({ kind, label, owner: exec.agent, run })`. The registry skips a pre-aborted invocation before the producer body; the runtime validates ownership and control-surface availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. The producer supplies synchronous `cancel`, non-rejecting `done` that settles after resource cleanup, and optional consuming `readOutput` with bounded-output formatting. Once the id is returned, use a task-owned cancellation signal rather than `exec.signal`. See the [background task runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer. diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 637dc33817..720ac5fdbe 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -25,7 +25,7 @@ export function apply(ctx: Context) { async execute(args, exec) { // args is TYPED from the schema: { path: string; limit?: number } // exec carries immutable identity + token; signal is the operational field - return [{ type: 'text', text: await readFile(args.path, 'utf8') }] + return [{ type: 'text', text: await readFile(args.path, { encoding: 'utf8', signal: exec.signal }) }] }, })) } @@ -37,7 +37,7 @@ export function apply(ctx: Context) { - **参数已为你校验。** `defineTool` 在 `execute` 运行前,会根据统一的 `ParameterSchemaSpec` 校验模型生成的 `arguments`(类型、必填键、字面量约束、恰好匹配一个分支的联合以及嵌套值——见[运行时参数校验](../../.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md)),因此 `execute` 内的 args 会匹配 `InferArgs`。显式对象节点必须声明 `additionalProperties: true | false`;隐式参数根对象保持开放。你仍需手动检查 schema DSL 无法表达的约束,例如非空字符串、正数或跨字段规则。直接注册的原始 JSON Schema 工具自行负责输入校验。 - **注册借用你的只读定义。** 类型化的同进程贡献不是序列化边界;注册后不要修改其 schema 或替换回调。`schemas()` 只物化显式的模型可见投影。如需热替换工具,请 dispose 其所属副作用并注册替代品;回调闭包内的可变状态仍是普通的插件状态。 -- **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 `exec.token`;`callId`、`name`、`arguments`、`agent`、`token` 以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。around-dispatch 包装器只能添加、替换或移除 `exec.signal`,以施加取消或截止时间。 +- **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 `exec.token`;`callId`、`name`、`arguments`、`agent`、`token`、必填且由调用方持有的 `signal`,以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。只有 around-dispatch 包装器会收到可变视图;它可以替换并恢复必填的 `exec.signal` 以施加截止时间,但不能移除该信号。 - **抛出异常或返回非 JSON 数据意味着 `isError`。** 注册表捕获异常,并在观察者运行前物化最终结果。格式错误或非 JSON 的结果变为 `{ isError: true }`,防止出现无法记录的活跃成功。基础设施故障请抛异常;当模型需要解读领域失败时,请在结果文本中报告。 - **遵守 `exec.signal`。** 信号触发时取消进行中的工作。 - **使用 `meta` 附加持久化的卡片数据(可选)。** `execute` 可以返回 `{ content, meta }` 而非裸的 `ContentBlock[]`。`meta` 是 JSON 可序列化的载荷,核心将其视为不透明数据,持久化在 `tool/result` 事件上并回传给你的 `presentResult`(这样需要 `args` 之外信息的卡片——如 `write`/`edit` 的已应用 hunk diff——在会话回放中依然存活)。仅在此处放 UI 数据,绝不放入模型可见的 `content`。 @@ -45,7 +45,7 @@ export function apply(ctx: Context) { ## 长时间运行的工作 -通过 producer 配置控制 `run_in_background`,拒绝已预先中止的调用,然后使用 `ctx.tasks.start({ kind, label, owner: exec.agent, run })` 注册任务。运行时会在 `run()` 启动工作前校验 owner 和控制面是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。 +通过 producer 配置控制 `run_in_background`,然后使用 `ctx.tasks.start({ kind, label, owner: exec.agent, run })` 注册任务。注册表会在进入 producer 主体前跳过已预先中止的调用;运行时会在 `run()` 启动工作前校验 owner 和控制面是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。 producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及可选的消费式 `readOutput`(负责有界输出的格式化)。返回 id 后,应使用 task 自有的取消信号,而不是 `exec.signal`。流式 producer 的示例和完整契约见[后台 task 运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)与 `dsh-tool-bash`。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index d89791abe5..9eb110944a 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -15,24 +15,24 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n ### `agent/cancel-requested` — emit -Effective broad cancellation was requested, before queued/steering work is cleared or the active step is aborted. This observe-only notification cannot veto cancellation; listener failures are contained. +Effective broad cancellation was requested, before queued/steering work is cleared or the active turn is aborted. This observe-only notification cannot veto cancellation; listener failures are contained. ```ts cordis-catalog /** * Effective broad cancellation was requested, before queued/steering work - * is cleared or the active step is aborted. This observe-only notification + * is cleared or the active turn is aborted. This observe-only notification * cannot veto cancellation; listener failures are contained. * @param agent - the agent whose current work is being cancelled. - * @param reason - resolved cancellation reason, including the default. + * @param cause - resolved typed cancellation cause, including the default. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/cancel-requested'(this: Scoped, agent: Agent, reason: string): void +'agent/cancel-requested'(this: Scoped, agent: Agent, cause: AgentCancelCause): void ``` -Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:191`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:153`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:172`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:328`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts) ### `agent/post-step` — serial @@ -119,7 +119,7 @@ Awaited serial checkpoint after the response, real or synthetic tool results, in Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:280`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:296`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -142,28 +142,31 @@ Awaited serial checkpoint before `step/start`; appends land outside the pending Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall -Allow, rewrite, or block one claimed prompt before it becomes a user message. Call `next()` for the unchanged default. +Allow, rewrite, or block one claimed 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 /** * Allow, rewrite, or block one claimed 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 whose turn claimed the message. * @param content - the claimed 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 ``` Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -184,7 +187,7 @@ 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) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:191`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -199,15 +202,17 @@ Replace the frozen call configuration. Model-visible content must use logged cha * @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 + * initiator 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 ``` Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:257`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -233,7 +238,7 @@ Recover a model-request failure after its failed step has closed. `retry` opens Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:295`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -251,7 +256,7 @@ Compose request-only messages placed before derived history. The frozen result i * 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 @@ -259,7 +264,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) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:257`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:272`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -281,7 +286,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -301,7 +306,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:171`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -315,15 +320,16 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va * @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 ``` Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:268`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:284`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -336,15 +342,16 @@ Override whether the turn continues. The default continues after tool calls or s * @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 ``` Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:322`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -357,15 +364,16 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a * 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 ``` Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -708,13 +716,15 @@ Source: [`packages/subagent/subagent/src/index.ts:130`](../../packages/subagent/ ### `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 /** * 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 @@ -724,7 +734,7 @@ Expert waterfall over the assembled sections, tools, and variables. Scope-filter Types: [AssembleContext](../core-data-structures/system-prompt.md) · [Scoped](../core-data-structures/scope.md) · [SystemPrompt](../core-data-structures/system-prompt.md) -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 @@ -739,7 +749,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/*` @@ -760,36 +770,41 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:131`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall -Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. +Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. The registry re-fuses the original caller signal before the body, so replacement cannot detach caller cancellation; wrappers must still restore their signal and reach quiescence. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog /** * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns * a normalized result; wrappers may change only `exec.signal`, while call - * identity remains immutable. + * identity remains immutable. The registry re-fuses the original caller + * signal before the body, so replacement cannot detach caller cancellation; + * wrappers must still restore their signal and reach quiescence. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). * @mode waterfall */ -'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise +'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise ``` -Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) +Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:104`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:108`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall -Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts it unchanged; thrown tools still reach this seam as errors. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. +Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts it unchanged; thrown tools still reach this seam as errors. Async listeners must observe `exec.signal`; after they settle, caller cancellation replaces only a successful accepted outcome with the code selected by whether the tool body was invoked. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog /** * Accept, replace, enrich, or block a normalized dispatch result. `next()` - * accepts it unchanged; thrown tools still reach this seam as errors. + * accepts it unchanged; thrown tools still reach this seam as errors. Async + * listeners must observe `exec.signal`; after they settle, caller + * cancellation replaces only a successful accepted outcome with the code + * selected by whether the tool body was invoked. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the call that just ran (name, parsed arguments, caller agent). * @param result - the dispatch outcome a listener may accept, replace, or block. @@ -800,16 +815,18 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:120`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall -Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. +Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Async gates must observe `exec.signal`; the registry rechecks cancellation after they settle but never abandons their promise. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog /** * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing - * approval support turns `ask` into denial. + * approval support turns `ask` into denial. Async gates must observe + * `exec.signal`; the registry rechecks cancellation after they settle but + * never abandons their promise. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall @@ -819,7 +836,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:95`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:97`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit @@ -838,7 +855,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:121`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:128`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e7300e2af1..45e6f76d3e 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:224`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:225`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -988,7 +988,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:553`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:570`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -1150,7 +1150,7 @@ async assemble(context: AssembleContext = {}): Promise Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md) -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` @@ -1379,7 +1379,11 @@ executionMode(exec: ToolExecutionInput): ToolExecutionMode * Execute through pre-policy, guards, around-dispatch, post-policy, and final * notification. Tool and listener failures resolve as materialized error * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is - * the same lossless, frozen snapshot final observers receive. + * the same lossless, frozen snapshot final observers receive. Cancellation + * arriving after entry and before final result materialization skips a + * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a + * successful started outcome with `ABORTED`; already-started work is still + * drained and may retain a tool-owned structured error. * @param exec - the typed same-process call input. The registry assigns its * correlation token before policy begins. * @returns the materialized final result. @@ -1389,7 +1393,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:453`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:508`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 5428ac2023..8765ba70d5 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -361,6 +361,13 @@ interface InjectOptions extends SendOptions { } ``` +```ts type-equiv +/** Stable runtime cause accepted by {@link Agent.cancel}. */ +type AgentCancelCause = + | { readonly kind: 'user' } + | { readonly kind: 'parent' } +``` + ```ts type-equiv /** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ interface Agent { @@ -401,12 +408,14 @@ interface Agent { /** * Clear all queued and steering work, including items waiting to start, and - * abort the active step. An effective call first emits `agent/cancel-requested` - * with the resolved reason. That 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. + * abort the active turn. An effective call first emits + * `agent/cancel-requested` with the resolved typed cause. The first cause wins + * for the active turn, and `whenIdle()` resolves after cancellation reaches + * quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op + * and does not arm later work. The active turn snapshots and freezes the cause. + * @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 @@ -416,6 +425,8 @@ interface Agent { `AgentStatus` is `'idle' | 'running' | 'disposed'`, and `SessionId` is branded. `running` describes the driver-wide drain interval, which can span turn close, its durability checkpoint, and consecutive queued turns; it does not prove a turn is still open. `AgentOptions` is merge-extensible and currently includes `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 holder copies its discriminant into the runtime-only `AbortSignal.reason`; it is retired before `turn/end` publication. `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 [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. ## Initiating Agent diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 8725625678..7020eb9ff9 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -463,13 +463,16 @@ 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 /** * Why a turn ended. Merge-extensible sum type. */ 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 04c86aabe6..85a974e6df 100644 --- a/docs/core-data-structures/system-prompt.md +++ b/docs/core-data-structures/system-prompt.md @@ -6,7 +6,7 @@ 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 /** Merge-extensible context for one prompt assembly. */ @@ -16,6 +16,8 @@ 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 } ``` diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 20a74e0190..bccb6027f4 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -11,6 +11,15 @@ A `ToolSchema` (the model-facing fields) plus the `execute` function, host-only ```ts type-equiv /** A registered tool: its schema plus the execution function. */ interface ToolDefinition extends ToolSchema { + /** + * Run one accepted call. Async work must observe or forward `exec.signal` and + * settle only after its owned work reaches quiescence. The registry preserves + * caller cancellation through around-dispatch signal replacement and does + * not abandon this promise, but it cannot hard-kill same-process code. + * @param args - losslessly snapshotted, frozen model arguments. + * @param exec - execution identity, cancellation signal, and context deferral. + * @returns model-facing content plus optional private presentation metadata. + */ execute(args: unknown, exec: ToolRunContext): Promise /** * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. @@ -138,7 +147,7 @@ interface ToolRestriction { ## Execution: extensible waterfalls plus monotonic policy -`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → `tools/result` (the immutable authoritative outcome). The outcome is a `ToolExecutionResult`. +`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput` with a required readonly `signal`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → `tools/result` (the immutable authoritative outcome). Only the `tools/execute` view may replace the required signal. The outcome is a `ToolExecutionResult`. ```ts type-equiv /** Opaque call identity that permits correlation without exposing mutable execution state. */ @@ -161,10 +170,11 @@ interface ToolExecutionInput { /** * Opaque token of the enclosing transport execution, when one exists. Code * Mode sets this on SDK sub-dispatches so commit-style observers can wait for - * the outer `run_code` outcome without receiving its live mutable execution. - */ + * the outer `run_code` outcome without receiving its live mutable execution. + */ readonly parent?: ToolExecutionToken - signal?: AbortSignal + /** Required caller-owned cancellation for this invocation. */ + readonly signal: AbortSignal } ``` @@ -203,9 +213,9 @@ type ToolExecutionMode = /** * One pending tool call inside the registry pipeline. Parsed arguments cross * one lossless-JSON materialization boundary before policy and are deep-frozen; - * call identity and the registry-assigned {@link token} are readonly. An - * around-dispatch wrapper may set, replace, or remove `signal`. The registry - * freezes the complete object before `tools/result` observers run. + * call identity, the caller signal, and the registry-assigned {@link token} are + * readonly. The registry freezes the complete object before `tools/result` + * observers run. */ interface ToolExecution extends ToolExecutionInput { /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ @@ -213,7 +223,19 @@ interface ToolExecution extends ToolExecutionInput { } ``` -`ToolExecutionToken` is an opaque runtime `Symbol` used only for identity comparison. Before policy, `execute()` materializes and freezes arguments, rejects non-JSON input, and assigns the token. Identity fields and the optional parent token remain readonly; only `signal` may change around dispatch. Final observers receive the frozen execution identity. +```ts type-equiv +/** + * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper + * may replace the signal for its delegated lifetime, but it cannot remove it. + * The registry fuses every replacement with the captured caller signal. + */ +interface ToolDispatchExecution extends Omit { + /** Cancellation signal visible to the next wrapper or tool body. */ + signal: AbortSignal +} +``` + +`ToolExecutionToken` is an opaque runtime `Symbol` used only for identity comparison. Before policy, `execute()` materializes and freezes arguments, rejects non-JSON input, and assigns the token. Identity fields, the required caller signal, and the optional parent token remain readonly. A `ToolDispatchExecution` wrapper may replace but not remove the signal; the registry re-fuses the caller signal before invoking the body. Final observers receive the frozen execution identity. A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d581204a5c..519cb27a52 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,22 +8,22 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:153`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:162`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:328`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:280`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:220`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`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:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:242`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:257`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:171`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:268`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:305`](../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:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:201`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:172`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`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:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:257`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:284`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:322`](../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:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | | `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) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:83`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -39,13 +39,13 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | -| `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`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | -| `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`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:131`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:104`](../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:113`](../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), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:95`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:121`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `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`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | +| `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:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:108`](../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:120`](../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), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:97`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:128`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 720bcae065..9cf9455d53 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -9,7 +9,6 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri flowchart TD subgraph group_util["packages/util"] pkg_brand["brand"] - pkg_home["home"] pkg_paths["paths"] pkg_retention["retention"] pkg_timeout["timeout"] @@ -168,7 +167,6 @@ flowchart TD pkg_workflow_workerthread["workflow-workerthread"] end pkg_brand --> pkg_invariants - pkg_home --> pkg_invariants pkg_paths --> pkg_invariants pkg_retention --> pkg_invariants pkg_timeout --> pkg_invariants @@ -190,6 +188,7 @@ flowchart TD pkg_scripts --> pkg_invariants pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants + pkg_telemetry --> pkg_paths pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout @@ -279,8 +278,8 @@ flowchart TD pkg_fs_policy --> pkg_fs pkg_fs_policy --> pkg_invariants pkg_skill_local --> pkg_fs - pkg_skill_local --> pkg_home pkg_skill_local --> pkg_invariants + pkg_skill_local --> pkg_paths pkg_skill_local --> pkg_skill pkg_compact_basic --> pkg_agent pkg_compact_basic --> pkg_compact @@ -379,9 +378,9 @@ flowchart TD pkg_tool_goal --> pkg_tools pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_bash - pkg_tool_bash --> pkg_home pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_llm + pkg_tool_bash --> pkg_paths pkg_tool_bash --> pkg_sandbox pkg_tool_bash --> pkg_sandbox_policy pkg_tool_bash --> pkg_session_persistence @@ -550,10 +549,10 @@ flowchart TD pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_goal pkg_agent_spine_demo --> pkg_goal_session - pkg_agent_spine_demo --> pkg_home pkg_agent_spine_demo --> pkg_invariants pkg_agent_spine_demo --> pkg_llm pkg_agent_spine_demo --> pkg_llm_retry + pkg_agent_spine_demo --> pkg_paths pkg_agent_spine_demo --> pkg_scope pkg_agent_spine_demo --> pkg_session pkg_agent_spine_demo --> pkg_skill @@ -629,7 +628,6 @@ flowchart TD | --- | --- | --- | | [`invariants`](../packages/support/invariants) | `support` | — | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) | -| [`home`](../packages/util/home) | `util` | [`invariants`](../packages/support/invariants) | | [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) | | [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | @@ -645,7 +643,7 @@ flowchart TD | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | -| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -674,7 +672,7 @@ flowchart TD | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | +| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -695,7 +693,7 @@ flowchart TD | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | @@ -721,7 +719,7 @@ flowchart TD | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 80b0b7196d..7f4392709f 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:337`](../packages/core/session/src/types.ts) ## Events @@ -151,7 +151,7 @@ Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:231`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -167,7 +167,7 @@ Source: [`packages/core/session/src/types.ts:231`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts) ### `compact/*` @@ -246,7 +246,7 @@ Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -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) ### `hook/*` @@ -342,7 +342,7 @@ Source: [`packages/ui/permission/src/index.ts:36`](../packages/ui/permission/src Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) ### `request/*` @@ -356,7 +356,7 @@ Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -387,7 +387,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:34`](../packages/s Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) ### `step/*` @@ -398,7 +398,7 @@ Source: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:206`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -407,7 +407,7 @@ Source: [`packages/core/session/src/types.ts:206`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) ### `todo/*` @@ -420,7 +420,7 @@ Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/ Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:258`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) ### `tool/*` @@ -437,7 +437,7 @@ Source: [`packages/core/session/src/types.ts:258`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -481,7 +481,7 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) ### `turn/*` @@ -499,7 +499,7 @@ Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:202`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:203`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -515,7 +515,7 @@ Source: [`packages/core/session/src/types.ts:202`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) ### `user/*` @@ -528,4 +528,4 @@ Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/ Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:208`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:209`](../packages/core/session/src/types.ts) diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl index 3129ceead5..9a792c3849 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl @@ -50,4 +50,4 @@ {"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} {"type":"context/message","seq":49,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal-session snapshot proof\",\"phase\":\"paused\",\"maxGoalRounds\":2},\"roundsStarted\":2,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"} {"type":"step/end","seq":50,"time":0,"data":{"turn":3,"step":1}} -{"type":"turn/end","seq":51,"time":0,"data":{"turn":3,"reason":{"kind":"aborted","reason":"session/cancel"}}} +{"type":"turn/end","seq":51,"time":0,"data":{"turn":3,"reason":{"kind":"aborted"}}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl index cdb76be163..e9b2467385 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -15,6 +15,6 @@ {"type":"tool/call","seq":13,"time":1784437195078,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} {"type":"tool/result","seq":14,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true},"sourceEventSeqs":[13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}} -{"type":"tool/result","seq":16,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","content":[{"type":"text","text":"Error: tool call skipped because the step was aborted before execution"}],"isError":true,"error":{"name":"AbortError","code":"ABORTED"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","content":[{"type":"text","text":"Error: tool call aborted before dispatch"}],"isError":true,"error":{"name":"AbortError","code":"ABORTED_BEFORE_DISPATCH"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1784437195090,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":18,"time":1784437195090,"data":{"turn":1,"reason":{"kind":"aborted","reason":"session/cancel"}}} +{"type":"turn/end","seq":18,"time":1784437195090,"data":{"turn":1,"reason":{"kind":"aborted"}}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl index 9cce0b2875..c44c88963f 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl @@ -5,4 +5,4 @@ {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: command aborted\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skipped","title":"printf skipped > skipped.txt","kind":"execute","status":"in_progress","rawInput":"printf skipped > skipped.txt","content":[{"type":"content","content":{"type":"text","text":"Write skipped marker"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skipped","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: tool call skipped because the step was aborted before execution\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skipped","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: tool call aborted before dispatch\n```"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/session.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.jsonl index 44e8ac136c..3fcf779321 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/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 207f8d8cc3..fbc6851750 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -10,7 +10,7 @@ {"type":"assistant/chunk","seq":8,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":11,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\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?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\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?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -20,7 +20,7 @@ {"type":"assistant/chunk","seq":18,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"inspect-tools-event","name":"cordis_inspect","arguments":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} {"type":"tool/call","seq":20,"time":1784449176734,"data":{"turn":1,"step":2,"callId":"inspect-tools-event","name":"cordis_inspect","arguments":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}} -{"type":"tool/result","seq":21,"time":1784449176734,"data":{"turn":1,"step":2,"callId":"inspect-tools-event","content":[{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}],"isError":false},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"tool/result","seq":21,"time":1784449176734,"data":{"turn":1,"step":2,"callId":"inspect-tools-event","content":[{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}],"isError":false},"sourceEventSeqs":[20],"surfaceOp":"append"} {"type":"step/end","seq":22,"time":1784449176734,"data":{"turn":1,"step":2}} {"type":"step/start","seq":23,"time":1784449176735,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":24,"time":1784449176735,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index 5528c956d8..e6fc1ad3cc 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -2,8 +2,8 @@ {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\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?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\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?: InjectOptions): void;\n cancel(cause?: AgentCancelCause): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index f12f85b6ca..ae3651fdaf 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -4,6 +4,8 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { cordisHarness, waitForIdle } from './harness.ts' import { SessionId } from '@deepseek-ai/dsh-session' +const testToolSignal = new AbortController().signal + /** * With-key smoke for the self-referential cordis tools: a REAL model drives * cordis_mount/cordis_unmount against the live context the test observes. @@ -51,6 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif // the mounted listener through the tagged sandbox console. expect(taggedCalls(log).length).toBeGreaterThan(0) const mid = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('verify-mounted'), name: 'cordis_inspect', arguments: { what: 'dynamic' }, }) expect(resultText(mid)).toContain('dyn-') @@ -59,6 +62,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif await waitForIdle(ctx, agent) const after = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('verify-unmounted'), name: 'cordis_inspect', arguments: { what: 'dynamic' }, }) expect(resultText(after)).toContain('(no dynamic plugins mounted)') @@ -148,6 +152,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif expect(ctx.get('shouter')).toBeUndefined() expect(ctx.tools.get('shout_text')).toBeUndefined() const after = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('verify-parked'), name: 'cordis_inspect', arguments: { what: 'dynamic' }, }) expect(resultText(after)).toContain('waiting for: shouter') diff --git a/knip.json b/knip.json index b95a4243f4..692820b362 100644 --- a/knip.json +++ b/knip.json @@ -57,10 +57,6 @@ "packages/util/brand": { "project": ["src/**/*.ts"] }, - "packages/util/home": { - "entry": ["tests/**/*.spec.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] - }, "packages/util/timeout": { "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 77a4712e04..4fc767173f 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -5,7 +5,6 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Plugin export shape:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **Optional services use `ctx.get(name)`.** Reserve `ctx.` for declared injections; the property proxy is topology-sensitive, while strict `ctx.get` reads the global service store ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md). -- **Typed same-process service and plugin calls are contracts, not serialization boundaries.** Prefer readonly borrowed values; materialize or defensively validate only at parser/config, queued, model/tool JSON, durable/file, worker, process, or wire boundaries. - **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). - **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence. - **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 258967fad9..5e0ceb3826 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -26,7 +26,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th ### Managed shell environment -Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-home`](../../util/home/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential. +Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential. `ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; tool-bash's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index 5ef96794af..6fe653fe6f 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -29,12 +29,12 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", - "@deepseek-ai/dsh-home": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -50,9 +50,9 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 6098056fb9..af1d1e8bf8 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -23,7 +23,7 @@ import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@ import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' -import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home' +import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' import { processOutcome } from './background.ts' import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' @@ -357,7 +357,7 @@ export function apply(ctx: Context, config: Config = {}): void { agent: exec.agent, callId: exec.callId, toolName: 'bash', - ...exec.signal ? { signal: exec.signal } : {}, + signal: exec.signal, }, ) } @@ -422,8 +422,8 @@ export function apply(ctx: Context, config: Config = {}): void { if (tasks === undefined) { throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') } - // Reject pre-start cancellation; returned tasks use their own lifecycle. - if (exec.signal?.aborted) throw new Error('command aborted') + // The caller owns cancellation until TaskService commits detached ownership. + if (exec.signal.aborted) return [] // Task preflight finishes before the starter can spawn a process. const id = tasks.start({ kind: 'bash', @@ -442,7 +442,7 @@ export function apply(ctx: Context, config: Config = {}): void { } const result = await ctx.bash.run(ctx.bash.resolve({ ...request, - ...exec.signal ? { signal: exec.signal } : {}, + signal: exec.signal, })) if (result.aborted) throw new Error('command aborted') return [{ type: 'text', text: renderResult(result, escalationModes) }] diff --git a/packages/bash/tool-bash/tests/bash-env.spec.ts b/packages/bash/tool-bash/tests/bash-env.spec.ts index 03d29b572b..d988075c5b 100644 --- a/packages/bash/tool-bash/tests/bash-env.spec.ts +++ b/packages/bash/tool-bash/tests/bash-env.spec.ts @@ -7,10 +7,13 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash' +const testToolSignal = new AbortController().signal + afterEach(() => vi.unstubAllEnvs()) function execution(sessionId?: string): ToolExecution { return { + signal: testToolSignal, token: Symbol('bash-env-test') as ToolExecution['token'], callId: CallId('bash-env-call'), name: 'bash', diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index b2a78ff9fe..c022a6135d 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -7,7 +7,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -21,6 +21,8 @@ import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { processOutcome } from '../src/background.ts' import { renderProcessRead, renderResult } from '../src/render.ts' +const testToolSignal = new AbortController().signal + const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-')) /** Foreground-only harness: no task runtime (backgrounding fails loud here). */ @@ -67,7 +69,7 @@ function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: un } let callCounter = 0 function call(ctx: Context, name: string, args: unknown, agent?: Agent) { - return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} }) + return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} }) } function text(result: { content: { type: string; text?: string }[] }): string { @@ -179,7 +181,11 @@ async function setupSandboxed(withApproval = false) { return { ctx, bash: ctx.bash as RecordingSandboxExecutor } } -function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', ctx?: Context): Agent { +function sandboxAgent( + mode?: 'read-only' | 'workspace-write' | 'danger-full-access', + ctx?: Context, + onAppend?: (type: string) => void, +): Agent { const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start' }] if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } }) const id = SessionId('sandbox-session') @@ -193,6 +199,7 @@ function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-acce append: (type: string, data: Record) => { const event = { type, data } events.push(event) + onAppend?.(type) return event }, }, @@ -451,7 +458,7 @@ describe('background execution through the task runtime', () => { expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') }) - it('a pre-aborted call refuses to start: isError, no process spawned', async () => { + it('a pre-aborted call is skipped before the process starts', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) @@ -470,7 +477,8 @@ describe('background execution through the task runtime', () => { signal: controller.signal, }) expect(result.isError).toBe(true) - expect(text(result)).toContain('command aborted') + expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(text(result)).toBe('Error: tool call aborted before dispatch') expect((ctx.bash as CountingStartExecutor).starts).toBe(0) }) @@ -597,6 +605,29 @@ describe('sandbox escalation through the generic task producer', () => { expect(bash.modes).toEqual(['workspace-write', 'workspace-write']) }) + it('does not publish detached work when cancellation follows the escalation grant', async () => { + const { ctx, bash } = await setupSandboxed(true) + const controller = new AbortController() + const agent = sandboxAgent(undefined, ctx, (type) => { + if (type === 'approval/decided') controller.abort() + }) + ctx.agents.register(agent) + ctx.on('approval/request', () => Promise.resolve('allowed-once')) + const start = vi.spyOn(bash, 'start') + + const result = await ctx.tools.execute({ + callId: CallId('cancelled-escalation-background'), + name: 'bash', + arguments: { ...escalate, run_in_background: true }, + agent, + signal: controller.signal, + }) + + expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED }) + expect(text(result)).toBe('Error: tool call aborted') + expect(start).not.toHaveBeenCalled() + }) + it('uses the session override for ordinary calls and evaluates widening against it', async () => { const { ctx, bash } = await setupSandboxed(true) const agent = sandboxAgent('workspace-write') @@ -730,7 +761,7 @@ describe('session-cwd routing (per-session workdir)', () => { it('falls back to the executor default when the agent has no session cwd', async () => { const ctx = await setup() // No exec.agent at all → executor uses its config/process.cwd() default. - const result = await ctx.tools.execute({ callId: CallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } }) expect(result.isError).toBe(false) expect(text(result).trim().length).toBeGreaterThan(0) }) @@ -1012,6 +1043,7 @@ describe('the model-facing bash tool builds its request from named args only (no const path = ctx.sessionPersistence.locate(agent.session.header)?.path await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('session-env-fg'), name: 'bash', arguments: { command: 'true', description: 'run command' }, @@ -1032,6 +1064,7 @@ describe('the model-facing bash tool builds its request from named args only (no const path = ctx.sessionPersistence.locate(agent.session.header)?.path await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('session-env-bg'), name: 'bash', arguments: { @@ -1058,6 +1091,7 @@ describe('the model-facing bash tool builds its request from named args only (no const ambient = process.env.DSH_SESSION_ID await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('session-env-id-only'), name: 'bash', arguments: { command: 'true', description: 'run command' }, @@ -1079,6 +1113,7 @@ describe('the model-facing bash tool builds its request from named args only (no for (const [callId, agent] of [['parent', parent], ['child', child]] as const) { await ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`session-env-${callId}`), name: 'bash', arguments: { command: 'true', description: 'run command' }, @@ -1109,6 +1144,7 @@ describe('the model-facing bash tool builds its request from named args only (no // This preserves the request shape; it is not a security boundary because shell syntax can // already set environment variables or feed stdin. await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('no-forward-1'), name: 'bash', arguments: { @@ -1130,6 +1166,7 @@ describe('the model-facing bash tool builds its request from named args only (no it('a background bash call likewise carries no trusted-only fields', async () => { const { ctx, bash } = await setupRecording() const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('no-forward-2'), name: 'bash', arguments: { diff --git a/packages/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json index a3145c2f52..00e9195f9f 100644 --- a/packages/bash/tool-bash/tsconfig.json +++ b/packages/bash/tool-bash/tsconfig.json @@ -33,7 +33,7 @@ "path": "../../bash/bash" }, { - "path": "../../util/home" + "path": "../../util/paths" }, { "path": "../../tasks/tasks" diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index 5da942603f..cd65f1aa3d 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -119,7 +119,7 @@ describe('time-context invariants', () => { it('rejects a reading after cancellation closes the turn', async () => { const ctx = await setup() const session = preparing(1, 2) - session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: 'cancelled' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) }) .toThrow(/inside an open turn/) }) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 07c2fe54ae..d1cd07207e 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -367,7 +367,7 @@ describe('real agent-loop request history', () => { ctx.on('agent/pre-step', (subject) => { laterSawReading = contextTexts(subject.session).length === 1 if (mode === 'throws') throw new Error('later pre-step failure') - subject.cancel('later pre-step cancellation') + subject.cancel({ kind: 'user' }) }) const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' }) diff --git a/packages/context/workspace-context/src/files.ts b/packages/context/workspace-context/src/files.ts index feb6304b4c..7a995c5886 100644 --- a/packages/context/workspace-context/src/files.ts +++ b/packages/context/workspace-context/src/files.ts @@ -9,7 +9,7 @@ import { lstat, stat } from 'node:fs/promises' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import type { FileSystem, FsInfo, FsPathInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs' import { assertNever } from '@deepseek-ai/dsh-llm' -import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths' +import { dshHomeDisplay } from '@deepseek-ai/dsh-paths' import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts' import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts' @@ -469,5 +469,5 @@ export async function readScopeInstruction( } function userGlobalDisplayPath(dshHome: string): string { - return dshHome === resolve(defaultDshHome()) ? `${DEFAULT_DSH_HOME_DISPLAY}/AGENTS.md` : '$DSH_HOME/AGENTS.md' + return `${dshHomeDisplay(dshHome)}/AGENTS.md` } diff --git a/packages/context/workspace-context/src/state.ts b/packages/context/workspace-context/src/state.ts index f2e113ddfe..0ab455349a 100644 --- a/packages/context/workspace-context/src/state.ts +++ b/packages/context/workspace-context/src/state.ts @@ -500,7 +500,7 @@ export async function dynamicInstructionContext( { touchedPath, includeBaselineScopes: baselineInstructionStates.has(agent.session), - ...exec.signal === undefined ? {} : { signal: exec.signal }, + signal: exec.signal, }, ) } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index e842dc98af..53f2ee9154 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -46,6 +46,8 @@ import { } from '../src/state.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +const testToolSignal = new AbortController().signal + async function tempRepo(): Promise { return mkdtemp(join(tmpdir(), 'dsh-workspace-context-')) } @@ -819,6 +821,7 @@ describe('workspace context request injection', () => { await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const decision = await postExecute(ctx, stubToolExecution({ + signal: testToolSignal, callId: CallId('no-fs-post-execute'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, @@ -854,6 +857,7 @@ describe('workspace context request injection', () => { const agent = stubAgent(root) const exec = stubToolExecution({ + signal: testToolSignal, callId: CallId('read-blocked-post-execute'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, @@ -999,6 +1003,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) await write(join(root, 'AGENTS.md'), 'new root rule with more detail') const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) @@ -1027,6 +1032,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) await rm(join(root, 'AGENTS.md')) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) @@ -1052,6 +1058,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-with-shared-global-root'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) @@ -1130,6 +1137,25 @@ describe('workspace context request injection', () => { } }) + it('keeps the direct provider API usable without an operation signal', async () => { + const root = '/virtual/no-signal-repo' + const home = '/virtual/no-signal-home' + const ctx = new Context() + try { + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'optional capability signal' }) + + const rendered = await loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 }, fs) + + expect(rendered?.text).toContain('optional capability signal') + expect(fs.signals).toEqual([]) + } finally { + await ctx.fiber.dispose() + } + }) + it('rejects a provider-sized instruction file before reading content', async () => { const root = join(await tempRepo(), 'virtual-repo') const home = join(await tempRepo(), 'virtual-home') @@ -1620,7 +1646,7 @@ describe('dynamic nested workspace context injection', () => { description: 'Abort the current test step.', parameters: {}, async execute() { - ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('test abort') + agent.cancel({ kind: 'user' }) return [{ type: 'text', text: 'aborted' }] }, })) @@ -1710,6 +1736,7 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-nested'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -1769,6 +1796,7 @@ describe('dynamic nested workspace context injection', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-configured-nested-candidate'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -1797,12 +1825,14 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-nested-1'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, agent, }) const second = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-nested-2'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -1835,10 +1865,12 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, first) const second = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-with-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) @@ -1870,14 +1902,17 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, first) fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-2') }) const afterVersionChange = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) const afterRefresh = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-version-cache-refresh'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) @@ -1908,9 +1943,11 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-from-first-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root), }) const second = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-from-second-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root), }) @@ -1936,11 +1973,13 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, first) await write(join(root, 'pkg/AGENTS.md'), 'new package rule with more detail') const changed = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) @@ -1976,15 +2015,18 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, first) await rm(join(root, 'pkg/AGENTS.md')) const changed = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, changed) const unchanged = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-logged-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) @@ -2015,11 +2057,13 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, first) await rm(join(root, 'pkg/AGENTS.md')) const removed = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) @@ -2053,17 +2097,20 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, first) await rm(join(root, 'pkg/AGENTS.md')) const removed = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-to-create-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, removed) await write(join(root, 'pkg/AGENTS.md'), 'restored package rule') const restored = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) @@ -2095,11 +2142,13 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) appendAdditionalContexts(agent, first) fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) const duringFailure = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-during-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }) @@ -2123,6 +2172,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-resume'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2135,6 +2185,7 @@ describe('dynamic nested workspace context injection', () => { } const afterResume = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-resume'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2160,6 +2211,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const original = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: original, }) appendAdditionalContexts(original, first) @@ -2190,6 +2242,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-before-compact'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2197,6 +2250,7 @@ describe('dynamic nested workspace context injection', () => { }) const contextSeq = appendAdditionalContexts(agent, first)! const visibleBeforeCompact = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-while-visible'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2212,6 +2266,7 @@ describe('dynamic nested workspace context injection', () => { }) const afterCompact = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-compact'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2241,6 +2296,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-package'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, @@ -2249,6 +2305,7 @@ describe('dynamic nested workspace context injection', () => { appendAdditionalContexts(agent, first) const second = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-subtree'), name: 'read', arguments: { file_path: 'pkg/sub/file.txt' }, @@ -2276,6 +2333,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-subtree-omitting-parent'), name: 'read', arguments: { file_path: 'pkg/sub/file.txt' }, @@ -2284,6 +2342,7 @@ describe('dynamic nested workspace context injection', () => { appendAdditionalContexts(agent, first) const second = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-parent-after-omit'), name: 'read', arguments: { file_path: 'pkg/other.txt' }, @@ -2345,6 +2404,7 @@ describe('dynamic nested workspace context injection', () => { }, { surfaceOp: 'append' }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-spoofed-state'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2371,12 +2431,14 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const rootResult = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-root-file'), name: 'read', arguments: { file_path: 'root.txt' }, agent, }) const absoluteResult = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-absolute-nested-file'), name: 'read', arguments: { file_path: join(root, 'pkg/deep/file.txt') }, @@ -2410,11 +2472,13 @@ describe('dynamic nested workspace context injection', () => { } const failedStat = await postExecute(ctx, stubToolExecution({ + signal: testToolSignal, callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }), result, async () => ({ kind: 'accept' as const })) fs.throwOnStat.clear() fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'directory' }) const mismatchedStat = await postExecute(ctx, stubToolExecution({ + signal: testToolSignal, callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent, }), result, async () => ({ kind: 'accept' as const })) @@ -2440,6 +2504,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-with-unreadable-nested-instruction'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2474,6 +2539,7 @@ describe('dynamic nested workspace context injection', () => { })) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-with-downstream'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2518,6 +2584,7 @@ describe('dynamic nested workspace context injection', () => { })) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-blocked-downstream'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2558,6 +2625,7 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const blocked = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('outer-block-first'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2565,6 +2633,7 @@ describe('dynamic nested workspace context injection', () => { }) shouldBlock = false const accepted = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('outer-block-retry'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2605,7 +2674,7 @@ describe('dynamic nested workspace context injection', () => { arguments: { file_path: 'pkg/deep/file.txt' }, ...exec.agent === undefined ? {} : { agent: exec.agent }, parent: exec.token, - ...exec.signal === undefined ? {} : { signal: exec.signal }, + signal: exec.signal, }) for (const context of nested.additionalContexts ?? []) exec.deferContext(context) return nested.content @@ -2622,10 +2691,12 @@ describe('dynamic nested workspace context injection', () => { const agent = stubAgent(root) const blocked = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('composite-first'), name: 'composite-read', arguments: {}, agent, }) shouldBlock = false const accepted = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('composite-retry'), name: 'composite-read', arguments: {}, agent, }) @@ -2649,19 +2720,23 @@ describe('dynamic nested workspace context injection', () => { const plainResult = { callId: CallId('plain'), content: [], isError: false } emitToolResult(ctx, stubToolExecution({ + signal: testToolSignal, callId: CallId('agentless-child'), name: 'read', arguments: {}, parent, }), plainResult) emitToolResult(ctx, stubToolExecution({ + signal: testToolSignal, callId: CallId('contextless-child'), name: 'read', arguments: {}, agent, parent, }), { ...plainResult, additionalContexts: [{ content: [], source: { kind: 'plugin', plugin: 'workspace-context' } }] }) emitToolResult(ctx, stubToolExecution({ + signal: testToolSignal, callId: CallId('first-child'), name: 'read', arguments: {}, agent, parent, }), { ...plainResult, additionalContexts: [workspaceChangeContext('first', 'one')] }) emitToolResult(ctx, stubToolExecution({ + signal: testToolSignal, callId: CallId('second-child'), name: 'read', arguments: {}, agent, parent, }), { ...plainResult, additionalContexts: [workspaceChangeContext('second', 'two')] }) emitToolResult(ctx, { - ...stubToolExecution({ callId: CallId('agentless-parent'), name: 'composite', arguments: {} }), + ...stubToolExecution({ signal: testToolSignal, callId: CallId('agentless-parent'), name: 'composite', arguments: {} }), token: parent, }, plainResult) @@ -2697,6 +2772,7 @@ describe('dynamic nested workspace context injection', () => { for (const item of cases) { const decision = await postExecute(ctx, stubToolExecution({ + signal: testToolSignal, callId: CallId(`manual-${item.name}-${cases.indexOf(item)}`), name: item.name, arguments: item.arguments, @@ -2721,6 +2797,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 0 }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-with-disabled-budget'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, @@ -2745,6 +2822,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-missing'), name: 'read', arguments: { file_path: 'pkg/missing.txt' }, @@ -2771,6 +2849,7 @@ describe('dynamic nested workspace context injection', () => { await fiber.dispose() const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('read-after-dispose'), name: 'read', arguments: { file_path: 'pkg/deep/file.txt' }, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index e764d9af6c..6335c7d281 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -653,7 +653,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async execute(exec: ToolExecutionInput): Promise', - jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */', + jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */', }, ], }, @@ -717,9 +717,9 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/cancel-requested', mode: 'emit', - signature: '\'agent/cancel-requested\'(this: Scoped, agent: Agent, reason: string): void', - jsDoc: '/**\n * Effective broad cancellation was requested, before queued/steering work\n * is cleared or the active step is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param reason - resolved cancellation reason, including the default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'Effective broad cancellation was requested, before queued/steering work is cleared or the active step is aborted.', + signature: '\'agent/cancel-requested\'(this: Scoped, agent: Agent, cause: AgentCancelCause): void', + jsDoc: '/**\n * Effective broad cancellation was requested, before queued/steering work\n * is cleared or the active turn is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param cause - resolved typed cancellation cause, including the default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'Effective broad cancellation was requested, before queued/steering work is cleared or the active turn is aborted.', }, { name: 'agent/created', @@ -759,8 +759,8 @@ 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', - jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, signal: AbortSignal, next: () => Promise): Promise', + jsDoc: '/**\n * Allow, rewrite, or block one claimed prompt before it becomes a user\n * message. Call `next()` for the unchanged default. The signal controls only\n * this turn; listeners may cooperate with it but must not retain it to\n * control another turn.\n * @param agent - the agent whose turn claimed the message.\n * @param content - the claimed message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Allow, rewrite, or block one claimed prompt before it becomes a user message.', }, { @@ -773,8 +773,8 @@ 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', - jsDoc: '/**\n * Replace the frozen call configuration. Model-visible content must use\n * logged channels; this seam cannot mutate messages. Injection here joins\n * the next request because the current step boundary is already fixed.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param config - the config the loop would use (frozen); return a replacement to switch.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, signal: AbortSignal, next: () => Promise): Promise', + jsDoc: '/**\n * Replace the frozen call configuration. Model-visible content must use\n * logged channels; this seam cannot mutate messages. Injection here joins\n * the next request because the current step boundary is already fixed.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param config - the config the loop would use (frozen); return a replacement to switch.\n * @param signal - the current turn\'s explicit abort signal; ambient\n * initiator identity does not imply liveness or cancellation authority.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Replace the frozen call configuration.', }, { @@ -788,7 +788,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/session-prefix', mode: 'waterfall', signature: '\'agent/session-prefix\'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise', - jsDoc: '/**\n * Compose request-only messages placed before derived history. The frozen\n * result is computed once per loop instance, logged on its anchoring request\n * header, and reused so the provider prefix remains stable. Interrupted\n * composition is discarded. Composition precedes the first `agent/pre-step`\n * and request boundary, so listener appends join the current request.\n * Changing context belongs in history; contributors should prepend to\n * `await next()` to preserve registration order.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - the agent whose session prefix is being composed.\n * @param prefix - the frozen seed; return an extended replacement.\n * @param signal - aborts composition when the step is torn down.\n * @mode waterfall\n */', + jsDoc: '/**\n * Compose request-only messages placed before derived history. The frozen\n * result is computed once per loop instance, logged on its anchoring request\n * header, and reused so the provider prefix remains stable. Interrupted\n * composition is discarded. Composition precedes the first `agent/pre-step`\n * and request boundary, so listener appends join the current request.\n * Changing context belongs in history; contributors should prepend to\n * `await next()` to preserve registration order.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - the agent whose session prefix is being composed.\n * @param prefix - the frozen seed; return an extended replacement.\n * @param signal - the current turn\'s explicit abort signal.\n * @mode waterfall\n */', summary: 'Compose request-only messages placed before derived history.', }, { @@ -808,22 +808,22 @@ 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', - jsDoc: '/**\n * Waterfall: post-process the assembled assistant {@link Message} before\n * tool dispatch (validation, content rewriting, …).\n * @param agent - the agent that received the step\'s response.\n * @param turn - the open turn number.\n * @param step - the step that produced the message.\n * @param message - the assistant message as assembled from the stream.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/step-result\'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, signal: AbortSignal, next: () => Promise): Promise', + jsDoc: '/**\n * Waterfall: post-process the assembled assistant {@link Message} before\n * tool dispatch (validation, content rewriting, …).\n * @param agent - the agent that received the step\'s response.\n * @param turn - the open turn number.\n * @param step - the step that produced the message.\n * @param message - the assistant message as assembled from the stream.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', 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', - jsDoc: '/**\n * Override whether the turn continues. The default continues after tool\n * calls or steering and stops otherwise; a continue reason becomes steering.\n * @param agent - the agent deciding whether to run another step.\n * @param turn - the turn being continued or stopped.\n * @param defaultDecision - what the loop would do absent an override.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + signature: '\'agent/turn-continuation\'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, signal: AbortSignal, next: () => Promise): Promise', + jsDoc: '/**\n * Override whether the turn continues. The default continues after tool\n * calls or steering and stops otherwise; a continue reason becomes steering.\n * @param agent - the agent deciding whether to run another step.\n * @param turn - the turn being continued or stopped.\n * @param defaultDecision - what the loop would do absent an override.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Override whether the turn continues.', }, { name: 'agent/turn-stop', mode: 'serial', - signature: '\'agent/turn-stop\'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined', - jsDoc: '/**\n * Monotonic terminal-stop checkpoint after continuation and steering are\n * folded; a stop remains authoritative through turn close and flush:\n * steering queued in that window is discarded, while ordinary sends survive.\n * @param agent - the agent whose composed continuation outcome may be stopped.\n * @param turn - the turn at its terminal-stop checkpoint.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', + signature: '\'agent/turn-stop\'(this: Scoped, agent: Agent, turn: number, signal: AbortSignal): Promise | ContinuationStop | undefined', + jsDoc: '/**\n * Monotonic terminal-stop checkpoint after continuation and steering are\n * folded; a stop remains authoritative through turn close and flush:\n * steering queued in that window is discarded, while ordinary sends survive.\n * @param agent - the agent whose composed continuation outcome may be stopped.\n * @param turn - the turn at its terminal-stop checkpoint.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', 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.', }, { @@ -935,7 +935,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'system-prompt/assemble', mode: 'waterfall', signature: '\'system-prompt/assemble\'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise', - jsDoc: '/**\n * Expert waterfall over the assembled sections, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */', + jsDoc: '/**\n * Expert waterfall over the assembled sections, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */', summary: 'Expert waterfall over the assembled sections, tools, and variables.', }, { @@ -955,22 +955,22 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'tools/execute', mode: 'waterfall', - signature: '\'tools/execute\'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise', - jsDoc: '/**\n * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns\n * a normalized result; wrappers may change only `exec.signal`, while call\n * identity remains immutable.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).\n * @mode waterfall\n */', + signature: '\'tools/execute\'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise', + jsDoc: '/**\n * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns\n * a normalized result; wrappers may change only `exec.signal`, while call\n * identity remains immutable. The registry re-fuses the original caller\n * signal before the body, so replacement cannot detach caller cancellation;\n * wrappers must still restore their signal and reach quiescence.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).\n * @mode waterfall\n */', summary: 'Around-dispatch waterfall for timeout, retry, or metrics.', }, { name: 'tools/post-execute', mode: 'waterfall', signature: '\'tools/post-execute\'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise', - jsDoc: '/**\n * Accept, replace, enrich, or block a normalized dispatch result. `next()`\n * accepts it unchanged; thrown tools still reach this seam as errors.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the call that just ran (name, parsed arguments, caller agent).\n * @param result - the dispatch outcome a listener may accept, replace, or block.\n * @mode waterfall\n */', + jsDoc: '/**\n * Accept, replace, enrich, or block a normalized dispatch result. `next()`\n * accepts it unchanged; thrown tools still reach this seam as errors. Async\n * listeners must observe `exec.signal`; after they settle, caller\n * cancellation replaces only a successful accepted outcome with the code\n * selected by whether the tool body was invoked.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the call that just ran (name, parsed arguments, caller agent).\n * @param result - the dispatch outcome a listener may accept, replace, or block.\n * @mode waterfall\n */', summary: 'Accept, replace, enrich, or block a normalized dispatch result.', }, { name: 'tools/pre-execute', mode: 'waterfall', signature: '\'tools/pre-execute\'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise', - jsDoc: '/**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */', + jsDoc: '/**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */', summary: 'Allow, deny, or ask before dispatch.', }, { @@ -1028,7 +1028,11 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\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?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\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?: InjectOptions): 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: 'AgentFactory', @@ -1080,7 +1084,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', @@ -1716,7 +1720,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolExecutionInput', - declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n}', + declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n}', }, { name: 'ToolExecutionMode', @@ -1768,7 +1772,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 } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\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 } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\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/cordis/tool-cordis/tests/helpers.ts b/packages/cordis/tool-cordis/tests/helpers.ts index b183a2444f..e945249814 100644 --- a/packages/cordis/tool-cordis/tests/helpers.ts +++ b/packages/cordis/tool-cordis/tests/helpers.ts @@ -6,6 +6,8 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import type { ToolDefinition, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import * as tool from '../src/index.ts' +const testToolSignal = new AbortController().signal + /** * Shared spec helpers: a real `SystemPrompt` + `ToolRegistry` + timer + * tool-cordis tree (only the model is absent — the code strings below stand in @@ -27,7 +29,7 @@ let callCounter = 0 /** Execute a registered tool through the real registry pipeline. */ export function call(ctx: Context, name: string, args: unknown): Promise { - return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args }) + return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args }) } /** Concatenated text blocks of one tool result. */ diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 1197a018be..f19c4f5471 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -60,7 +60,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history. -Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. Effective cancellation resolves its reason and emits `agent/cancel-requested` before clearing pending work or aborting the current step; notification failures are contained, queued work added by an observer is included in the same broad clear, and idle cancellation emits nothing. Undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush. +Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, and terminal stop; it retires the holder immediately before publishing `turn/end`, while the driver may remain `running` through the durability flush. An effective `cancel()` emits the typed runtime-only `user | parent` cause before clearing pending work and cooperatively aborting the holder; notification failures cannot veto cancellation, work queued by a notification observer is cleared, work queued by a later abort observer belongs to the next turn, and idle cancellation emits nothing. Durable `turn/end` remains coarse `aborted`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path. @@ -109,7 +109,7 @@ Ordinary history growth is append-only and preserves reusable entries. A surface #### What the model sees -If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has the error result text `Error: tool call skipped because the step was aborted before execution`. +If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has error code `ABORTED_BEFORE_DISPATCH` and result text `Error: tool call aborted before dispatch`. #### Token effect diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 847a15d64f..78f39a7df4 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -8,11 +8,12 @@ import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent' +import type { AgentCancelCause, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { snapshotJsonValue, type Session, type SessionId } 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' @@ -95,7 +96,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), turn cancellation, and * the loop driver. Everything observable happens through session events and * the agent/* event taxonomy — plugins never need this class. */ @@ -120,21 +121,14 @@ export class ReactLoopAgent implements Agent { } private _status: AgentStatus = 'idle' - private currentAbort: AbortController | undefined + /** Active turn owner from pre-running publication through durability settlement. */ + 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. - */ - private cancelRequested = false - /** Pending cancellation reason, preserved even outside an active step signal. */ - private cancelReason = 'cancelled' + /** Cause-less marker for queued work cancelled before the driver installs a turn owner. */ + private preRunCancelled = false private disposed: Promise private resolveDisposed!: () => void /** Resolves when the driver loop has fully exited (tests/disposal). */ @@ -330,29 +324,21 @@ export class ReactLoopAgent implements Agent { } } - cancel(reason?: string): void { - const resolvedReason = reason ?? 'cancelled' - // 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 = resolvedReason + cancel(cause?: AgentCancelCause): void { + const resolvedCause = cause ?? { kind: 'user' } + const cancellation = this.turnCancellation + const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering) + if (cancellation !== undefined || preRun) { + if (preRun) this.preRunCancelled = true // Coordination consumers must update their own state before this call - // clears the inbox or aborts the step. Notification failures are + // clears the inbox or aborts the turn. Notification failures are // contained by the fused dispatcher and cannot veto cancellation. - agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedReason) + agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause) } - // 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. + // Clear work already present before abort observers run. A replacement + // synchronously enqueued by an observer belongs to the next turn. 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(resolvedReason) + cancellation?.request(resolvedCause) } /** @@ -394,14 +380,21 @@ export class ReactLoopAgent implements Agent { inbox: this.#inbox, maxParallelToolCalls: this.maxParallelToolCalls, 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 driver clears only the exact owner 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 }, + isPreRunCancelled: () => this.preRunCancelled, + clearPreRunCancel: () => { this.preRunCancelled = false }, withToolBatch: run => this.withToolBatch(run), - // Pre-start cancellation settles queued-work waiters before publishing idle. + // Pre-run cancellation settles queued-work waiters before publishing idle. settleIdle: () => { this.settleIdleWaiters() }, })) } @@ -419,7 +412,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..c3f5430a20 --- /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 typed 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(Object.freeze({ kind: reason.kind })) + return true + } +} diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index b26a79a1ef..72c51e44fb 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 c14e126566..758773852a 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -9,7 +9,7 @@ import type { Context } from 'cordis' import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm' import { isDeepStrictEqual } from 'node:util' import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf } from '@deepseek-ai/dsh-llm' -import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' +import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' @@ -21,6 +21,7 @@ import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { executeToolCalls } from './tool-calls.ts' import type { Inbox } from './inbox.ts' +import type { TurnCancellation } from './cancellation.ts' /** Normalize thrown values while preserving an existing error code. */ function toError(error: unknown): RequestError { @@ -89,6 +90,32 @@ 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') + } +} + /** Mutable agent controls supplied to the loop driver. */ export interface LoopHandle { /** Native-private agent inbox handed to the driver only at internal startup. */ @@ -96,16 +123,17 @@ export interface LoopHandle { /** Maximum parallel-safe calls allowed in one step. */ readonly maxParallelToolCalls: number 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 reached its terminal event boundary. */ + 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 before pre-running cancellation publishes idle. */ settleIdle(): void /** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */ @@ -120,7 +148,7 @@ export interface LoopHandle { * @param ctx - the plugin context the loop reaches its initiating Agent, * events (agent/…, session/flush), and services (systemPrompt, llm, tools) * through. - * @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. * @throws when no initiating Agent is active. */ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { @@ -135,8 +163,8 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { while (!handle.isDisposed()) { // An idle listener can enqueue and cancel replacement work before the next // wait is installed. Consume that empty marker before parking the driver. - if (handle.isCancelled()) { - handle.clearCancel() + if (handle.isPreRunCancelled()) { + handle.clearPreRunCancel() if (!handle.inbox.hasQueued) { handle.settleIdle() handle.setStatus('idle') @@ -149,8 +177,8 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { // Cancellation between wake and `running` skips only the cancelled work; // a replacement prompt still runs before the eventual idle transition. - if (handle.isCancelled()) { - handle.clearCancel() + if (handle.isPreRunCancelled()) { + handle.clearPreRunCancel() if (!handle.inbox.hasQueued) { // Settle before publishing idle: the already-idle path has no status // transition, while an idle listener can register waiters for new work. @@ -160,24 +188,29 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { } } + let cancellation = handle.installTurnCancellation() handle.setStatus('running') - if (handle.isDisposed()) break + if (handle.isDisposed()) { + handle.clearTurnCancellation(cancellation) + break + } // 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 (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, handle, turn, transmission) + terminalStopped = await runTurn(ctx, events, handle, turn, transmission, cancellation) } catch (error: unknown) { // Pre-turn failure has no durable boundary to close; report it without appending outside a turn. const err = toError(error) @@ -185,11 +218,10 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { 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) @@ -201,9 +233,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise { async function runTurn( ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog, + cancellation: TurnCancellation, ): Promise { const agent = ctx.agents.requireInitiator() const { session } = agent + const { signal } = cancellation const drainSteering = (): boolean => { const messages = handle.inbox.drainSteering() for (const message of messages) { @@ -247,8 +281,11 @@ async function runTurn( } } - // Pre-commit validation failure escapes rather than masquerading as a committed boundary. + // Retire cancellation authority before publishing the terminal event. The + // following durability flush is quiescent turn work, but no longer part of + // the cancellable turn lifetime. const closeTurn = (): void => { + handle.clearTurnCancellation(cancellation) session.append('turn/end', { turn, reason }) } @@ -257,15 +294,17 @@ 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) // The claimed 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; // turn/end is now owed, so a throwing prompt-submit listener (the waterfall // throws) is caught below and the turn still closes. const promptDecision = 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 (promptDecision.kind === 'block') { session.append('prompt/blocked', { content: message.content, source: message.source, reason: promptDecision.reason }) reason = { kind: 'rejected', reason: promptDecision.reason } @@ -293,53 +332,28 @@ async function runTurn( // the request. drainSteering() - // 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 listener work and the request share one prompt value. - 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 the first // request boundary. 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 before snapshotting history. - await events.serial('agent/pre-step', turn, step, 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, signal) + interruptionCheckpoint(signal) // Snapshot the exact log prefix before step/start: the reconstruction // boundary. Appends after this synchronous snapshot join the next request. @@ -351,16 +365,8 @@ async function runTurn( // 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 } @@ -368,7 +374,7 @@ async function runTurn( | { error: RequestError } try { stepOutcome = await runStep( - ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal) + ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal) } catch (error: unknown) { if (error instanceof TerminalModelRequestFailure) { stepOutcome = { requestError: error.requestError, failure: error.failure } @@ -381,11 +387,9 @@ async function runTurn( // Recovery observes a balanced failed step and the original provider // error while the failed step's signal remains the active owner. closeStep() - if (handle.isDisposed() || abort.signal.aborted) { - handle.setAbort(undefined) - reason = handle.isDisposed() - ? { kind: 'disposed' } - : { kind: 'aborted', reason: String(abort.signal.reason) } + const interrupted = interruptionTurnEndReason(handle, signal) + if (interrupted !== undefined) { + reason = interrupted break } @@ -394,7 +398,7 @@ async function runTurn( try { recoveryDecision = await events.waterfall( 'agent/request-error', turn, step, stepOutcome.requestError, - stepOutcome.failure, requestFailureHistory, abort.signal, + stepOutcome.failure, requestFailureHistory, signal, () => Promise.resolve(defaultDecision), ) } catch (recoveryError: unknown) { @@ -402,15 +406,11 @@ async function runTurn( `agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`, ) } - handle.setAbort(undefined) - // Cancellation and disposal always win over either a recovery decision // or a recovery-listener failure. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (handle.isDisposed() || abort.signal.aborted) { - reason = handle.isDisposed() - ? { kind: 'disposed' } - : { kind: 'aborted', reason: String(abort.signal.reason) } + const recoveryInterrupted = interruptionTurnEndReason(handle, signal) + if (recoveryInterrupted !== undefined) { + reason = recoveryInterrupted break } switch (recoveryDecision.action) { @@ -432,17 +432,10 @@ async function runTurn( // runLoop re-enqueues it as a queued message, so an abort-then-steer // starts a fresh turn instead of being silently consumed. closeStep() - handle.setAbort(undefined) const { error } = stepOutcome - /* v8 ignore next -- narrow race: disposal while non-request step work throws. */ - 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 interrupted = interruptionTurnEndReason(handle, signal) + if (interrupted === undefined) failTurn(error) + else reason = interrupted break } @@ -456,48 +449,40 @@ async function runTurn( const steered = drainSteering() try { - await events.serial('agent/post-step', turn, step, abort.signal) + await events.serial('agent/post-step', turn, step, signal) } catch (error: unknown) { stepOutcome = { error: toError(error) } } if ('error' in stepOutcome) { closeStep() - handle.setAbort(undefined) - /* v8 ignore next -- narrow race: disposal while a post-step listener throws. */ - if (handle.isDisposed()) { - reason = { kind: 'disposed' } - } else if (abort.signal.aborted) { - /* v8 ignore next -- signal.reason always set by cancellation or disposal. */ - reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') } - } else { - failTurn(stepOutcome.error) - } + const interrupted = interruptionTurnEndReason(handle, signal) + if (interrupted === undefined) failTurn(stepOutcome.error) + else reason = interrupted break } - if (handle.isDisposed() || abort.signal.aborted) { - reason = handle.isDisposed() - ? { kind: 'disposed' } - : { kind: 'aborted', reason: String(abort.signal.reason) } + const postStepInterrupted = interruptionTurnEndReason(handle, signal) + if (postStepInterrupted !== undefined) { + reason = postStepInterrupted closeStep() - handle.setAbort(undefined) break } closeStep() - handle.setAbort(undefined) 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 interrupted = interruptionTurnEndReason(handle, signal) + if (interrupted === undefined) failTurn(toError(error)) + else reason = interrupted break } @@ -513,12 +498,15 @@ 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 interrupted = interruptionTurnEndReason(handle, signal) + if (interrupted === undefined) failTurn(toError(error)) + else reason = interrupted break } if (terminalStop) { @@ -528,17 +516,7 @@ 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' } - break - } + if (!shouldContinue) break } // Normal / inline-error loop exit: close the turn. @@ -548,12 +526,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 interrupted = interruptionTurnEndReason(handle, signal) + if (interrupted === undefined) failTurn(toError(error)) + else reason = interrupted closeTurn() } @@ -602,7 +577,10 @@ async function runStep( : { provider: options.provider ?? '', 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.provider || !config.model) { throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`) } @@ -639,8 +617,7 @@ async function runStep( const stream = ctx.llm.stream(request) try { for await (const chunk of stream) { - /* 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) @@ -650,6 +627,7 @@ async function runStep( if (failure !== undefined && error instanceof Error) throw new TerminalModelRequestFailure(error, failure) throw error } + interruptionCheckpoint(signal) // Normalize failure finish chunks into the same path as thrown stream errors. const stepError = finishError(assembler.finish) @@ -680,9 +658,11 @@ async function runStep( // A rejected result still records the successful provider call without retaining rejected output. const processStepResult = async (assembledContent: ContentBlock[], message: Message): Promise => { try { - return await events.waterfall( - 'agent/step-result', turn, step, message, () => Promise.resolve(message), + const processed = await events.waterfall( + 'agent/step-result', turn, step, message, signal, () => Promise.resolve(message), ) + interruptionCheckpoint(signal) + return processed } catch (error: unknown) { recordAssistantMessage(assembledContent, { ...message, content: [] }, false) throw error diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index 663dda1b53..64c6c69cf6 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -13,7 +13,7 @@ import type { Context } from 'cordis' import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm' import type { HookContext } from '@deepseek-ai/dsh-agent' import type { Session } from '@deepseek-ai/dsh-session' -import { TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools' +import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools' /** One tool call after argument parsing, ready to schedule. */ interface PlannedCall { @@ -217,9 +217,9 @@ async function runGroup( function appendSkippedToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): void { const callSeq = appendToolCall(session, turn, step, block) appendToolResult(session, turn, step, block, { - content: [{ type: 'text', text: 'Error: tool call skipped because the step was aborted before execution' }], + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], isError: true, - error: { name: 'AbortError', code: 'ABORTED' }, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, }, callSeq) } diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 8e9b951fa1..70c02bbeed 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -9,6 +9,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +const testToolSignal = new AbortController().signal + interface Harness { ctx: Context agentsFiber: Fiber @@ -142,6 +144,80 @@ describe('AgentLoop initiator scope', () => { await ctx.fiber.dispose() }) + it('keeps initiator 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(SessionId('signal-owner'), { provider: 'mock', model: 'mock' }) + let signals: AbortSignal[] = [] + const capture = (signal: AbortSignal | undefined): void => { + if (signal === undefined) throw new Error('turn seam omitted its explicit signal') + expect(ctx.agents.requireInitiator()).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, 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.agents.currentInitiator()).toBeUndefined() + await ctx.fiber.dispose() + }) + it('keeps child setup under the parent boundary and restores the parent while the child driver remains active', async () => { const adapter = new MockAdapter([ toolCallResponse('spawn', 'spawn-child', {}), @@ -239,6 +315,7 @@ describe('AgentLoop initiator scope', () => { })) const direct = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('direct'), name: 'agentless-probe', arguments: {}, diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index d23b69efb7..2cb3191f83 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -345,7 +345,7 @@ describe('Agent', () => { 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 f6df171365..162d3b552a 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 signal reset and `whenIdle()` quiescence. * @module dsh-agent-loop/tests/cancel */ @@ -12,7 +11,7 @@ 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, { defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -61,22 +60,22 @@ describe('Agent.cancel()', () => { const agent = ctx.agentLoop.create(SessionId('cancel-event'), { provider: 'mock', model: 'mock' }) const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const seen: string[] = [] - ctx.on('agent/cancel-requested', (subject, reason) => { + ctx.on('agent/cancel-requested', (subject, cause) => { if (subject !== agent) return - seen.push(`first:${reason}`) + seen.push(`first:${cause.kind}`) subject.send([{ type: 'text', text: 'queued by cancel observer' }]) throw new Error('observer failed') }) - ctx.on('agent/cancel-requested', (subject, reason) => { - if (subject === agent) seen.push(`second:${reason}`) + ctx.on('agent/cancel-requested', (subject, cause) => { + if (subject === agent) seen.push(`second:${cause.kind}`) }) send(agent, 'drop me') agent.cancel() await new Promise(resolve => setTimeout(resolve, 30)) - agent.cancel('idle no-op') + agent.cancel({ kind: 'parent' }) - expect(seen).toEqual(['first:cancelled', 'second:cancelled']) + expect(seen).toEqual(['first:user', 'second:user']) expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toHaveLength(0) expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested')) @@ -89,7 +88,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) @@ -108,7 +107,7 @@ describe('Agent.cancel()', () => { // resumed). Cancel in that pre-step window: the queued turn must not run. send(agent, 'drop me first') send(agent, 'drop me second') - 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)) @@ -157,7 +156,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([ @@ -186,7 +185,7 @@ describe('Agent.cancel()', () => { // before its resolved waitForQueued continuation checks cancellation. queueMicrotask(() => { queueMicrotask(() => { - agent.cancel('between turns') + agent.cancel({ kind: 'user' }) cancelled.resolve(undefined) }) }) @@ -236,7 +235,7 @@ describe('Agent.cancel()', () => { ctx.on('agent/error', (subject, _turn, _step, error) => { if (subject !== agent || error.message !== 'first flush failed') return queueMicrotask(() => { - queueMicrotask(() => { agent.cancel('between turns') }) + queueMicrotask(() => { agent.cancel({ kind: 'user' }) }) }) }) @@ -277,7 +276,7 @@ describe('Agent.cancel()', () => { requests: adapter.requests.length, turns: agent.session.events.filter(event => event.type === 'turn/start').length, })) - agent.cancel('idle listener') + agent.cancel({ kind: 'user' }) replacementRegistered.resolve(undefined) }) @@ -307,7 +306,7 @@ describe('Agent.cancel()', () => { ctx.on('agent/status', (subject, status) => { if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return send(agent, 'cancelled replacement') - agent.cancel('idle listener') + agent.cancel({ kind: 'user' }) send(agent, 'surviving replacement') replacementIdle = agent.whenIdle() replacementRegistered.resolve(undefined) @@ -334,16 +333,16 @@ describe('Agent.cancel()', () => { await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') send(agent, 'queued tail') - 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' }]) expect(userTexts(agent)).toEqual(['go']) expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) expect(adapter.requests).toHaveLength(1) }) - 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(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -353,10 +352,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('cancel from an assistant/message observer skips execution but balances replay', async () => { @@ -378,7 +377,7 @@ describe('Agent.cancel()', () => { const agent = ctx.agentLoop.create(SessionId('cancel-after-assistant-message'), { provider: 'mock', model: 'mock' }) const dispose = ctx.on('session/event', (session, event) => { if (session === agent.session && event.type === 'assistant/message') { - agent.cancel('cancelled after assistant message') + agent.cancel({ kind: 'user' }) } }) @@ -390,14 +389,14 @@ describe('Agent.cancel()', () => { dispose() expect(executions).toBe(0) - expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled after assistant message' }]) + expect(reasons).toEqual([{ kind: 'aborted' }]) const call = agent.session.events.find(event => event.type === 'tool/call') const result = agent.session.events.find(event => event.type === 'tool/result') expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1') expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({ callId: 'c1', isError: true, - error: { name: 'AbortError', code: 'ABORTED' }, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, }) send(agent, 'continue safely') @@ -407,7 +406,7 @@ describe('Agent.cancel()', () => { .find(block => block.type === 'tool-result') expect(replayedResult).toMatchObject({ toolCallId: 'c1', isError: true }) expect(reasons).toEqual([ - { kind: 'aborted', reason: 'cancelled after assistant message' }, + { kind: 'aborted' }, { kind: 'completed' }, ]) }) @@ -420,7 +419,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 @@ -445,7 +444,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() }) @@ -456,7 +455,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 () => { @@ -508,7 +507,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()] @@ -536,7 +535,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 === 'turn/start') agent.cancel('from turn-start') + if (session === agent.session && event.type === 'turn/start') agent.cancel({ kind: 'user' }) }) const reasons: TurnEndReason[] = [] @@ -547,10 +546,10 @@ describe('Agent.cancel()', () => { 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 + // the caller's cause — the marker carries `cancel(cause)` through even // though no AbortController observed it in this window. 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 () => { @@ -565,7 +564,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[] = [] @@ -575,10 +574,10 @@ describe('Agent.cancel()', () => { await waitForIdle(ctx, agent) dispose() - // No step streamed, the turn ended aborted with the caller's reason, and the + // No step streamed, the turn ended with the coarse aborted outcome, 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) }) @@ -636,11 +635,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() }) @@ -649,10 +648,9 @@ describe('Agent.cancel()', () => { 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). + // and the shared turn signal classified the durable outcome as aborted. 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 () => { @@ -665,7 +663,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') @@ -688,7 +686,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') }) @@ -713,7 +711,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 @@ -736,7 +734,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 @@ -752,4 +750,228 @@ describe('Agent.cancel()', () => { .flatMap(b => b.type === 'text' ? [b.text] : []) expect(flat).not.toContain('steer text') }) + + 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(SessionId('abort-observer-replacement'), { provider: 'mock', 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('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(SessionId('typed-first-wins'), { provider: 'mock', model: 'mock' }) + const supplied: { kind: 'parent' | 'user' } = { kind: 'parent' } + + send(agent, 'go') + await expect.poll(() => adapter.requests.length).toBe(1) + 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('retires turn cancellation before terminal publication and a blocked durability flush', async () => { + const adapter = new MockAdapter([textResponse('done')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('terminal-cancellation-authority'), { provider: 'mock', model: 'mock' }) + const flushStarted = Promise.withResolvers() + const releaseFlush = Promise.withResolvers() + let abortedDuringTurnEnd: boolean | undefined + let cancelNotifications = 0 + + ctx.on('agent/cancel-requested', (subject) => { + if (subject === agent) cancelNotifications += 1 + }) + ctx.on('session/event', (session, event) => { + if (session !== agent.session || event.type !== 'turn/end') return + const signal = adapter.requests[0]?.signal + if (signal === undefined) throw new Error('model request omitted its turn signal') + agent.cancel({ kind: 'user' }) + abortedDuringTurnEnd = signal.aborted + }) + ctx.on('session/flush', async (session) => { + if (session !== agent.session) return + flushStarted.resolve(undefined) + await releaseFlush.promise + }) + + send(agent, 'finish before persistence drains') + await flushStarted.promise + const signal = adapter.requests[0]?.signal + if (signal === undefined) throw new Error('model request omitted its turn signal') + const idle = agent.whenIdle() + agent.cancel({ kind: 'user' }) + + expect(abortedDuringTurnEnd).toBe(false) + expect(signal.aborted).toBe(false) + expect(cancelNotifications).toBe(0) + expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({ + data: { reason: { kind: 'completed' } }, + }) + + releaseFlush.resolve(undefined) + await idle + expect(agent.status).toBe('idle') + }) + + 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({ + sessionId: SessionId('cancel-dispose-race'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const { agent } = handle + + send(agent, 'go') + await expect.poll(() => adapter.requests.length).toBe(1) + 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', + 'post-step', + '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(SessionId(`cooperative-${stage}`), { provider: 'mock', 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, 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 'post-step': + ctx.on('agent/post-step', async (subject, _turn, _step, signal) => { + if (subject !== agent) return + await blockUntilAbort(signal) + throw new Error('post-step failed after cancellation') + }) + 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 6900faddb0..dffbd1220c 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import LlmService, { CallId, ContentBlock, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' @@ -73,7 +73,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 { @@ -214,7 +214,7 @@ describe('successful provider completion survives agent/step-result failure', () }) describe('abort during tool execution ends the turn', () => { - it('balances an aborted tool batch through context, steering, and post-step before closing', async () => { + it('balances a cancelled tool batch through context and post-step before closing', async () => { const adapter = new MockAdapter([ // model asks for two tool calls in one step [ @@ -239,8 +239,7 @@ describe('abort during tool execution ends the turn', () => { [{ type: 'text', text: 'steering before abort' }], { source: { kind: 'plugin', plugin: 'abort-test' } }, ) - // Exercise bare step abort without `cancel()` clearing queued work. - ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt') + agent.cancel({ kind: 'user' }) return [{ type: 'text', text: 'done' }] }, })) @@ -269,7 +268,10 @@ describe('abort during tool execution ends the turn', () => { case 'assistant/message': order.push('assistant/message'); break case 'tool/call': order.push(`tool/call:${event.data.callId}`); break case 'tool/result': { - const outcome = event.data.error?.code === 'ABORTED' ? 'synthetic-aborted' : 'real' + const outcome = event.data.error?.code === TOOL_ABORTED + || event.data.error?.code === TOOL_ABORTED_BEFORE_DISPATCH + ? 'aborted' + : 'completed' order.push(`tool/result:${event.data.callId}:${outcome}`) break } @@ -300,25 +302,29 @@ describe('abort during tool execution ends the turn', () => { expect(order).toEqual([ 'assistant/message', 'tool/call:c1', - 'tool/result:c1:real', + 'tool/result:c1:aborted', 'tool/call:c2', - 'tool/result:c2:synthetic-aborted', + 'tool/result:c2:aborted', 'context/message', - 'steering/message', 'agent/post-step', 'step/end', 'turn/end:aborted', ]) - expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }]) + expect(reasons).toEqual([{ kind: 'aborted' }]) const calls = agent.session.events.filter(event => event.type === 'tool/call') const results = agent.session.events.filter(event => event.type === 'tool/result') expect(calls.map(event => event.data.callId)).toEqual([CallId('c1'), CallId('c2')]) expect(results).toHaveLength(2) - expect(results[0]!.data).toMatchObject({ callId: CallId('c1'), isError: false }) + expect(results[0]!.data).toMatchObject({ + callId: CallId('c1'), + content: [{ type: 'text', text: 'Error: tool call aborted' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED }, + }) expect(results[1]!.data).toMatchObject({ callId: CallId('c2'), isError: true, - error: { name: 'AbortError', code: 'ABORTED' }, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, }) }) @@ -332,7 +338,7 @@ describe('abort during tool execution ends the turn', () => { parameters: {}, async execute() { agent.inject([{ type: 'text', text: 'accepted before abort' }], { source: { kind: 'plugin', plugin: 'test' } }) - ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt') + agent.cancel({ kind: 'user' }) return [{ type: 'text', text: 'done' }] }, })) @@ -385,7 +391,7 @@ describe('abort during tool execution ends the turn', () => { description: '', parameters: {}, async execute() { - ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt') + agent.cancel({ kind: 'user' }) return [{ type: 'text', text: 'aborted' }] }, })) @@ -478,7 +484,7 @@ describe('abort during tool execution ends the turn', () => { description: '', parameters: {}, async execute() { - ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt') + agent.cancel({ kind: 'user' }) return [{ type: 'text', text: 'done' }] }, })) @@ -517,7 +523,7 @@ describe('steering from late extension points is never stranded', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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' }]) @@ -591,26 +597,6 @@ 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')]) - const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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') - 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') - }) }) describe('plugin exceptions are contained', () => { @@ -767,7 +753,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('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) => { return { ...config, provider: 'mock', model: 'mock' } }) @@ -1481,7 +1467,7 @@ describe('surface: assistant/message records exact empty provenance when no chun await mountInvariants(ctx) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({ + ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _signal) => ({ role: 'assistant' as const, content: [{ type: 'text' as const, text: 'injected' }], })) @@ -1584,7 +1570,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) @@ -1596,15 +1582,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 () => { @@ -1689,7 +1672,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) @@ -1700,10 +1683,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 ac7f301525..7d4e79238e 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -170,7 +170,7 @@ describe('toError normalization', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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 @@ -200,7 +200,7 @@ describe('coded error data emission', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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 83b156b0ef..131c6ffb2d 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -56,7 +56,7 @@ describe('agent/prompt-submit', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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() }) @@ -182,7 +182,7 @@ describe('agent/prompt-submit', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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() }) @@ -497,7 +497,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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' } } } @@ -662,7 +662,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 dd686edfcb..5eaaa2ea5a 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -231,7 +231,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, provider: 'mock', model: 'mock' } }) const agent = ctx.agentLoop.create(SessionId('a-late-model'), {}) @@ -527,7 +527,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() }) @@ -566,7 +566,7 @@ describe('agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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) @@ -692,10 +692,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 () => { @@ -732,7 +732,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() }) @@ -884,7 +884,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 46cfe3eb56..aad90a7dde 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -167,7 +167,7 @@ describe('request stability across the loop', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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' } }) @@ -243,7 +243,7 @@ describe('request stability across the loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', 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" @@ -280,7 +280,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/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts index cf87d376ef..1e6bc14548 100644 --- a/packages/core/agent-loop/tests/request-recovery.spec.ts +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -204,7 +204,7 @@ describe('agent post-step and request-error lifecycle', () => { send(agent) const idle = waitForIdle(ctx, agent) await postStepEntered - agent.cancel('cancelled during max-tokens post-step') + agent.cancel({ kind: 'user' }) await idle expect(agent.session.events.find(event => event.type === 'assistant/message')).toMatchObject({ @@ -212,7 +212,7 @@ describe('agent post-step and request-error lifecycle', () => { }) expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', - data: { reason: { kind: 'aborted', reason: 'cancelled during max-tokens post-step' } }, + data: { reason: { kind: 'aborted' } }, }) }) @@ -587,7 +587,7 @@ describe('agent post-step and request-error lifecycle', () => { const idle = waitForIdle(ctx, agent) await recoveryEntered if (action === 'cancel') { - agent.cancel('cancelled during recovery') + agent.cancel({ kind: 'user' }) await idle } else { await ctx.fiber.dispose() @@ -596,7 +596,7 @@ describe('agent post-step and request-error lifecycle', () => { expect(adapter.requests).toHaveLength(1) expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', - data: { reason: action === 'cancel' ? { kind: 'aborted', reason: 'cancelled during recovery' } : { kind: 'disposed' } }, + data: { reason: action === 'cancel' ? { kind: 'aborted' } : { kind: 'disposed' } }, }) }) }) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index a218987e0e..5c64bb92f9 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -193,7 +193,7 @@ describe('the session-persistence Agent Note: 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/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index a77e1d678e..35985c9185 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -9,7 +9,7 @@ import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import LlmService from '@deepseek-ai/dsh-llm' -import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -461,7 +461,7 @@ describe('tool-call scheduler: abort handling', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('session/event', (session, event) => { if (session === agent.session && event.type === 'assistant/message') { - ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('already aborted') + agent.cancel({ kind: 'user' }) } }) @@ -476,12 +476,12 @@ describe('tool-call scheduler: abort handling', () => { isError: e.data.isError, error: e.data.error, }))).toEqual([ - { callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }, - { callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }, + { callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, + { callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, ]) }) - it('stops starting siblings when abort fires during ordered pre-execute', async () => { + it('skips dispatch and stops starting siblings when abort fires during ordered pre-execute', async () => { const adapter = new MockAdapter([ multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), textResponse('should never be requested'), @@ -492,24 +492,25 @@ describe('tool-call scheduler: abort handling', () => { const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('tools/pre-execute', async (exec, next): Promise => { if (exec.callId === CallId('c1')) { - ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('pre cancelled') + agent.cancel({ kind: 'user' }) } return next() }) agent.send([{ type: 'text', text: 'go' }]) - await until(() => gated.started.length === 1) - await new Promise(r => setTimeout(r, 5)) - expect(gated.started).toEqual(['1']) - gated.release('1') await waitForIdle(ctx, agent) + expect(gated.started).toEqual([]) expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) .toEqual([CallId('c1'), CallId('c2')]) - expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) - .toEqual([CallId('c1'), CallId('c2')]) - expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data) - .toMatchObject({ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }) + expect(events(agent).filter(e => e.type === 'tool/result').map(e => ({ + callId: e.data.callId, + isError: e.data.isError, + error: e.data.error, + }))).toEqual([ + { callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, + { callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }, + ]) }) it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => { @@ -528,7 +529,7 @@ describe('tool-call scheduler: abort handling', () => { agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) - ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop now') + agent.cancel({ kind: 'user' }) gated.release('1') gated.release('2') await waitForIdle(ctx, agent) @@ -540,8 +541,8 @@ describe('tool-call scheduler: abort handling', () => { .toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')]) expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => e.data)) .toEqual([ - expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }), - expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }), + expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }), + expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }), ]) const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message') expect(settled.map(e => e.type)) @@ -574,7 +575,7 @@ describe('tool-call scheduler: abort handling', () => { agent.send([{ type: 'text', text: 'go' }]) await until(() => gated.started.length === 2) - ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop before barrier') + agent.cancel({ kind: 'user' }) gated.release('1') gated.release('2') await waitForIdle(ctx, agent) @@ -583,6 +584,6 @@ describe('tool-call scheduler: abort handling', () => { expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) .toEqual([CallId('c1'), CallId('c2'), CallId('c3')]) expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data) - .toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }) + .toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }) }) }) diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index 736ada7013..c0516415c0 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -60,7 +60,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 269190dab0..2a4174f22e 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -46,7 +46,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `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. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved reason, then clears queues and aborts; notification failures are contained and cannot veto the stop. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). +Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `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. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. `PromptDecision.additionalContexts` is an array so every injected context keeps its own source and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source. @@ -59,7 +59,7 @@ The handle every plugin programs against: - `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Omitting `options.source` attests direct human input as `{ kind: 'user' }` and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. 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). The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the rationale. - `agent.steer(content, options?)` — submit steering while the agent is `running`. An open turn records it at the next steering checkpoint before a request or continuation decision; policy can still stop before another step. After turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. The method uses the same synchronous snapshot-and-validation boundary as `send` and delegates to `send` when idle - `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)). -- `agent.cancel(reason?)` — cancel ALL pending work: an effective call emits `agent/cancel-requested` before it clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window). Observers may synchronize their own state but cannot veto cancellation. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op with no notification. +- `agent.cancel(cause?)` — cancel ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; observers may synchronize state but cannot veto cancellation. The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`. - `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/cancellation.ts b/packages/core/agent/src/cancellation.ts new file mode 100644 index 0000000000..708009b456 --- /dev/null +++ b/packages/core/agent/src/cancellation.ts @@ -0,0 +1,30 @@ +/** 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/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/index.ts b/packages/core/agent/src/index.ts index 66dce92464..b939bd6e34 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -15,6 +15,7 @@ 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 { agentEvents, assembleContextFor } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' diff --git a/packages/core/agent/src/llm-target.ts b/packages/core/agent/src/llm-target.ts index 98f1e19d7d..18287a3ff5 100644 --- a/packages/core/agent/src/llm-target.ts +++ b/packages/core/agent/src/llm-target.ts @@ -49,7 +49,7 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR }) const disposeRequest = agentCtx.on( 'agent/request', - async (_agent, _turn, _step, _config, next): Promise => { + async (_agent, _turn, _step, _config, _signal, next): Promise => { const resolved = await next() const selected = target.assembled return selected === undefined ? resolved : { diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index e0ba54a60d..e653c315b5 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -86,6 +86,14 @@ 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' } + +/** Runtime reason carried by the signal that controls one live turn. */ +export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' } + /** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ export interface Agent { /** The single identity shared with {@link session}. */ @@ -125,12 +133,14 @@ export interface Agent { /** * Clear all queued and steering work, including items waiting to start, and - * abort the active step. An effective call first emits `agent/cancel-requested` - * with the resolved reason. That 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. + * abort the active turn. An effective call first emits + * `agent/cancel-requested` with the resolved typed cause. The first cause wins + * for the active turn, and `whenIdle()` resolves after cancellation reaches + * quiescence. Omission means `{ kind: 'user' }`. Idle cancellation is a no-op + * and does not arm later work. The active turn snapshots and freezes the cause. + * @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 @@ -181,14 +191,14 @@ declare module 'cordis' { 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void /** * Effective broad cancellation was requested, before queued/steering work - * is cleared or the active step is aborted. This observe-only notification + * is cleared or the active turn is aborted. This observe-only notification * cannot veto cancellation; listener failures are contained. * @param agent - the agent whose current work is being cancelled. - * @param reason - resolved cancellation reason, including the default. + * @param cause - resolved typed cancellation cause, including the default. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/cancel-requested'(this: Scoped, agent: Agent, reason: string): void + 'agent/cancel-requested'(this: Scoped, agent: Agent, cause: AgentCancelCause): void // ---- session lifecycle (emit) ---- /** @@ -220,14 +230,17 @@ declare module 'cordis' { 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void /** * Allow, rewrite, or block one claimed 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 whose turn claimed the message. * @param content - the claimed 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 @@ -236,10 +249,12 @@ 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 + * initiator 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 @@ -251,7 +266,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 @@ -262,10 +277,11 @@ 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 /** * Awaited serial checkpoint after the response, real or synthetic tool * results, injected context, and steering are durable but before `step/end`. @@ -299,20 +315,22 @@ declare module 'cordis' { * @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 5d79d1a91f..509ad14a22 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -2,9 +2,12 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context, Service, symbols } from 'cordis' import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { + agentEvents, + agentInterruptReasonOf, +} from '@deepseek-ai/dsh-agent' -import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentCancelCause, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' function stubAgent(rawId: string): Agent { const id = SessionId(rawId) @@ -23,12 +26,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 () => { @@ -182,6 +185,40 @@ describe('agentEvents()', () => { }) }) +describe('explicit cancellation helpers', () => { + 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', () => { function stubFactory() { const calls: { diff --git a/packages/core/agent/tests/llm-target.spec.ts b/packages/core/agent/tests/llm-target.spec.ts index 0c9ccb56ec..fa4ef2b459 100644 --- a/packages/core/agent/tests/llm-target.spec.ts +++ b/packages/core/agent/tests/llm-target.spec.ts @@ -17,28 +17,29 @@ describe('installAgentLlmTarget()', () => { const dispose = installAgentLlmTarget(ctx, target) const agent = {} as Agent const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } + const signal = new AbortController().signal expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + 'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed), )).resolves.toBe(seed) target.current = { provider: 'alpha', model: 'a1' } expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' }) target.current = { provider: 'beta', model: 'b1' } await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + 'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed), )).resolves.toEqual({ provider: 'alpha', model: 'a1', temperature: 0.2 }) expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' }) await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 1, seed, () => Promise.resolve(seed), + 'agent/request', 1, 1, seed, signal, () => Promise.resolve(seed), )).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) dispose() expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 2, 0, seed, () => Promise.resolve(seed), + 'agent/request', 2, 0, seed, signal, () => Promise.resolve(seed), )).resolves.toBe(seed) await ctx.fiber.dispose() }) diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index fd619402d0..036b393ea2 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' +import type { Events } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeTarget } from '@deepseek-ai/dsh-scope' import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -18,6 +20,9 @@ function emit(ctx: Context, receiver: object | undefined, event: string, args: u } describe('scoped-dispatch invariants', () => { + type AgentEventName = Extract + type EventArgs = Events[K] extends (...args: infer Args) => unknown ? Args : never + it('ignores ordinary events and rejects a scoped dispatch without a carrier', async () => { const ctx = await setup() expect(() => { emit(ctx, undefined, 'ordinary/event', []) }).not.toThrow() @@ -28,24 +33,31 @@ describe('scoped-dispatch invariants', () => { it('checks every generated subject resolver against the carrier key', async () => { const ctx = await setup() - const agent = { id: 'a1' } - const other = { id: 'a2' } + const agent = { id: 'a1' } as unknown as Agent + const other = { id: 'a2' } as unknown as Agent + const signal = new AbortController().signal + const config = { provider: 'p', model: 'm' } + const message = { role: 'assistant' as const, content: [] } + const agentRows = { + 'agent/created': [agent], + 'agent/disposed': [agent], + 'agent/status': [agent, 'idle'], + 'agent/queued': [agent, [], { source: { kind: 'user' }, steering: false }], + 'agent/cancel-requested': [agent, { kind: 'user' }], + 'agent/session-start': [agent, 'startup'], + 'agent/pre-step': [agent, 1, 1, signal], + 'agent/post-step': [agent, 1, 1, signal], + 'agent/prompt-submit': [agent, [], { kind: 'user' }, signal, () => Promise.resolve({ kind: 'allow' })], + 'agent/request': [agent, 1, 1, config, signal, () => Promise.resolve(config)], + 'agent/request-error': [agent, 1, 1, new Error('request failed'), { message: 'request failed', code: 'UNKNOWN' }, [], signal, () => Promise.resolve({ action: 'fail' })], + 'agent/session-prefix': [agent, [], signal, () => Promise.resolve([])], + 'agent/step-result': [agent, 1, 1, message, signal, () => Promise.resolve(message)], + 'agent/turn-continuation': [agent, 1, { action: 'stop' }, signal, () => Promise.resolve({ action: 'stop' })], + 'agent/turn-stop': [agent, 1, signal], + 'agent/error': [agent, 1, 0, new Error('x')], + } satisfies { [K in AgentEventName]: EventArgs } const rows: Array<[string, unknown[]]> = [ - ['agent/created', [agent]], - ['agent/disposed', [agent]], - ['agent/error', [agent, 1, 0, new Error('x')]], - ['agent/post-step', [agent, 1, 1]], - ['agent/pre-step', [agent, 1, 1, new AbortController().signal]], - ['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]], - ['agent/queued', [agent, [], { source: { kind: 'user' }, steering: false }]], - ['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]], - ['agent/request-error', [agent, 1, 1, new Error('x')]], - ['agent/session-prefix', [agent, [], new AbortController().signal, () => Promise.resolve([])]], - ['agent/session-start', [agent, 'startup']], - ['agent/status', [agent, 'idle']], - ['agent/step-result', [agent, 1, 1, { role: 'assistant', content: [] }, () => Promise.resolve({ role: 'assistant', content: [] })]], - ['agent/turn-continuation', [agent, 1, { action: 'stop' }, () => Promise.resolve({ action: 'stop' })]], - ['agent/turn-stop', [agent, 1]], + ...Object.entries(agentRows), ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], ['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]], ['system-prompt/assemble', [[], { scope: agent }]], diff --git a/packages/core/session/README.md b/packages/core/session/README.md index c618e78301..f8d5d9ea96 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -68,6 +68,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). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step. +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 entries behind a compaction replacement entry). On `assistant/message`, a present `[]` records a known empty provider stream, while omission means legacy or otherwise unrecorded provenance; other surface events require a non-empty list when this field is present. @@ -80,7 +82,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Extension points - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. -- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model and assistant messages require provider/model provenance. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage. +- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model, assistant messages require provider/model provenance, and a coarse aborted outcome must contain only `{ kind: 'aborted' }` (legacy reason-bearing records are rejected). `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage. - Compaction: `dsh-compact-basic` appends a `user/message` replacement for summary checkpoints, while `dsh-compact-tool-result-prune` appends a content-only `tool/result` replacement. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership, replacement validation, and `replaceGeneration`. ## Model Experience diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 9b2eb74a37..4a8963bb4c 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -137,6 +137,7 @@ function assertSessionEventEnvelope(value: Record, index: numbe throw new Error(`seed event at index ${index} has an invalid event envelope`) } assertCurrentLlmShape(event, index) + assertCurrentTurnEndShape(event, index) } /** Reject pre-provider request headers and assistant messages at the seed/load boundary. */ @@ -154,6 +155,22 @@ function assertCurrentLlmShape(event: Record, index: number): v } } +/** Reject legacy aborted outcomes that persisted caller-owned reason detail. */ +function assertCurrentTurnEndShape(event: Record, index: number): void { + if (event['type'] !== 'turn/end') return + const data = event['data'] + /* v8 ignore next -- this migration recognizes only the legacy object shape; format-wide payload validation is separate. */ + if (typeof data !== 'object' || data === null) return + const reason = (data as Record)['reason'] + /* v8 ignore next -- non-object reasons cannot carry the legacy aborted detail this migration removes. */ + if (typeof reason !== 'object' || reason === null || Array.isArray(reason)) return + const record = reason as Record + if (record['kind'] === 'aborted' + && (Object.keys(record).length !== 1 || !Object.hasOwn(record, 'kind'))) { + throw new Error(`seed turn/end at index ${index} uses unsupported reason-bearing aborted format`) + } +} + /** Whether an unknown value carries the current provider/model pair. */ function hasProviderModel(value: unknown): boolean { if (typeof value !== 'object' || value === null) return false diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index ea0ad55a51..6deed3a302 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -101,7 +101,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 5344449078..921232d724 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 0c41af5b46..019eb1ccb1 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -48,6 +48,32 @@ 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('rejects legacy reason-bearing aborted outcomes at the seed/load boundary', () => { + const legacy = [ + { + type: 'turn/start', seq: 0, time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }, + { + type: 'turn/end', seq: 1, time: 2, + data: { turn: 1, reason: { kind: 'aborted', reason: 'legacy cancellation detail' } }, + }, + ] as unknown as SessionEvent[] + + expect(() => new Session(SessionId('legacy-aborted'), legacy)) + .toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format') + }) + it('renders context and steering messages as plain 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 5c5c554351..65e8f3e590 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/core/tools/README.md b/packages/core/tools/README.md index a26bffd8f6..24588299d9 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -20,24 +20,28 @@ tools: - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog Agent Note](../../../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. -- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body; around wrappers may replace only `signal`. +- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body. Around wrappers may replace only `signal`; the registry re-fuses the original caller signal immediately before the body. - `ctx.tools.executionMode(exec)` returns `parallel` only when the visible definition's `isConcurrencySafe(exec.arguments)` classifier returns exactly `true`; unknown, hidden, undeclared, invalid, or throwing classifications are exclusive. ### Injected services `SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`. The approval seam is consumed opportunistically instead (`ctx.get('approval')`, no static inject): a deployment without it keeps the ask→deny degrade, and the registry stays active either way. +### Cancellation + +Cancellation is cooperative and quiescent. Every typed invocation supplies a caller-owned `AbortSignal`; tool bodies receive it as required readonly `exec.signal`, while only `tools/execute` wrappers may temporarily replace the required signal. The registry preserves caller cancellation through replacement and never races away from a started same-process promise. Cancellation before body invocation is `ABORTED_BEFORE_DISPATCH`; cancellation after invocation can replace only a successful outcome with `ABORTED`. A denial, wrapper failure, tool failure, post-policy failure, or timeout-owned `TOOL_TIMEOUT` remains more specific. A pre-aborted entry materializes and freezes arguments, then skips every policy and dispatch phase and publishes one result. Every async tool must observe or forward the signal and settle only after owned work stops. The [tool-cancellation Agent Note](../../../.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md) owns the full contract and hard-termination limit. + ### Live events The live registry pipeline has three transformable waterfalls followed by the observe-only `tools/result` boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards. ### Key types -- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. -- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token. +- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, whose async work must cooperatively stop through `exec.signal`, plus optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. +- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, signal, agent?, parent? }`; `signal` is required and readonly, callers may pass an enclosing execution's opaque token as `parent`, and callers never choose the new execution's own token. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. -- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. -- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately. +- `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. +- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately. - `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step. - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny. - `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. @@ -74,7 +78,7 @@ ctx.tools.register(defineTool({ }, async execute(args, exec) { // args is typed: { path: string; offset?: number; limit?: number } - const text = await readFile(args.path, 'utf8') + const text = await readFile(args.path, { encoding: 'utf8', signal: exec.signal }) return [{ type: 'text', text }] }, })) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 0a65c9e434..01af0e4857 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -160,9 +160,8 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => // (its executor kills on this signal) instead of orphaned, and // queued-unstarted dispatches are abandoned. const runController = new AbortController() - const onOuterAbort = (): void => { runController.abort(exec.signal?.reason) } - if (exec.signal?.aborted) onOuterAbort() - exec.signal?.addEventListener('abort', onOuterAbort, { once: true }) + const onOuterAbort = (): void => { runController.abort(exec.signal.reason) } + exec.signal.addEventListener('abort', onOuterAbort, { once: true }) let dispatches = 0 // The per-run serialization queue: every binding call chains onto the tail, so even @@ -273,7 +272,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => meta, } } finally { - exec.signal?.removeEventListener('abort', onOuterAbort) + exec.signal.removeEventListener('abort', onOuterAbort) } }, // ACP execute cards use the program as their visible title. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 2c01639047..6ff1ca2e3f 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -87,7 +87,9 @@ declare module 'cordis' { interface Events { /** * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing - * approval support turns `ask` into denial. + * approval support turns `ask` into denial. Async gates must observe + * `exec.signal`; the registry rechecks cancellation after they settle but + * never abandons their promise. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall @@ -96,15 +98,20 @@ declare module 'cordis' { /** * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns * a normalized result; wrappers may change only `exec.signal`, while call - * identity remains immutable. + * identity remains immutable. The registry re-fuses the original caller + * signal before the body, so replacement cannot detach caller cancellation; + * wrappers must still restore their signal and reach quiescence. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). * @mode waterfall */ - 'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise + 'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise /** * Accept, replace, enrich, or block a normalized dispatch result. `next()` - * accepts it unchanged; thrown tools still reach this seam as errors. + * accepts it unchanged; thrown tools still reach this seam as errors. Async + * listeners must observe `exec.signal`; after they settle, caller + * cancellation replaces only a successful accepted outcome with the code + * selected by whether the tool body was invoked. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the call that just ran (name, parsed arguments, caller agent). * @param result - the dispatch outcome a listener may accept, replace, or block. @@ -137,6 +144,15 @@ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta /** A registered tool: its schema plus the execution function. */ export interface ToolDefinition extends ToolSchema { + /** + * Run one accepted call. Async work must observe or forward `exec.signal` and + * settle only after its owned work reaches quiescence. The registry preserves + * caller cancellation through around-dispatch signal replacement and does + * not abandon this promise, but it cannot hard-kill same-process code. + * @param args - losslessly snapshotted, frozen model arguments. + * @param exec - execution identity, cancellation signal, and context deferral. + * @returns model-facing content plus optional private presentation metadata. + */ execute(args: unknown, exec: ToolRunContext): Promise /** * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. @@ -218,7 +234,8 @@ export interface ToolExecutionInput { * the outer `run_code` outcome without receiving its live mutable execution. */ readonly parent?: ToolExecutionToken - signal?: AbortSignal + /** Required caller-owned cancellation for this invocation. */ + readonly signal: AbortSignal } /** @@ -232,15 +249,25 @@ export type ToolExecutionMode = /** * One pending tool call inside the registry pipeline. Parsed arguments cross * one lossless-JSON materialization boundary before policy and are deep-frozen; - * call identity and the registry-assigned {@link token} are readonly. An - * around-dispatch wrapper may set, replace, or remove `signal`. The registry - * freezes the complete object before `tools/result` observers run. + * call identity, the caller signal, and the registry-assigned {@link token} are + * readonly. The registry freezes the complete object before `tools/result` + * observers run. */ export interface ToolExecution extends ToolExecutionInput { /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } +/** + * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper + * may replace the signal for its delegated lifetime, but it cannot remove it. + * The registry fuses every replacement with the captured caller signal. + */ +export interface ToolDispatchExecution extends Omit { + /** Cancellation signal visible to the next wrapper or tool body. */ + signal: AbortSignal +} + /** * Runtime context handed to a tool implementation after the registry has * accepted a {@link ToolExecution}. A composite tool uses @@ -256,6 +283,9 @@ export interface ToolRunContext extends ToolExecution { deferContext(context: HookContext): void } +/** Registry-owned live execution object; public pipeline views stay readonly. */ +type MutableToolRunContext = Omit & { signal: AbortSignal } + /** * Scheduler-only result after ordered pre-execute and guards. A `post-result` * still receives post-execute; a `final-result` bypasses it. @@ -297,6 +327,13 @@ export interface ToolRegistryScheduler { * @internal */ export const TOOL_REGISTRY_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler') + +/** Canonical error code for cancellation after a tool body was invoked. */ +export const TOOL_ABORTED = 'ABORTED' + +/** Canonical error code for cancellation before a tool body was invoked. */ +export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH' + /** Structured error metadata for a failed tool call (alongside the model-facing text). */ export interface ToolErrorInfo { name: string @@ -446,6 +483,24 @@ interface ToolGuardRegistration { guard: ToolGuard } +/** Approval decision plus whether the approval channel reported cancellation. */ +interface ToolAskResolution { + readonly decision: Extract + readonly approvalCancelled: boolean +} + +/** Caller cancellation and dispatch state kept outside the around-wrapper view. */ +interface ToolCancellationState { + readonly callerSignal: AbortSignal + bodyInvoked: boolean +} + +/** One dispatch-scoped fused signal plus listener cleanup after the body settles. */ +interface FusedToolSignal { + readonly signal: AbortSignal + dispose(): void +} + /** * Tool registry and execution pipeline. Scoped registrations shadow globals; * one visibility resolver feeds presentation, lookup, and dispatch. @@ -467,6 +522,8 @@ export class ToolRegistry extends Service { /** Context deferred by a running tool body, keyed by its scheduler-owned execution. */ private deferredContexts = new WeakMap() + /** Original caller cancellation, kept outside the wrapper-mutable execution object. */ + private cancellationStates = new WeakMap() private global = new Map() private scoped = new Map>() /** Compiled restriction filters, per scope (see {@link restrict}). */ @@ -789,7 +846,11 @@ export class ToolRegistry extends Service { * Execute through pre-policy, guards, around-dispatch, post-policy, and final * notification. Tool and listener failures resolve as materialized error * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is - * the same lossless, frozen snapshot final observers receive. + * the same lossless, frozen snapshot final observers receive. Cancellation + * arriving after entry and before final result materialization skips a + * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a + * successful started outcome with `ABORTED`; already-started work is still + * drained and may retain a tool-owned structured error. * @param exec - the typed same-process call input. The registry assigns its * correlation token before policy begins. * @returns the materialized final result. @@ -816,7 +877,7 @@ export class ToolRegistry extends Service { } } - private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: ToolRunContext } { + private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } { const deferredContexts: HookContext[] = [] const token = createExecutionToken() const callId = exec.callId @@ -828,9 +889,9 @@ export class ToolRegistry extends Service { token, callId, name, + signal, ...agent !== undefined ? { agent } : {}, ...parent !== undefined ? { parent } : {}, - ...signal !== undefined ? { signal } : {}, deferContext(context: HookContext): void { deferredContexts.push(context) }, @@ -840,11 +901,15 @@ export class ToolRegistry extends Service { if (detached === undefined) { throw new TypeError('tool execution arguments must be losslessly JSON-serializable') } - const execution: ToolRunContext = { ...base, arguments: deepFreeze(detached) } + const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) } this.deferredContexts.set(execution, deferredContexts) + this.cancellationStates.set(execution, { + callerSignal: signal, + bodyInvoked: false, + }) return { kind: 'ready', exec: execution } } catch (error: unknown) { - const execution: ToolRunContext = { ...base, arguments: undefined } + const execution: MutableToolRunContext = { ...base, arguments: undefined } return { kind: 'final-result', exec: execution, result: toolErrorResult(error) } } } @@ -866,13 +931,22 @@ export class ToolRegistry extends Service { const created = this.createExecution(input) if (created.kind !== 'ready') return next(created) const exec = created.exec + if (this.callerCancelled(exec)) { + return next({ kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() }) + } try { const carrier = scopeTarget(this, exec.agent) const gate = await this.ctx.waterfall( carrier, 'tools/pre-execute', exec, () => Promise.resolve({ kind: 'allow' }), ) - const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate + const askResolution: ToolAskResolution = gate.kind === 'ask' + ? await this.serviceAsk(exec, gate) + : { decision: gate, approvalCancelled: false } + const { decision } = askResolution + if (this.callerCancelled(exec) && askResolution.approvalCancelled) { + return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() }) + } const denialReason = decision.kind === 'allow' ? this.guardReason(exec) : decision.reason @@ -886,12 +960,74 @@ export class ToolRegistry extends Service { }, }) } + if (this.callerCancelled(exec)) { + return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() }) + } return await next({ kind: 'dispatch', exec }) } catch (error: unknown) { return next({ kind: 'final-result', exec, result: toolErrorResult(error) }) } } + /** Whether the original caller signal is currently aborted. */ + private callerCancelled(exec: ToolRunContext): boolean { + const state = this.cancellationStates.get(exec) + /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ + if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') + return state.callerSignal.aborted + } + + /** Canonical cancellation outcome selected by whether the tool body started. */ + private cancellationResult(exec: ToolRunContext, prior?: ToolExecutionResult): ToolExecutionResult { + const state = this.cancellationStates.get(exec) + /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ + if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') + return state.bodyInvoked + ? toolAbortedResult(prior) + : toolAbortedBeforeDispatchResult(prior) + } + + /** + * Dispatch the registered body with the original caller signal fused back + * into any around-wrapper replacement. Cancellation never abandons the body: + * a started promise reaches quiescence before its outcome becomes `ABORTED`. + */ + private async dispatchToolBody(exec: MutableToolRunContext): Promise { + const state = this.cancellationStates.get(exec) + /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ + if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') + const wrapperSignal = exec.signal + const fused = fuseToolSignals(state.callerSignal, wrapperSignal) + const signal = fused.signal + + if (isAborted(signal)) { + fused.dispose() + return toolAbortedBeforeDispatchResult() + } + exec.signal = signal + try { + const tool = this.get(exec.name, exec.agent) + if (!tool) throw new ToolNotFoundError(exec.name) + state.bodyInvoked = true + const returned = await tool.execute(exec.arguments, exec) + const content = Array.isArray(returned) ? returned : returned.content + const meta = Array.isArray(returned) ? undefined : returned.meta + const result: ToolExecutionResult = { + content, + isError: false, + ...meta !== undefined ? { meta } : {}, + } + return isAborted(signal) + ? toolAbortedResult(result) + : result + } catch (error: unknown) { + return toolErrorResult(error) + } finally { + fused.dispose() + exec.signal = wrapperSignal + } + } + /** * Run around-dispatch and the tool body. Tool and unknown-tool failures still * receive post-execute; pipeline failures are already final. @@ -901,21 +1037,11 @@ export class ToolRegistry extends Service { */ private async dispatchScheduledExecution(exec: ToolRunContext): Promise { try { + const mutableExec = exec as MutableToolRunContext const carrier = scopeTarget(this, exec.agent) const result = await this.ctx.waterfall( - carrier, 'tools/execute', exec, - async (): Promise => { - try { - const tool = this.get(exec.name, exec.agent) - if (!tool) throw new ToolNotFoundError(exec.name) - const returned = await tool.execute(exec.arguments, exec) - const content = Array.isArray(returned) ? returned : returned.content - const meta = Array.isArray(returned) ? undefined : returned.meta - return { content, isError: false, ...meta !== undefined ? { meta } : {} } - } catch (error: unknown) { - return toolErrorResult(error) - } - }, + carrier, 'tools/execute', mutableExec, + () => this.dispatchToolBody(mutableExec), ) const deferredContexts = this.deferredContexts.get(exec) /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */ @@ -929,7 +1055,12 @@ export class ToolRegistry extends Service { ...result.additionalContexts ?? [], ], } - return { kind: 'post-result', result: resultWithDeferredContexts } + return { + kind: 'post-result', + result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError + ? this.cancellationResult(exec, resultWithDeferredContexts) + : resultWithDeferredContexts, + } } catch (error: unknown) { return { kind: 'final-result', result: toolErrorResult(error) } } @@ -944,7 +1075,13 @@ export class ToolRegistry extends Service { */ private async finalizeScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): Promise { try { - return this.finishScheduledExecution(exec, await this.postExecute(exec, result)) + const postResult = await this.postExecute(exec, result) + return this.finishScheduledExecution( + exec, + this.callerCancelled(exec) && !postResult.isError + ? this.cancellationResult(exec, postResult) + : postResult, + ) } catch (error: unknown) { return this.finishScheduledExecution(exec, toolErrorResult(error)) } @@ -970,8 +1107,8 @@ export class ToolRegistry extends Service { /** Notify observers without exposing a mutation or error channel into the outcome. */ private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void { - // Freeze the remaining mutable signal slot before observers receive the - // shared WeakMap-keyable execution object. + // Freeze the registry's live object before observers receive its readonly + // WeakMap-keyable view. Object.freeze(exec) const { name: toolName, callId } = exec const reportFailure = (error: unknown): void => { @@ -1004,26 +1141,41 @@ export class ToolRegistry extends Service { private async serviceAsk( exec: ToolExecution, ask: Extract, - ): Promise> { + ): Promise { const approval = this.ctx.get('approval') if (approval === undefined) { - return { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` } + return { + decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` }, + approvalCancelled: false, + } } if (exec.agent === undefined) { - return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` } + return { + decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` }, + approvalCancelled: false, + } } const outcome = await approval.request({ agent: exec.agent, toolName: exec.name, callId: exec.callId, ...ask.reason !== undefined ? { reason: ask.reason } : {}, - ...exec.signal !== undefined ? { signal: exec.signal } : {}, + signal: exec.signal, }) switch (outcome) { - case 'allowed-once': return { kind: 'allow' } - case 'rejected': return { kind: 'deny', reason: `the user rejected tool "${exec.name}"` } - case 'cancelled': return { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` } - case 'unavailable': return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` } + case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false } + case 'rejected': return { + decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }, + approvalCancelled: false, + } + case 'cancelled': return { + decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` }, + approvalCancelled: true, + } + case 'unavailable': return { + decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` }, + approvalCancelled: false, + } default: return assertNever(outcome, 'ApprovalOutcome') } } @@ -1089,4 +1241,64 @@ function toolErrorResult(error: unknown): ToolExecutionResult { } } +/** Read live abort state across an await without treating it as synchronously immutable. */ +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} + +/** + * Fuse caller and wrapper cancellation without nesting `AbortSignal.any`. + * Keeping the relay dispatch-scoped also removes listeners when work settles. + */ +function fuseToolSignals(caller: AbortSignal, wrapper: AbortSignal): FusedToolSignal { + if (caller === wrapper) return { signal: caller, dispose() {} } + + const controller = new AbortController() + let listening = false + const dispose = (): void => { + if (!listening) return + listening = false + caller.removeEventListener('abort', abortFromCaller) + wrapper.removeEventListener('abort', abortFromWrapper) + } + const abortFrom = (source: AbortSignal): void => { + const reason: unknown = source.reason + controller.abort(reason) + dispose() + } + const abortFromCaller = (): void => { abortFrom(caller) } + const abortFromWrapper = (): void => { abortFrom(wrapper) } + + if (wrapper.aborted) abortFromWrapper() + else if (caller.aborted) abortFromCaller() + else { + listening = true + caller.addEventListener('abort', abortFromCaller, { once: true }) + wrapper.addEventListener('abort', abortFromWrapper, { once: true }) + } + return { signal: controller.signal, dispose } +} + +/** Canonical result when cancellation supersedes success after body invocation. */ +function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult { + const additionalContexts = prior?.additionalContexts ?? [] + return { + content: [{ type: 'text', text: 'Error: tool call aborted' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED }, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + } +} + +/** Canonical result when cancellation prevents tool body invocation. */ +function toolAbortedBeforeDispatchResult(prior?: ToolExecutionResult): ToolExecutionResult { + const additionalContexts = prior?.additionalContexts ?? [] + return { + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + } +} + export default ToolRegistry diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 227ff98129..b8f47cca81 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -6,12 +6,14 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' -import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineTool } from '@deepseek-ai/dsh-tools' import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEventMap } from '@deepseek-ai/dsh-session' +const testToolSignal = new AbortController().signal + /** * Code Mode unit tier (per the Agent Note's plan): provider contribution per mode, * misconfiguration rejections, the run_code dispatch bridge (serialization, @@ -95,6 +97,7 @@ function fakeAgent(options: { cwd?: string } = { cwd: '/workspace' }): { agent: /** Dispatch run_code through the registry pipeline, as the loop would. */ async function runCode(ctx: Context, code: string, extras: { agent?: Agent; signal?: AbortSignal } = {}): Promise { return ctx.tools.execute({ + signal: testToolSignal, callId: CallId('call-1'), name: RUN_CODE_NAME, arguments: { code }, @@ -357,8 +360,7 @@ describe('the run_code dispatch bridge', () => { const previous = exec.signal exec.signal = new AbortController().signal const result = await next() - if (previous === undefined) delete exec.signal - else exec.signal = previous + exec.signal = previous return result }) ctx.on('tools/result', (exec) => { @@ -574,7 +576,7 @@ describe('the run_code dispatch bridge', () => { seen.push(args.id) await new Promise((resolve) => { const timer = setTimeout(resolve, 500) - exec.signal?.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true }) + exec.signal.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true }) }) return [{ type: 'text' as const, text: args.id }] }, @@ -610,7 +612,7 @@ describe('the run_code dispatch bridge', () => { started() await new Promise((resolve) => { const timer = setTimeout(resolve, 500) - exec.signal?.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true }) + exec.signal.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true }) }) return [{ type: 'text' as const, text: args.id }] }, @@ -838,7 +840,7 @@ describe('the run_code dispatch bridge', () => { expect((result.content[0] as { text: string }).text).toBe('{ n: 42 }') }) - it('reports a pre-aborted outer signal as the run failure without dispatching anything', async () => { + it('short-circuits a pre-aborted outer signal before the code runtime', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const calls = registerEcho(ctx) runtime.behavior = (request) => { @@ -850,11 +852,16 @@ describe('the run_code dispatch bridge', () => { controller.abort('too-late') const result = await runCode(ctx, 'program', { signal: controller.signal }) expect(result.isError).toBe(true) - expect((result.content[0] as { text: string }).text).toContain('code run failed (abort)') + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) + expect(runtime.lastRequest).toBeUndefined() expect(calls).toEqual([]) }) - it('rejects a binding invoked after the run is over without dispatching it', async () => { + it('reports cancellation after rejecting a late binding without dispatching it', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const calls = registerEcho(ctx) const controller = new AbortController() @@ -865,8 +872,9 @@ describe('the run_code dispatch bridge', () => { return { logs: [], value: message } } const result = await runCode(ctx, 'program', { signal: controller.signal }) - expect(result.isError).toBe(false) - expect((result.content[0] as { text: string }).text).toContain('not dispatched') + expect(result.isError).toBe(true) + expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' }) + expect((result.content[0] as { text: string }).text).toBe('Error: tool call aborted') expect(calls).toEqual([]) }) diff --git a/packages/core/tools/tests/execution-mode.spec.ts b/packages/core/tools/tests/execution-mode.spec.ts index 9a12f33a51..054eed65ed 100644 --- a/packages/core/tools/tests/execution-mode.spec.ts +++ b/packages/core/tools/tests/execution-mode.spec.ts @@ -11,6 +11,8 @@ import ToolRegistry, { type ToolExecutionMode, } from '@deepseek-ai/dsh-tools' +const testToolSignal = new AbortController().signal + async function setup() { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -19,7 +21,7 @@ async function setup() { } function exec(name: string, args: unknown): ToolExecutionInput { - return { callId: CallId('c1'), name, arguments: args } + return { signal: testToolSignal, callId: CallId('c1'), name, arguments: args } } describe('ToolRegistry.executionMode', () => { diff --git a/packages/core/tools/tests/execution-signal-types.spec.ts b/packages/core/tools/tests/execution-signal-types.spec.ts new file mode 100644 index 0000000000..e0e030543f --- /dev/null +++ b/packages/core/tools/tests/execution-signal-types.spec.ts @@ -0,0 +1,100 @@ +import { describe, expectTypeOf, it } from 'vitest' +import type { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { + ToolDispatchExecution, + ToolExecution, + ToolExecutionInput, + ToolRunContext, +} from '@deepseek-ai/dsh-tools' + +function inputAndExecutionContracts( + input: ToolExecutionInput, + execution: ToolExecution, + run: ToolRunContext, +): void { + // @ts-expect-error -- every typed invocation must supply a caller-owned signal. + const missingSignal: ToolExecutionInput = { callId: CallId('missing'), name: 'probe', arguments: {} } + void missingSignal + + // @ts-expect-error -- caller input is readonly after construction. + input.signal = new AbortController().signal + // @ts-expect-error -- required readonly properties cannot be deleted. + delete input.signal + // @ts-expect-error -- required signals cannot become undefined. + input.signal = undefined + + // @ts-expect-error -- pipeline observers receive a readonly execution view. + execution.signal = new AbortController().signal + // @ts-expect-error -- pipeline observers cannot remove the required signal. + delete execution.signal + // @ts-expect-error -- tool bodies receive a readonly run context. + run.signal = new AbortController().signal + // @ts-expect-error -- tool bodies cannot remove the required signal. + delete run.signal + // @ts-expect-error -- tool bodies cannot replace the required signal with undefined. + run.signal = undefined +} +void inputAndExecutionContracts + +function observerContracts(ctx: Context): void { + ctx.on('tools/pre-execute', (exec, next) => { + // @ts-expect-error -- pre-policy sees a readonly signal. + exec.signal = new AbortController().signal + // @ts-expect-error -- pre-policy cannot remove the required signal. + delete exec.signal + // @ts-expect-error -- pre-policy cannot replace the required signal with undefined. + exec.signal = undefined + return next() + }) + ctx.on('tools/post-execute', (exec, _result, next) => { + // @ts-expect-error -- post-policy sees a readonly signal. + exec.signal = new AbortController().signal + // @ts-expect-error -- post-policy sees a readonly signal. + delete exec.signal + // @ts-expect-error -- post-policy cannot replace the required signal with undefined. + exec.signal = undefined + return next() + }) + ctx.on('tools/result', (exec) => { + // @ts-expect-error -- result observers see a readonly signal. + exec.signal = new AbortController().signal + // @ts-expect-error -- result observers cannot remove the required signal. + delete exec.signal + // @ts-expect-error -- result observers see a readonly signal. + exec.signal = undefined + }) + ctx.on('tools/execute', (exec, next) => { + exec.signal = new AbortController().signal + // @ts-expect-error -- around-dispatch may replace but not remove the signal. + delete exec.signal + // @ts-expect-error -- around-dispatch cannot replace the required signal with undefined. + exec.signal = undefined + return next() + }) +} +void observerContracts + +const inferredTool = defineTool({ + name: 'signal-inference', + description: 'Pins contextual signal inference.', + parameters: {}, + async execute(_args, exec) { + expectTypeOf(exec.signal).toEqualTypeOf() + // @ts-expect-error -- defineTool contextually exposes a readonly signal. + exec.signal = new AbortController().signal + return [] + }, +}) +void inferredTool + +describe('tool execution signal types', () => { + it('requires an exact AbortSignal at every readonly tool view', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toBeFunction() + }) +}) diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index 3db0e961ce..ef795cce73 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -6,6 +6,8 @@ import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@de import * as ToolsInvariant from '@deepseek-ai/dsh-tools/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' +const testToolSignal = new AbortController().signal + async function setup(): Promise { const ctx = new Context() await ctx.plugin(InvariantService) @@ -19,6 +21,7 @@ const execution = (overrides: Partial = {}): ToolExecution => ({ name: 'echo', arguments: Object.freeze({ text: 'hi' }), ...overrides, + signal: overrides.signal ?? testToolSignal, }) const outcome = (): ToolExecutionResult => Object.freeze({ diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 843aeb3837..ab3b869953 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -12,6 +12,8 @@ import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' +const testToolSignal = new AbortController().signal + /** Mount the registry (with its systemPrompt dependency) on a fresh context. */ async function mount(): Promise { const ctx = new Context() @@ -43,6 +45,7 @@ function tool(name: string, reply = `ran:${name}`): ToolDefinition { async function run(ctx: Context, name: string, agent?: Agent): Promise { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('c1'), name, arguments: {}, @@ -305,6 +308,7 @@ describe('scoped execution dispatch', () => { expect(await run(ctx, 'danger', key)).toBe('Error: danger denied') const callerArguments = { source: true } const safeResult = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('safe-call'), name: 'safe', arguments: callerArguments, @@ -348,7 +352,7 @@ describe('scoped execution dispatch', () => { if (exec.name === 'parent') parent = exec.token return next() }) - await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} }) + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('parent'), name: 'parent', arguments: {} }) stopCapture() policyCalls = 0 const signal = new AbortController().signal @@ -372,6 +376,7 @@ describe('scoped execution dispatch', () => { signal, }) const subjectlessResult = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('non-cloneable-subjectless'), name: 't', arguments: { invalid: () => undefined }, @@ -414,6 +419,7 @@ describe('scoped execution dispatch', () => { callId: CallId('stateful-parent'), name: 't', arguments: {}, + signal: testToolSignal, get parent(): ToolExecutionToken | undefined { parentReads += 1 return parentReads === 1 ? undefined : forged @@ -438,7 +444,7 @@ describe('scoped execution dispatch', () => { if (exec.name === 'parent') parent = exec.token return next() }) - await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} }) + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('parent'), name: 'parent', arguments: {} }) stopCapture() const acceptedSignal = new AbortController().signal const driftSignal = new AbortController().signal @@ -485,6 +491,7 @@ describe('scoped execution dispatch', () => { const input = { callId: CallId('throwing-arguments'), name: 't', + signal: testToolSignal, get arguments(): unknown { argumentReads += 1 throw new Error('getter exploded') @@ -525,6 +532,7 @@ describe('scoped execution dispatch', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('bad-arguments'), name: 't', arguments: argumentsValue, }) @@ -545,6 +553,7 @@ describe('scoped execution dispatch', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue, }) @@ -585,7 +594,7 @@ describe('scoped execution dispatch', () => { ctx.on('tools/result', () => Promise.reject(new Error('async observer failure')) as never) ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) }) - const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('final'), name: 't', arguments: {}, agent: key }) await Promise.resolve() expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] }) expect(seen).toEqual([true, true]) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 80f5812071..c1c920fed2 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -6,10 +6,13 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import ToolRegistry, { defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, + TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, - type ToolExecution, type ToolExecutionResult, + type ToolDispatchExecution, type ToolExecutionResult, } from '@deepseek-ai/dsh-tools' +const testToolSignal = new AbortController().signal + async function setup() { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -79,7 +82,7 @@ describe('ToolRegistry', () => { it('executes a tool and returns its content', async () => { const ctx = await setup() ctx.tools.register(echoTool) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false }) }) @@ -92,7 +95,7 @@ describe('ToolRegistry', () => { return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] } } }, }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'meta-tool', arguments: {} }) expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false, @@ -109,7 +112,7 @@ describe('ToolRegistry', () => { return { content: [{ type: 'text', text: 'ok' }] } }, }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'no-meta-tool', arguments: {} }) expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false }) expect('meta' in result).toBe(false) }) @@ -127,6 +130,7 @@ describe('ToolRegistry', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('bad-meta'), name: 'bad-meta', arguments: {}, }) expect(result.isError).toBe(true) @@ -144,13 +148,13 @@ describe('ToolRegistry', () => { }, }) - const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'nope', arguments: {} }) + const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'nope', arguments: {} }) expect(unknown.isError).toBe(true) expect(unknown.content[0]).toMatchObject({ text: 'Error: unknown tool "nope"' }) // An unknown tool is a routable failure class, same as a tool-thrown one. expect(unknown.error).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }) - const thrown = await ctx.tools.execute({ callId: CallId('c2'), name: 'boom', arguments: {} }) + const thrown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'boom', arguments: {} }) expect(thrown.isError).toBe(true) expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' }) }) @@ -170,6 +174,7 @@ describe('ToolRegistry', () => { }) await expect(ctx.tools.execute({ + signal: testToolSignal, callId: CallId('hostile'), name: 'hostile-throw', arguments: {}, })).resolves.toMatchObject({ isError: true, @@ -195,7 +200,7 @@ describe('ToolRegistry', () => { return next() }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' }) }) @@ -207,7 +212,7 @@ describe('ToolRegistry', () => { ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask', reason: 'needs approval' })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: needs approval' }) }) @@ -218,7 +223,7 @@ describe('ToolRegistry', () => { ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' }) }) @@ -269,7 +274,7 @@ describe('ToolRegistry', () => { ctx.on('approval/request', () => Promise.resolve('rejected')) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: the user rejected tool "echo"' }) }) @@ -279,16 +284,51 @@ describe('ToolRegistry', () => { ctx.on('approval/request', () => Promise.resolve('cancelled')) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: approval for tool "echo" was cancelled' }) }) + it('returns ABORTED_BEFORE_DISPATCH when caller cancellation overtakes approval', async () => { + const ctx = await approvalSetup() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'approval-probe', + async execute() { dispatched += 1; return [] }, + }) + ctx.on('approval/request', () => { + entered.resolve(undefined) + return release.promise + }) + ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('approval-cancelled'), + name: 'approval-probe', + arguments: {}, + agent: fakeAgent(), + signal: controller.signal, + }) + + await entered.promise + controller.abort('caller cancelled approval') + release.resolve('allowed-once') + + await expect(pending).resolves.toMatchObject({ + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) + expect(dispatched).toBe(0) + }) + it('denies with the no-channel reason when the seam is mounted but nobody answers', async () => { const ctx = await approvalSetup() ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but no approval channel is available' }) }) @@ -302,7 +342,7 @@ describe('ToolRegistry', () => { }) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} }) expect(asked).toBe(false) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but the call has no agent to route it through' }) @@ -317,7 +357,7 @@ describe('ToolRegistry', () => { ctx.provide('approval', { request: () => Promise.resolve('yolo') } as unknown as ApprovalService) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) const text = result.content[0]?.type === 'text' ? result.content[0].text : '' expect(text).toContain('unreachable') @@ -331,7 +371,7 @@ describe('ToolRegistry', () => { ctx.on('tools/post-execute', async (_exec, _result, _next): Promise => ({ kind: 'accept', content: [{ type: 'text', text: 'rewritten' }] })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(false) expect(result.content[0]).toMatchObject({ text: 'rewritten' }) }) @@ -343,7 +383,7 @@ describe('ToolRegistry', () => { ctx.on('tools/post-execute', async (_exec, _result, _next): Promise => ({ kind: 'block', feedback: [{ type: 'text', text: 'output rejected: try again' }] })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' }) }) @@ -359,7 +399,7 @@ describe('ToolRegistry', () => { additionalContexts: [{ content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }], })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'rejected' }) expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }]) @@ -372,7 +412,7 @@ describe('ToolRegistry', () => { ctx.on('tools/post-execute', async (_exec, _result, _next): Promise => ({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }] })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }]) }) @@ -409,7 +449,7 @@ describe('ToolRegistry', () => { } }) - const result = await ctx.tools.execute({ callId: CallId('composite'), name: 'composite', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('composite'), name: 'composite', arguments: {} }) expect(result.additionalContexts?.map(context => context.source)).toEqual([ { kind: 'plugin', plugin: 'nested-1' }, @@ -432,7 +472,7 @@ describe('ToolRegistry', () => { }, })) - const failed = await ctx.tools.execute({ callId: CallId('failed'), name: 'failing-composite', arguments: {} }) + const failed = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('failed'), name: 'failing-composite', arguments: {} }) expect(failed.isError).toBe(true) expect(failed.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'nested' }]) @@ -441,7 +481,7 @@ describe('ToolRegistry', () => { feedback: [{ type: 'text', text: 'blocked' }], additionalContexts: [{ content: [{ type: 'text', text: 'block-only' }], source: { kind: 'plugin', plugin: 'blocker' } }], })) - const blocked = await ctx.tools.execute({ callId: CallId('blocked'), name: 'failing-composite', arguments: {} }) + const blocked = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('blocked'), name: 'failing-composite', arguments: {} }) expect(blocked.isError).toBe(true) expect(blocked.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'blocker' }]) }) @@ -464,7 +504,7 @@ describe('ToolRegistry', () => { return decision }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } }) expect(result.isError).toBe(false) // pre runs fully (gate) before dispatch, then post runs over the result. expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after']) @@ -484,7 +524,7 @@ describe('ToolRegistry', () => { })) ctx.on('tools/pre-execute', async (_exec, next) => { order.push('pre'); return next() }) - ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise): Promise => { + ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise): Promise => { order.push('execute:before') const result = await next() order.push('execute:after') @@ -492,24 +532,574 @@ describe('ToolRegistry', () => { }) ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false }) // The around seam wraps dispatch; pre gates before it, post runs over its result. expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post']) }) + it('skips dispatch when caller cancellation arrives while pre-execute awaits', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'must-not-run', + async execute() { dispatched += 1; return [] }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/pre-execute', async (_exec, next) => { + entered.resolve(undefined) + await release.promise + return await next() + }) + + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-in-pre'), name: 'must-not-run', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('cancelled in policy') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) + expect(dispatched).toBe(0) + }) + + it('preserves a pre-execute denial that settles after cancellation', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'denied-after-cancel', + async execute() { dispatched += 1; return [] }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/pre-execute', async () => { + entered.resolve(undefined) + await release.promise + return { kind: 'deny', reason: 'policy denied the call' } + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('denied-after-cancel'), name: 'denied-after-cancel', arguments: {}, signal: controller.signal, + }) + + await entered.promise + controller.abort('cancelled while policy decided') + release.resolve(undefined) + + await expect(pending).resolves.toEqual({ + content: [{ type: 'text', text: 'Error: policy denied the call' }], + isError: true, + }) + expect(dispatched).toBe(0) + }) + + it('preserves an async pre-execute failure that settles after cancellation', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'must-not-run', + async execute() { dispatched += 1; return [] }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/pre-execute', async () => { + entered.resolve(undefined) + await release.promise + throw new Error('gate interrupted') + }) + + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-pre-error'), name: 'must-not-run', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('cancelled in policy') + release.resolve(undefined) + + await expect(pending).resolves.toEqual({ + content: [{ type: 'text', text: 'Error: gate interrupted' }], + isError: true, + }) + expect(dispatched).toBe(0) + }) + + it('rechecks caller cancellation after an async around-dispatch wrapper delegates', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'must-not-run', + async execute() { dispatched += 1; return [] }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const replacement = new AbortController() + ctx.on('tools/execute', async (exec, next) => { + const upstream = exec.signal + exec.signal = replacement.signal + try { + entered.resolve(undefined) + await release.promise + return await next() + } finally { + exec.signal = upstream + } + }) + + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-in-around'), name: 'must-not-run', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('cancelled in wrapper') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) + expect(dispatched).toBe(0) + }) + + it('skips dispatch when an around wrapper supplies an already-aborted signal', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'must-not-run', + async execute() { dispatched += 1; return [] }, + }) + const replacement = AbortSignal.abort('wrapper cancelled') + ctx.on('tools/execute', async (exec, next) => { + const upstream = exec.signal + exec.signal = replacement + try { + return await next() + } finally { + exec.signal = upstream + } + }) + + const controller = new AbortController() + const result = await ctx.tools.execute({ + callId: CallId('cancelled-wrapper'), name: 'must-not-run', arguments: {}, signal: controller.signal, + }) + + expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(dispatched).toBe(0) + }) + + it('uses ABORTED_BEFORE_DISPATCH when cancellation overtakes a wrapper short-circuit', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'short-circuited', + async execute() { dispatched += 1; return [] }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/execute', async () => { + entered.resolve(undefined) + await release.promise + return { + content: [{ type: 'text', text: 'wrapper success' }], + isError: false, + additionalContexts: [{ + content: [{ type: 'text', text: 'wrapper context' }], + source: { kind: 'plugin', plugin: 'wrapper' }, + }], + } + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-short-circuit'), + name: 'short-circuited', + arguments: {}, + signal: controller.signal, + }) + + await entered.promise + controller.abort('cancelled while wrapper waited') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + additionalContexts: [{ source: { kind: 'plugin', plugin: 'wrapper' } }], + }) + expect(dispatched).toBe(0) + }) + + it('replaces a late wrapper success with ABORTED and preserves deferred contexts', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'completed-before-wrapper', + async execute(_args, exec) { + exec.deferContext({ + content: [{ type: 'text', text: 'completed child work' }], + source: { kind: 'plugin', plugin: 'child' }, + }) + return [{ type: 'text', text: 'body complete' }] + }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/execute', async (_exec, next) => { + const result = await next() + entered.resolve(undefined) + await release.promise + return result + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-after-body'), name: 'completed-before-wrapper', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('cancelled while wrapper settled') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: tool call aborted' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED }, + additionalContexts: [{ source: { kind: 'plugin', plugin: 'child' } }], + }) + }) + + it('replaces a late post-execute success with ABORTED and preserves contexts', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'completed-before-post', + async execute(_args, exec) { + exec.deferContext({ + content: [{ type: 'text', text: 'completed child work' }], + source: { kind: 'plugin', plugin: 'child' }, + }) + return [{ type: 'text', text: 'body complete' }] + }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/post-execute', async (_exec, _result, next) => { + const decision = await next() + entered.resolve(undefined) + await release.promise + return { + ...decision, + additionalContexts: [{ + content: [{ type: 'text', text: 'post context' }], + source: { kind: 'plugin', plugin: 'post' }, + }], + } + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-in-post'), name: 'completed-before-post', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('cancelled while post policy waits') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: tool call aborted' }], + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + additionalContexts: [ + { source: { kind: 'plugin', plugin: 'child' } }, + { source: { kind: 'plugin', plugin: 'post' } }, + ], + }) + }) + + it('preserves an around-dispatch failure that settles after cancellation', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'wrapper-failure', + async execute() { dispatched += 1; return [] }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/execute', async () => { + entered.resolve(undefined) + await release.promise + throw new HarnessError('wrapper failed', 'WRAPPER_FAILURE') + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('wrapper-failure'), name: 'wrapper-failure', arguments: {}, signal: controller.signal, + }) + + await entered.promise + controller.abort('cancelled while wrapper failed') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: wrapper failed' }], + isError: true, + error: { name: 'HarnessError', code: 'WRAPPER_FAILURE' }, + }) + expect(dispatched).toBe(0) + }) + + it('preserves a tool-owned failure after the body observes cancellation', async () => { + const ctx = await setup() + const entered = Promise.withResolvers() + ctx.tools.register({ + ...echoTool, + name: 'tool-failure', + execute(_args, exec) { + entered.resolve(undefined) + return new Promise((_resolve, reject) => { + exec.signal.addEventListener('abort', () => { + reject(new HarnessError('tool failed', 'TOOL_FAILURE')) + }, { once: true }) + }) + }, + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('tool-failure'), name: 'tool-failure', arguments: {}, signal: controller.signal, + }) + + await entered.promise + controller.abort('cancelled running body') + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: tool failed' }], + isError: true, + error: { name: 'HarnessError', code: 'TOOL_FAILURE' }, + }) + }) + + it('preserves a post-policy failure that settles after cancellation', async () => { + const ctx = await setup() + ctx.tools.register(echoTool) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/post-execute', async () => { + entered.resolve(undefined) + await release.promise + throw new HarnessError('post-policy failed', 'POST_FAILURE') + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('post-failure'), name: 'echo', arguments: {}, signal: controller.signal, + }) + + await entered.promise + controller.abort('cancelled while post-policy failed') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: post-policy failed' }], + isError: true, + error: { name: 'HarnessError', code: 'POST_FAILURE' }, + }) + }) + + it('fuses caller cancellation back into a wrapper replacement for the running body', async () => { + const ctx = await setup() + const entered = Promise.withResolvers() + const replacement = new AbortController() + let bodySignal: AbortSignal | undefined + ctx.tools.register({ + ...echoTool, + name: 'cooperative', + execute(_args, exec) { + bodySignal = exec.signal + entered.resolve(undefined) + if (exec.signal.aborted) return Promise.resolve([]) + return new Promise((resolve) => { + exec.signal.addEventListener('abort', () => { resolve([]) }, { once: true }) + }) + }, + }) + ctx.on('tools/execute', async (exec, next) => { + const upstream = exec.signal + exec.signal = replacement.signal + try { + return await next() + } finally { + exec.signal = upstream + } + }) + + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-body'), name: 'cooperative', arguments: {}, signal: controller.signal, + }) + await entered.promise + expect(bodySignal).not.toBe(controller.signal) + expect(bodySignal).not.toBe(replacement.signal) + controller.abort('cancel running body') + + await expect(pending).resolves.toMatchObject({ + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + }) + expect(bodySignal?.aborted).toBe(true) + expect(replacement.signal.aborted).toBe(false) + }) + + it('restores the required caller signal after around dispatch', async () => { + const ctx = await setup() + let postSignal: AbortSignal | undefined + ctx.on('tools/execute', async (exec, next) => { + const upstream = exec.signal + exec.signal = new AbortController().signal + try { + return await next() + } finally { + exec.signal = upstream + } + }) + ctx.on('tools/post-execute', async (exec, _result, next) => { + postSignal = exec.signal + return next() + }) + const controller = new AbortController() + + await ctx.tools.execute({ + callId: CallId('restored-signal'), name: 'echo', arguments: {}, signal: controller.signal, + }) + + expect(postSignal).toBe(controller.signal) + }) + + it('waits for an uncooperative started body before returning ABORTED', async () => { + const ctx = await setup() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.tools.register({ + ...echoTool, + name: 'uncooperative', + execute(_args, exec) { + exec.deferContext({ + content: [{ type: 'text', text: 'nested outcome' }], + source: { kind: 'plugin', plugin: 'nested' }, + }) + entered.resolve(undefined) + return release.promise + }, + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('drain-body'), name: 'uncooperative', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('must still drain') + + const state = await Promise.race([ + pending.then(() => 'settled' as const), + Promise.resolve('pending' as const), + ]) + expect(state).toBe('pending') + release.resolve([]) + await expect(pending).resolves.toMatchObject({ + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + additionalContexts: [{ source: { kind: 'plugin', plugin: 'nested' } }], + }) + }) + + it('materializes a pre-aborted call and publishes one result without entering pipeline phases', async () => { + const ctx = await setup() + const phases = { pre: 0, around: 0, body: 0, post: 0, result: 0 } + const callerArguments = { nested: { value: 1 } } + const callerSignal = AbortSignal.abort('already cancelled') + let argumentReads = 0 + let observedArguments: unknown + let observedExecution: object | undefined + let observedToken: symbol | undefined + let observedSignal: AbortSignal | undefined + let observedResult: ToolExecutionResult | undefined + ctx.tools.register({ + ...echoTool, + name: 'domain-abort', + async execute() { phases.body += 1; return [] }, + }) + ctx.on('tools/pre-execute', async (_exec, next) => { phases.pre += 1; return next() }) + ctx.on('tools/execute', async (_exec, next) => { phases.around += 1; return next() }) + ctx.on('tools/post-execute', async (_exec, _result, next) => { phases.post += 1; return next() }) + ctx.on('tools/result', (exec, result) => { + phases.result += 1 + observedExecution = exec + observedArguments = exec.arguments + observedToken = exec.token + observedSignal = exec.signal + observedResult = result + }) + + const result = await ctx.tools.execute({ + callId: CallId('pre-aborted'), + name: 'domain-abort', + get arguments() { argumentReads += 1; return callerArguments }, + signal: callerSignal, + }) + + expect(argumentReads).toBe(1) + expect(phases).toEqual({ pre: 0, around: 0, body: 0, post: 0, result: 1 }) + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) + expect(observedResult).toBe(result) + expect(Object.isFrozen(observedExecution)).toBe(true) + expect(typeof observedToken).toBe('symbol') + expect(observedSignal).toBe(callerSignal) + expect(Object.isFrozen(result)).toBe(true) + expect(observedArguments).not.toBe(callerArguments) + expect(Object.isFrozen(observedArguments)).toBe(true) + expect(Object.isFrozen((observedArguments as { nested: object }).nested)).toBe(true) + }) + + it('lets argument materialization failure win over a pre-aborted signal', async () => { + const ctx = await setup() + let observed = 0 + ctx.on('tools/result', () => { observed += 1 }) + + const result = await ctx.tools.execute({ + callId: CallId('invalid-pre-aborted'), + name: 'missing', + arguments: { invalid: () => undefined }, + signal: AbortSignal.abort('already cancelled'), + }) + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error: tool execution arguments must be losslessly JSON-serializable' }], + isError: true, + }) + expect(observed).toBe(1) + }) + it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => { const ctx = await setup() ctx.tools.register(echoTool) let entered = false ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'deny', reason: 'nope' })) - ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise): Promise => { + ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise): Promise => { entered = true return next() }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: nope' }) expect(entered).toBe(false) // a denied call never enters the around-dispatch seam @@ -524,7 +1114,7 @@ describe('ToolRegistry', () => { }) let seen: { isError: boolean; error?: unknown } | undefined - ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise): Promise => { + ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise): Promise => { const result = await next() // The base next() IS dispatch-with-normalization: the wrapper sees the // normalized isError result, never a raw throw from the tool body. @@ -532,7 +1122,7 @@ describe('ToolRegistry', () => { return result }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'boom', arguments: {} }) expect(seen).toEqual({ isError: true, error: { name: 'HarnessError', code: 'BOOM' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' }) @@ -547,19 +1137,19 @@ describe('ToolRegistry', () => { }) let postSaw: boolean | undefined - ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise): Promise => next()) + ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise): Promise => next()) ctx.on('tools/post-execute', async (_exec, result, next) => { postSaw = result.isError return next() }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'boom', arguments: {} }) expect(postSaw).toBe(true) // the normalized isError still flows through post-execute expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: exploded' }) }) - it('a tools/execute listener can replace exec.signal for the dispatched tool (deadline pattern)', async () => { + it('re-fuses the caller signal with an around-dispatch replacement for the body', async () => { const ctx = await setup() let seenSignal: AbortSignal | undefined ctx.tools.register({ @@ -573,7 +1163,7 @@ describe('ToolRegistry', () => { const upstream = new AbortController().signal const replacement = new AbortController().signal - ctx.on('tools/execute', async (exec: ToolExecution, next: () => Promise): Promise => { + ctx.on('tools/execute', async (exec: ToolDispatchExecution, next: () => Promise): Promise => { expect(exec.signal).toBe(upstream) // Cordis next() ignores passed arguments, so a wrapper mutates exec in // place (the documented "mutate the shared object, then delegate" idiom). @@ -582,7 +1172,9 @@ describe('ToolRegistry', () => { }) await ctx.tools.execute({ callId: CallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream }) - expect(seenSignal).toBe(replacement) // dispatch saw the wrapper's replacement, not the upstream + expect(seenSignal).toBeDefined() + expect(seenSignal).not.toBe(upstream) + expect(seenSignal).not.toBe(replacement) }) it('a tools/execute listener can short-circuit dispatch by returning a result without next()', async () => { @@ -594,10 +1186,10 @@ describe('ToolRegistry', () => { async execute() { dispatched = true; return [] }, }) - ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise): Promise => + ctx.on('tools/execute', async (_exec: ToolDispatchExecution, _next: () => Promise): Promise => ({ content: [{ type: 'text', text: 'short-circuited' }], isError: false })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'never-runs', arguments: {} }) expect(dispatched).toBe(false) // returning without next() skips core dispatch expect(result.content[0]).toMatchObject({ text: 'short-circuited' }) }) @@ -615,6 +1207,7 @@ describe('ToolRegistry', () => { })) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('around-context'), name: 'echo', arguments: {}, }) expect(result.additionalContexts).toEqual([{ @@ -628,7 +1221,7 @@ describe('ToolRegistry', () => { ctx.tools.register(echoTool) ctx.on('tools/execute', async () => { throw new Error('wrapper broke') }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'Error: wrapper broke' }], isError: true, @@ -642,7 +1235,7 @@ describe('ToolRegistry', () => { throw new Error('permission hook broke') }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'Error: permission hook broke' }], @@ -657,7 +1250,7 @@ describe('ToolRegistry', () => { throw new Error('post hook broke') }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'Error: post hook broke' }], @@ -672,7 +1265,7 @@ describe('ToolRegistry', () => { throw new HarnessError('denied', 'DENIED') }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toMatchObject({ isError: true, @@ -861,6 +1454,7 @@ describe('defineTool / schema DSL', () => { }]) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('c1'), name: 'typed-echo', arguments: { text: 'hello', uppercase: true }, @@ -915,6 +1509,7 @@ describe('defineTool / schema DSL', () => { // Execution round-trip const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('c1'), name: 'roundtrip', arguments: { req: 'hello' }, @@ -947,6 +1542,7 @@ describe('defineTool / schema DSL', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('c1'), name: 'raw-tool', arguments: { path: '/tmp' }, @@ -1124,7 +1720,7 @@ describe('schema DSL optional and nested contracts', () => { throw { message: 'denied by object' } }, }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-thrower', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'object-thrower', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: denied by object' }) }) @@ -1139,7 +1735,7 @@ describe('schema DSL optional and nested contracts', () => { throw 'kaboom' }, }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'string-thrower', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'string-thrower', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' }) }) @@ -1154,7 +1750,7 @@ describe('schema DSL optional and nested contracts', () => { throw { code: 500 } }, }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-no-message', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'object-no-message', arguments: {} }) expect(result.isError).toBe(true) const firstContent = result.content[0]! expect(firstContent.type).toBe('text') @@ -1293,7 +1889,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }, })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'reader', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: invalid arguments: missing required property "path"', @@ -1310,7 +1906,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () return [{ type: 'text', text: `read ${args.path}` }] }, })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false }) }) @@ -1333,7 +1929,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () return [{ type: 'text', text: args.path }] }, })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'reader', arguments: {} }) expect(result.isError).toBe(true) expect(result.error).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' }) }) @@ -1348,7 +1944,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () throw new HarnessError('disk full', 'ENOSPC') }, }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'coded', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'coded', arguments: {} }) expect(result.isError).toBe(true) expect(result.error).toEqual({ name: 'HarnessError', code: 'ENOSPC' }) expect(result.content[0]).toMatchObject({ text: 'Error: disk full' }) @@ -1363,7 +1959,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () throw new Error('just a message') }, }) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'plain', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'plain', arguments: {} }) expect(result.isError).toBe(true) expect(result.error).toBeUndefined() expect(result.content[0]).toMatchObject({ text: 'Error: just a message' }) @@ -1382,7 +1978,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) // Missing the "required" path — but raw tools validate their own input, so // this reaches execute rather than being rejected by the harness. - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'raw', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'raw', arguments: {} }) expect(result.isError).toBe(false) }) diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index f7bc0a98c8..f77d7d3eb1 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -55,7 +55,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index b39e0fd178..b8fafc32c1 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -32,9 +32,9 @@ "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-goal": "^0.0.1", "@deepseek-ai/dsh-goal-session": "^0.0.1", - "@deepseek-ai/dsh-home": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -57,9 +57,9 @@ "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 049bdc9076..0ad7ef5f37 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -33,7 +33,7 @@ import * as toolSkill from '@deepseek-ai/dsh-tool-skill' import * as toolTasks from '@deepseek-ai/dsh-tool-tasks' import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop' import * as llmRetry from '@deepseek-ai/dsh-llm-retry' -import { resolveDshHome } from '@deepseek-ai/dsh-home' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' export const name = 'agent-spine-demo' diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 94253523ce..25c7f8422f 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -17,6 +17,8 @@ import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as scopeInvariant from '@deepseek-ai/dsh-scope/invariant' import * as agentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' +const testToolSignal = new AbortController().signal + declare module '@deepseek-ai/dsh-tasks' { interface TaskKindMap { probe: 'probe' @@ -385,6 +387,7 @@ describe('dsh-agent-spine-demo bundle', () => { expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['shared-skill']) const execution: ToolExecution = { + signal: testToolSignal, token: Symbol('agent-core-dsh-home-test') as ToolExecution['token'], callId: CallId('agent-core-dsh-home'), name: 'bash', @@ -456,11 +459,12 @@ describe('dsh-agent-spine-demo bundle', () => { }) const wait = vi.spyOn(ctx.tasks, 'wait') await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('task-config-forwarding'), name: 'task_output', arguments: { task_id: id, wait: true }, }) - expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined) + expect(wait).toHaveBeenCalledWith(id, 7, undefined, testToolSignal) await ctx.fiber.dispose() }) diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index 6938f422b9..050f8bc44c 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -63,7 +63,7 @@ "path": "../../support/invariants" }, { - "path": "../../util/home" + "path": "../../util/paths" }, { "path": "../../bash/tool-bash" diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index a2b61f85f6..7a6dfee8eb 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -181,12 +181,12 @@ async function waitForStartupIdle(agent: Agent, signal?: AbortSignal): Promise((resolve, reject) => { const onAbort = (): void => { - agent.cancel(interruptionReason(signal)) + agent.cancel({ kind: 'user' }) reject(new CliInterruptedError(interruptionReason(signal))) } signal.addEventListener('abort', onAbort, { once: true }) @@ -243,7 +243,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise options.onEvent(sessionId, event) } catch (error: unknown) { outputError = toError(error) - agent.cancel('stream output failed') + agent.cancel({ kind: 'user' }) } } @@ -273,7 +273,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise let onAbort: (() => void) | undefined if (signal !== undefined) { onAbort = (): void => { - agent.cancel(interruptionReason(signal)) + agent.cancel({ kind: 'user' }) if (targetTurn === undefined) settleRejected(new CliInterruptedError(interruptionReason(signal))) } signal.addEventListener('abort', onAbort, { once: true }) @@ -370,7 +370,7 @@ async function bootInterruptibly( export function formatTurnFailure(reason: TurnEndReason): string { switch (reason.kind) { case 'completed': return 'completed' - case 'aborted': return reason.reason === undefined ? 'was aborted' : `was aborted: ${reason.reason}` + case 'aborted': return 'was aborted' case 'error': return `failed at step ${reason.step}: ${'failure' in reason ? reason.failure.message : reason.message}` case 'disposed': return 'was disposed' case 'max-tokens': return 'reached the model output-token limit' diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index c57f09b006..3dadd32215 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -179,7 +179,7 @@ describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => { ) expect(result, JSON.stringify(result)).toMatchObject({ code, signal: null }) expect(result.stdout).toContain('"kind":"aborted"') - expect(result.stderr).toContain(`received ${signal}`) + expect(result.stderr).toContain('turn 1 was aborted') }, 30_000) }) }) diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index 554fee8bf1..adf3999445 100644 --- a/packages/examples/cli-demo/tests/cli-demo.spec.ts +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -10,6 +10,8 @@ import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { afterEach, describe, expect, it, vi } from 'vitest' import * as cliDemo from '../src/index.ts' +const testToolSignal = new AbortController().signal + const contexts: Context[] = [] async function skillConfig(catalogDescriptionMaxLength?: number): Promise> { @@ -131,6 +133,7 @@ describe('dsh-cli-demo app composition', () => { expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3) const execution: ToolExecution = { + signal: testToolSignal, token: Symbol('cli-demo-dsh-home-test') as ToolExecution['token'], callId: CallId('cli-demo-dsh-home'), name: 'bash', @@ -148,11 +151,12 @@ describe('dsh-cli-demo app composition', () => { }) const wait = vi.spyOn(ctx.tasks, 'wait') await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('cli-demo-task-config'), name: 'task_output', arguments: { task_id: id, wait: true }, }) - expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined) + expect(wait).toHaveBeenCalledWith(id, 7, undefined, testToolSignal) }) it('accepts false to keep task services without model-facing task controls', async () => { diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index f038efe67e..f20697b827 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -398,9 +398,9 @@ describe('runOneShot and executeCli', () => { await running abort.abort('received SIGINT') const output = await outcome - expect(JSON.parse(output.stdout)).toMatchObject({ success: false, reason: { kind: 'aborted', reason: 'received SIGINT' } }) + expect(JSON.parse(output.stdout)).toMatchObject({ success: false, reason: { kind: 'aborted' } }) expect(output.code).toBe(1) - expect(output.stderr).toContain('was aborted: received SIGINT') + expect(output.stderr).toContain('turn 1 was aborted') expect(agent.status).toBe('disposed') }) @@ -486,7 +486,7 @@ describe('formatTurnFailure', () => { const cases: [TurnEndReason, string][] = [ [{ kind: 'completed' }, 'completed'], [{ kind: 'aborted' }, 'was aborted'], - [{ kind: 'aborted', reason: 'stop' }, 'was aborted: stop'], + [{ kind: 'aborted' }, 'was aborted'], [{ kind: 'error', step: 2, message: 'bad' }, 'failed at step 2: bad'], [{ kind: 'error', step: 3, failure: { message: 'provider bad', code: 'SERVER' } }, 'failed at step 3: provider bad'], [{ kind: 'disposed' }, 'was disposed'], diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 0682c86e35..0eff077fea 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -162,7 +162,7 @@ export async function runRipgrep( command, stdoutMaxBytes: rawOutputMaxBytes, ...cwd !== undefined ? { workdir: cwd } : {}, - ...exec.signal ? { signal: exec.signal } : {}, + signal: exec.signal, }) let result: BashRunResult try { diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index 36fb2c28e6..7c2ab16705 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -16,10 +16,12 @@ import { join } from 'node:path' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' +const testToolSignal = new AbortController().signal + const hasRg = spawnSync('rg', ['--version'], { encoding: 'utf8' }).status === 0 let dir: string @@ -28,6 +30,7 @@ let ctx: Context let callCounter = 0 function call(name: string, args: unknown, agentObj?: object) { return ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`it-${++callCounter}`), name, arguments: args, @@ -165,8 +168,8 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () }) }) - describe('bash-start infrastructure failures stay in the SEARCH_* taxonomy', () => { - it('a pre-aborted exec.signal (real executor rejects before spawn) is SEARCH_ABORTED', async () => { + describe('pre-dispatch cancellation and bash-start failures', () => { + it('a pre-aborted registry call is ABORTED_BEFORE_DISPATCH', async () => { const controller = new AbortController() controller.abort() const result = await ctx.tools.execute({ @@ -176,7 +179,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () signal: controller.signal, }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + expect(result.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) }) it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => { diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index c11b10556a..d8f92d9f8f 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -14,7 +14,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' @@ -31,6 +31,7 @@ import { toWorkdirRelative, } from '@deepseek-ai/dsh-tool-fs-search' +const testToolSignal = new AbortController().signal const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1' /** A successful run result over the given stdout; overrides script the failure shapes. */ @@ -59,6 +60,7 @@ class FakeBash extends BashExecutor { requests: BashExecRequest[] = [] specs: BashExecSpec[] = [] startCalls = 0 + forwardSignal = true probeResult: BashRunResult = runResult('') probeError?: Error handler: (spec: BashExecSpec) => BashRunResult = () => runResult('') @@ -71,7 +73,7 @@ class FakeBash extends BashExecutor { workdir: request.workdir ?? '/work', timeoutMs: request.timeoutMs ?? 60_000, stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - signal: request.signal, + ...this.forwardSignal ? { signal: request.signal } : {}, sandboxMode: request.sandboxMode, } } @@ -147,6 +149,7 @@ const agent = (cwd?: string) => ({ session: { header: { id: 'session-1', ...cwd let callCounter = 0 function call(ctx: Context, name: string, args: unknown, options: { agent?: object; signal?: AbortSignal } = {}) { return ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, @@ -302,16 +305,13 @@ describe('workdir derivation and signal forwarding', () => { expect(bash.requests[1]).not.toHaveProperty('workdir') }) - it('forwards exec.signal into the bash spec (the abort reaches the backend)', async () => { + it('forwards exec.signal into the bash spec', async () => { const { ctx, bash } = await setup() const controller = new AbortController() - controller.abort() - bash.handler = spec => runResult('', { aborted: spec.signal?.aborted === true }) + bash.handler = () => runResult('') const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) expect(bash.specs[0]?.signal).toBe(controller.signal) - expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) - expect(text(result)).toContain('aborted') + expect(result.isError).toBe(false) }) it('reports the bash executor timeout as SEARCH_ABORTED with the budget', async () => { @@ -323,20 +323,46 @@ describe('workdir derivation and signal forwarding', () => { expect(text(result)).toContain('timed out after 1234ms') }) - it('translates a run() rejection under a pre-aborted signal into SEARCH_ABORTED', async () => { - // The seam contract: run() REJECTS for a pre-aborted signal (it never - // spawns). The plain rejection must not escape the SEARCH_* taxonomy. + it('skips a pre-aborted registry call before run()', async () => { const { ctx, bash } = await setup() const controller = new AbortController() controller.abort() bash.handler = () => { throw new Error('aborted before spawn') } const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(bash.specs).toHaveLength(0) + }) + + it('translates a run() rejection after the forwarded signal aborts', async () => { + const { ctx, bash } = await setup() + const controller = new AbortController() + bash.handler = () => { + controller.abort('cancel search') + throw new Error('executor stopped on abort') + } + + const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal }) + expect(result.isError).toBe(true) expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + expect(text(result)).toContain('aborted before completion') + }) + + it('translates an aborted executor result after dispatch starts', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { aborted: true, exitCode: null }) + + const result = await call(ctx, 'glob', { pattern: '*' }) + + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' }) + expect(text(result)).toContain('aborted before completion') }) it('translates a run() rejection without an abort (unusable workdir) into SEARCH_FAILED', async () => { const { ctx, bash } = await setup() + bash.forwardSignal = false bash.handler = () => { throw new Error('spawn bash ENOENT') } const result = await call(ctx, 'glob', { pattern: '*' }) expect(result.isError).toBe(true) diff --git a/packages/fs/tool-fs/src/sandbox.ts b/packages/fs/tool-fs/src/sandbox.ts index f58f9d6b13..e6cc0a61cd 100644 --- a/packages/fs/tool-fs/src/sandbox.ts +++ b/packages/fs/tool-fs/src/sandbox.ts @@ -106,7 +106,7 @@ export class FsSandboxSurface { agent: exec.agent, callId: exec.callId, toolName, - ...exec.signal ? { signal: exec.signal } : {}, + signal: exec.signal, }, ) } diff --git a/packages/fs/tool-fs/src/session-cwd.ts b/packages/fs/tool-fs/src/session-cwd.ts index 4f98a41a94..65a22bbc06 100644 --- a/packages/fs/tool-fs/src/session-cwd.ts +++ b/packages/fs/tool-fs/src/session-cwd.ts @@ -28,6 +28,6 @@ export function sessionResolveOptions(exec: ToolExecution): { cwd?: string; sign const cwd = sessionCwd(exec) return { ...cwd !== undefined ? { cwd } : {}, - ...exec.signal !== undefined ? { signal: exec.signal } : {}, + signal: exec.signal, } } diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 6a9e6f3568..a0dbc11294 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -12,11 +12,13 @@ import { join } from 'node:path' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +const testToolSignal = new AbortController().signal + let dir: string let ctx: Context let fiber: Awaited> @@ -26,6 +28,7 @@ const session = { header: {} } let callCounter = 0 function call(name: string, args: unknown) { return ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, @@ -299,6 +302,7 @@ describe('per-session cwd', () => { const callIn = (sessionObj: object, name: string, args: unknown) => ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, @@ -344,26 +348,25 @@ describe('signal, concurrency, and the fs/observed contract', () => { const callSig = (signal: AbortSignal, name: string, args: unknown) => ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never, signal }) const callOwned = (name: string, args: unknown) => - ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never }) + ctx.tools.execute({ signal: testToolSignal, callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never }) - it('a pre-aborted signal makes read/write/edit return isError FS_ABORTED', async () => { + it('a pre-aborted registry call skips read/write/edit with ABORTED_BEFORE_DISPATCH', async () => { await writeFile(join(dir, 'a.txt'), 'hello') const read = await callSig(AbortSignal.abort(), 'read', { file_path: 'a.txt' }) expect(read.isError).toBe(true) - expect(read.error).toMatchObject({ code: 'FS_ABORTED' }) + expect(read.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) const write = await callSig(AbortSignal.abort(), 'write', { file_path: 'new.txt', content: 'x' }) expect(write.isError).toBe(true) - expect(write.error).toMatchObject({ code: 'FS_ABORTED' }) + expect(write.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) await expect(readFile(join(dir, 'new.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) // Read first (un-aborted, SAME session owner) so the edit clears the - // observation gate; then the aborted edit fails on the signal, not on - // FS_NOT_OBSERVED. + // observation gate; then the registry skips the aborted edit before its body. expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false) const edit = await callSig(AbortSignal.abort(), 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }) expect(edit.isError).toBe(true) - expect(edit.error).toMatchObject({ code: 'FS_ABORTED' }) + expect(edit.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello') // unchanged }) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 4d80061584..5de343abe3 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -27,6 +27,8 @@ import type { FileReadOutcome } from '../src/read-render.ts' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +const testToolSignal = new AbortController().signal + /** An in-memory fake provider; a test can arm a rejection on any primitive. */ class FakeFs extends FileSystem { files = new Map() @@ -93,6 +95,7 @@ async function setup() { let callCounter = 0 function call(ctx: Context, name: string, args: unknown, agent?: object) { return ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, @@ -112,11 +115,11 @@ describe('registration', () => { it('declares read parallel-safe while write/edit remain exclusive', async () => { const { ctx } = await setup() - expect(ctx.tools.executionMode({ callId: CallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } })) + expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } })) .toEqual({ kind: 'parallel' }) - expect(ctx.tools.executionMode({ callId: CallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } })) + expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } })) .toEqual({ kind: 'exclusive' }) - expect(ctx.tools.executionMode({ callId: CallId('edit-exclusive'), name: 'edit', arguments: { file_path: 'a.txt', old_string: 'x', new_string: 'y' } })) + expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('edit-exclusive'), name: 'edit', arguments: { file_path: 'a.txt', old_string: 'x', new_string: 'y' } })) .toEqual({ kind: 'exclusive' }) }) diff --git a/packages/goal/goal-session/README.md b/packages/goal/goal-session/README.md index 2ad4b161a3..ee86f72d8c 100644 --- a/packages/goal/goal-session/README.md +++ b/packages/goal/goal-session/README.md @@ -44,7 +44,7 @@ A goal mutation made during its round supersedes settlement of the older revisio Activation is never inherited when this plugin loads over an existing agent. `GoalService.disarm()` removes process-local authority without changing durable phase, revision, or history; explicit human-authorized resume records the later reactivation. The same rule applies after session resume and fork through the goal domain's `agent/session-start` handling. -Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` before clearing queues or aborting a step. The plugin durably pauses an active goal only when the cancellation owns a reserved or admitted goal attempt; cancellation of unrelated human work merely disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels an admitted round, and awaits the driver plus agent quiescence while its event fence remains installed. +Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` with its typed cause before clearing queues or aborting the turn. The plugin durably pauses an active goal only when the cancellation owns a reserved or admitted goal attempt; cancellation of unrelated human work merely disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels an admitted round with the `parent` cause, and awaits the driver plus agent quiescence while its event fence remains installed. ## Model Experience diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index 24364a4983..deb0a33f30 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -313,7 +313,7 @@ export function apply(ctx: Context): void { state.competingQueued = true if (attempt?.phase === 'queued') attempt.stale = true }) - ctx.on('agent/cancel-requested', (agent, reason) => { + ctx.on('agent/cancel-requested', (agent, cause) => { const state = stateFor(agent) const attempt = state.attempt state.attempt = undefined @@ -325,7 +325,7 @@ export function apply(ctx: Context): void { return } try { - applyOutcome(state, goal, { kind: 'pause', reason }) + applyOutcome(state, goal, { kind: 'pause', reason: cause.kind }) } catch (error: unknown) { ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`) disarm(state) @@ -393,7 +393,7 @@ export function apply(ctx: Context): void { && source.round === goal.roundsStarted + 1 } - ctx.on('agent/prompt-submit', async (agent, content, source, next): Promise => { + ctx.on('agent/prompt-submit', async (agent, content, source, _signal, next): Promise => { if (!isGoalRoundSource(source)) return next() const state = stateFor(agent) let valid = false @@ -443,7 +443,7 @@ export function apply(ctx: Context): void { if (attempt !== undefined) { attempt.stale = true if (attempt.phase === 'admitted' && state.agent.status === 'running') { - state.agent.cancel('goal-session driver disposed') + state.agent.cancel({ kind: 'parent' }) } waits.push(state.agent.whenIdle()) } diff --git a/packages/goal/goal-session/src/outcome.ts b/packages/goal/goal-session/src/outcome.ts index e69f53eeec..d3048cb6f4 100644 --- a/packages/goal/goal-session/src/outcome.ts +++ b/packages/goal/goal-session/src/outcome.ts @@ -26,7 +26,7 @@ export function classifyGoalRound(reason: TurnEndReason, durable: boolean): Goal case 'completed': return { kind: 'continue' } case 'aborted': - return { kind: 'pause', reason: reason.reason ?? 'cancelled' } + return { kind: 'pause', reason: 'cancelled' } case 'error': { const { code, message } = reason.failure ?? reason return code === 'RATE_LIMIT' || code === 'QUOTA' diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 8495b73ecc..c6cb96df82 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -123,7 +123,6 @@ async function waitForRequests(adapter: ScriptedAdapter, count: number): Promise describe('goal-round outcome policy', () => { it.each([ [{ kind: 'completed' }, true, { kind: 'continue' }], - [{ kind: 'aborted', reason: 'operator stopped' }, true, { kind: 'pause', reason: 'operator stopped' }], [{ kind: 'aborted' }, true, { kind: 'pause', reason: 'cancelled' }], [{ kind: 'error', step: 1, message: 'slow down', code: 'RATE_LIMIT' }, true, { kind: 'blocked', code: 'usage-limited', message: 'slow down' }], @@ -248,7 +247,7 @@ describe('same-session goal driving', () => { it('maps a downstream prompt veto to blocked without admitting the round', async () => { const test = await harness([]) - test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => source.kind === 'goal' + test.ctx.on('agent/prompt-submit', (_agent, _content, source, _signal, next) => source.kind === 'goal' ? Promise.resolve({ kind: 'block', reason: 'deployment policy' }) : next()) test.ctx.goals.create(test.agent, { objective: 'respect policy' }) @@ -264,7 +263,7 @@ describe('same-session goal driving', () => { it('does not reserve again when a stopped-goal observer queues ordinary work', async () => { const test = await harness([textResponse('human follow-up')]) - test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => source.kind === 'goal' + test.ctx.on('agent/prompt-submit', (_agent, _content, source, _signal, next) => source.kind === 'goal' ? Promise.resolve({ kind: 'block', reason: 'stop this round' }) : next()) test.ctx.on('goal/changed', (agent, change) => { @@ -284,7 +283,7 @@ describe('same-session goal driving', () => { const cancel = test.ctx.on('agent/queued', (agent, _content, info) => { if (agent === test.agent && info.source.kind === 'goal') { cancel() - agent.cancel('operator cancelled pending goal') + agent.cancel({ kind: 'user' }) } }) test.ctx.goals.create(test.agent, { objective: 'do not start yet' }) @@ -302,7 +301,7 @@ describe('same-session goal driving', () => { test.ctx.goals.create(test.agent, { objective: 'stop in flight' }) await waitForRequests(test.adapter, 1) - test.agent.cancel('operator stopped active goal') + test.agent.cancel({ kind: 'user' }) await test.agent.whenIdle() const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'paused') @@ -369,7 +368,7 @@ describe('same-session goal driving', () => { it('rechecks revision after downstream prompt hooks before admitting', async () => { const test = await harness([textResponse('new revision')]) let edited = false - test.ctx.on('agent/prompt-submit', (agent, _content, source, next) => { + test.ctx.on('agent/prompt-submit', (agent, _content, source, _signal, next) => { if (source.kind === 'goal' && !edited) { edited = true const current = test.ctx.goals.get(agent) @@ -543,7 +542,7 @@ describe('same-session goal driving', () => { it('fails a post-hook read closed before the prompt can enter history', async () => { const test = await harness([]) let armed = true - test.ctx.on('agent/prompt-submit', (_agent, _content, source, next) => { + test.ctx.on('agent/prompt-submit', (_agent, _content, source, _signal, next) => { if (source.kind === 'goal' && armed) { armed = false vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { @@ -575,7 +574,7 @@ describe('same-session goal driving', () => { it('does not invent goal state when ordinary queued work is cancelled', async () => { const test = await harness([]) test.agent.send([{ type: 'text', text: 'cancel ordinary work' }]) - test.agent.cancel('ordinary cancellation') + test.agent.cancel({ kind: 'user' }) await test.agent.whenIdle() expect(test.ctx.goals.get(test.agent)).toBeUndefined() @@ -588,7 +587,7 @@ describe('same-session goal driving', () => { await waitForRequests(test.adapter, 1) const created = test.ctx.goals.create(test.agent, { objective: 'continue after inspection' }) - test.agent.cancel('cancel the inspection') + test.agent.cancel({ kind: 'user' }) await test.agent.whenIdle() expect(test.ctx.goals.get(test.agent)).toMatchObject({ @@ -608,7 +607,7 @@ describe('same-session goal driving', () => { vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => { throw new Error('pause failed') }) - agent.cancel('cancel the reserved goal round') + agent.cancel({ kind: 'user' }) }) test.ctx.goals.create(test.agent, { objective: 'fail closed after cancellation' }) @@ -621,10 +620,10 @@ describe('same-session goal driving', () => { it('blocks admission when downstream cancellation clears the reservation', async () => { const test = await harness([]) let cancelled = false - test.ctx.on('agent/prompt-submit', (agent, _content, source, next) => { + test.ctx.on('agent/prompt-submit', (agent, _content, source, _signal, next) => { if (source.kind === 'goal' && !cancelled) { cancelled = true - agent.cancel('cancel from downstream admission policy') + agent.cancel({ kind: 'user' }) } return next() }) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 2065bac23e..faaf9d1c76 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -13,6 +13,8 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' import * as toolGoal from '@deepseek-ai/dsh-tool-goal' +const testToolSignal = new AbortController().signal + interface StubAgent { readonly agent: Agent readonly session: Session @@ -84,6 +86,7 @@ async function execute( initiator: Agent | undefined = agent, ): Promise { const run = () => ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`call-${Math.random()}`), name, arguments: args, @@ -113,7 +116,7 @@ describe('goal tool registration and presentation', () => { expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name)) .toEqual(['create_goal', 'get_goal', 'update_goal']) for (const name of ['create_goal', 'get_goal', 'update_goal']) { - expect(ctx.tools.executionMode({ callId: CallId(name), name, arguments: {} })) + expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId(name), name, arguments: {} })) .toEqual({ kind: 'exclusive' }) } const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal') @@ -197,6 +200,7 @@ describe('goal tool execution authority', () => { openTurn(root, { kind: 'user' }) const driverless = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('call-driverless'), name: 'get_goal', arguments: {}, @@ -326,7 +330,7 @@ describe('goal tool state transitions', () => { goal_id: goal['id'], revision: goal['revision'], action: 'resume', }, root.agent)) expect(goal).toMatchObject({ phase: 'active', revision: 4 }) - expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1)).toBeUndefined() + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1, testToolSignal)).toBeUndefined() }) it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => { @@ -337,7 +341,7 @@ describe('goal tool state transitions', () => { goal_id: created.id, revision: created.revision, action: 'pause', }, root.agent) expect(resultGoal(paused)).toMatchObject({ phase: 'paused' }) - expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', humanTurn)).toBeUndefined() + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', humanTurn, testToolSignal)).toBeUndefined() const resumed = resultGoal(await execute(ctx, 'update_goal', { goal_id: created.id, revision: 2, action: 'resume', }, root.agent)) @@ -350,8 +354,8 @@ describe('goal tool state transitions', () => { goal_id: created.id, revision: resumed['revision'], action: 'complete', }, root.agent) expect(resultGoal(complete)).toMatchObject({ phase: 'complete' }) - expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn)).toEqual({ action: 'stop' }) - expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn)).toBeUndefined() + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn, testToolSignal)).toEqual({ action: 'stop' }) + expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn, testToolSignal)).toBeUndefined() }) it('rearms a restored active goal only after a new direct human prompt', async () => { diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index 0c630686b4..2265c583d5 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -222,7 +222,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) return next() }) diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 101c542d5a..b3c90021a5 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -10,6 +10,8 @@ import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard' import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +const testToolSignal = new AbortController().signal + /** * Behavior suite for the repeat-tool-call guard: chain semantics (identical / * different-tracked / untracked-transparent / per-agent / resets), threshold @@ -284,7 +286,7 @@ describe('chain semantics', () => { it('ignores direct executes with no agent (they neither crash nor advance any chain)', async () => { const ctx = await harness({ thresholds: [2] }) - const direct = await ctx.tools.execute({ callId: CallId('d1'), name: 'probe', arguments: { q: 1 } }) + const direct = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('d1'), name: 'probe', arguments: { q: 1 } }) expect(direct.isError).toBe(false) ctx.llm.registerAdapter(['mock'], new MockAdapter([ diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index cae3f5e6b6..42a642caf0 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -18,7 +18,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud ## Primitives - **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws). -- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations. +- **`runHook(bash, hook, options, now)`** — require and forward the caller-owned `options.signal`, serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Cancellation therefore reaches the executor's process-group kill and join boundary. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations. - **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge. - **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order. - **`createDetachedRuns()`** — quiescence tracking for the emit-shaped points, which run detached (no seam awaits them). The bridge tracks each run chain — the hook run PLUS its continuation — and registers `drain()` as its effect disposer: drain fires the tracker's abort `signal` (so a still-running hook process is killed via `runHook`, not awaited out to its timeout), then resolves once every tracked chain has settled. `fiber.dispose()` resolving therefore means no detached hook work is left to fire into a disposed context ([defensive patterns](../../../docs/defensive-patterns.md): dispose must reach quiescence). diff --git a/packages/hooks/hook-protocol/src/runner.ts b/packages/hooks/hook-protocol/src/runner.ts index fefb6936c9..802022085e 100644 --- a/packages/hooks/hook-protocol/src/runner.ts +++ b/packages/hooks/hook-protocol/src/runner.ts @@ -27,8 +27,8 @@ 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). */ - signal?: AbortSignal + /** Explicit owning-operation signal; firing it cancels the hook run. */ + readonly signal: AbortSignal /** Whether to append a trailing newline to the stdin payload (CC yes, Codex no). */ trailingNewline: boolean /** @@ -78,9 +78,9 @@ export async function runHook( command: hook.command, timeoutMs, stdin, + signal: options.signal, ...options.cwd !== undefined ? { workdir: options.cwd } : {}, ...options.env !== undefined ? { env: options.env } : {}, - ...options.signal ? { signal: options.signal } : {}, } try { diff --git a/packages/hooks/hook-protocol/tests/runner.spec.ts b/packages/hooks/hook-protocol/tests/runner.spec.ts index c2990e10be..09e0e65275 100644 --- a/packages/hooks/hook-protocol/tests/runner.spec.ts +++ b/packages/hooks/hook-protocol/tests/runner.spec.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, expectTypeOf, it } from 'vitest' import type { BashExecRequest, BashExecSpec, BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash' import { DEFAULT_HOOK_TIMEOUT_MS, runHook } from '@deepseek-ai/dsh-hook-protocol' +import type { RunHookOptions } from '@deepseek-ai/dsh-hook-protocol' /** * A minimal stand-in for the bits of {@link BashExecutor} that {@link runHook} @@ -51,12 +52,18 @@ function result(over: Partial = {}): BashRunResult { } const clock = () => { let t = 0; return () => (t += 5) } // +5ms per call → duration 5 +const testSignal = (): AbortSignal => new AbortController().signal describe('runHook — payload + env + stdin plumbing', () => { + it('requires an explicit caller-owned abort signal', () => { + expectTypeOf().toEqualTypeOf() + }) + it('serializes the payload to stdin (with trailing newline when requested)', async () => { const { bash, specs } = recordingBash(async () => result({ stdout: { text: '', truncated: false } })) await runHook(bash, { command: 'my-hook.sh' }, { payload: { hook_event_name: 'PreToolUse', tool_name: 'Bash' }, + signal: testSignal(), defaultTimeoutMs: 60000, trailingNewline: true, }, clock()) @@ -66,14 +73,14 @@ describe('runHook — payload + env + stdin plumbing', () => { it('omits the trailing newline when trailingNewline is false (Codex)', async () => { const { bash, specs } = recordingBash(async () => result()) - await runHook(bash, { command: 'h' }, { payload: { a: 1 }, defaultTimeoutMs: 1000, trailingNewline: false }, clock()) + await runHook(bash, { command: 'h' }, { payload: { a: 1 }, signal: testSignal(), defaultTimeoutMs: 1000, trailingNewline: false }, clock()) expect(specs[0]!.stdin).toBe('{"a":1}') }) it('threads env and cwd into the request', async () => { const { bash, specs } = recordingBash(async () => result()) await runHook(bash, { command: 'h' }, { - payload: {}, env: { CLAUDE_PROJECT_DIR: '/proj' }, cwd: '/work', + payload: {}, env: { CLAUDE_PROJECT_DIR: '/proj' }, cwd: '/work', signal: testSignal(), defaultTimeoutMs: 1000, trailingNewline: true, }, clock()) expect(specs[0]!.env).toEqual({ CLAUDE_PROJECT_DIR: '/proj' }) @@ -82,13 +89,13 @@ describe('runHook — payload + env + stdin plumbing', () => { it('a per-hook timeoutSec (seconds) overrides the default (ms)', async () => { const { bash, specs } = recordingBash(async () => result()) - await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock()) + await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, signal: testSignal(), defaultTimeoutMs: 60000, trailingNewline: true }, clock()) expect(specs[0]!.timeoutMs).toBe(3000) }) it('falls back to the default timeout when the hook sets none', async () => { const { bash, specs } = recordingBash(async () => result()) - await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock()) + await runHook(bash, { command: 'h' }, { payload: {}, signal: testSignal(), defaultTimeoutMs: 60000, trailingNewline: true }, clock()) expect(specs[0]!.timeoutMs).toBe(60000) expect(DEFAULT_HOOK_TIMEOUT_MS).toBe(600_000) // the CC/Codex reference default (10 minutes) }) @@ -106,7 +113,7 @@ describe('runHook — outcome decoding + duration', () => { const { bash } = recordingBash(async () => result({ exitCode: 0, stdout: { text: JSON.stringify({ decision: 'block', reason: 'no' }), truncated: false }, })) - const { output, durationMs } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + const { output, durationMs } = await runHook(bash, { command: 'h' }, { payload: {}, signal: testSignal(), defaultTimeoutMs: 1000, trailingNewline: true }, clock()) expect(output.decision).toBe('block') expect(output.reason).toBe('no') expect(durationMs).toBe(5) @@ -114,7 +121,7 @@ describe('runHook — outcome decoding + duration', () => { it('a signal death (exitCode null) decodes as undefined exit (non-blocking error)', async () => { const { bash } = recordingBash(async () => result({ exitCode: null, signal: 'SIGKILL', stderr: { text: 'killed', truncated: false } })) - const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + const { output } = await runHook(bash, { command: 'h' }, { payload: {}, signal: testSignal(), defaultTimeoutMs: 1000, trailingNewline: true }, clock()) expect(output.exitCode).toBeUndefined() expect(output.decision).toBeUndefined() expect(output.stderr).toBe('killed') @@ -122,7 +129,7 @@ describe('runHook — outcome decoding + duration', () => { it('an executor rejection (infra fault) becomes a non-blocking error, never throws', async () => { const { bash } = recordingBash(async () => { throw new Error('bad workdir: ENOENT') }) - const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + const { output } = await runHook(bash, { command: 'h' }, { payload: {}, signal: testSignal(), defaultTimeoutMs: 1000, trailingNewline: true }, clock()) expect(output.exitCode).toBeUndefined() expect(output.stderr).toBe('bad workdir: ENOENT') expect(output.decision).toBeUndefined() @@ -130,7 +137,7 @@ describe('runHook — outcome decoding + duration', () => { it('a non-Error rejection is stringified onto stderr', async () => { const { bash } = recordingBash(async () => { throw 'plain string fault' }) - const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + const { output } = await runHook(bash, { command: 'h' }, { payload: {}, signal: testSignal(), defaultTimeoutMs: 1000, trailingNewline: true }, clock()) expect(output.stderr).toBe('plain string fault') }) @@ -140,7 +147,7 @@ describe('runHook — outcome decoding + duration', () => { stdout: { text: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } }), truncated: false }, })) const { output } = await runHook(bash, { command: 'h' }, { - payload: {}, defaultTimeoutMs: 1000, trailingNewline: true, expectedEventName: 'Stop', + payload: {}, signal: testSignal(), defaultTimeoutMs: 1000, trailingNewline: true, expectedEventName: 'Stop', }, clock()) // A PreToolUse block on a Stop hook is malformed → its decision is discarded. expect(output.hookEventName).toBe('PreToolUse') diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 03ac21fea6..5a5d33427d 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -132,7 +132,7 @@ export function apply(ctx: Context, config: Config): void { point: string, matchQuery: string, payload: unknown, - opts: { agent?: Agent; turn?: number; signal?: AbortSignal }, + opts: { agent?: Agent; turn?: number; readonly signal: AbortSignal }, ): Promise { const groups: MatcherGroup[] = parsed[point] ?? [] const outputs: HookOutput[] = [] @@ -159,7 +159,7 @@ export function apply(ctx: Context, config: Config): void { defaultTimeoutMs, ...hookEnv ? { env: hookEnv } : {}, ...workdir !== undefined ? { cwd: workdir } : {}, - ...opts.signal ? { signal: opts.signal } : {}, + signal: opts.signal, trailingNewline: true, // Discard a `hookSpecificOutput` block whose `hookEventName` names a // different event than the one firing (the schemas key it by event). @@ -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(ctx, agent, content), { agent, turn }) + const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn, signal }) if (merged.decision === 'deny') { return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } } @@ -231,7 +231,7 @@ export function apply(ctx: Context, config: Config): void { // --- PreToolUse → PreToolDecision. Matcher subject is the tool name. --- ctx.on('tools/pre-execute', async (exec, next): Promise => { const turn = lastTurn(exec.agent) - const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} } return next() @@ -240,7 +240,7 @@ export function apply(ctx: Context, config: Config): void { // --- PostToolUse → PostToolDecision. Matcher subject is the tool name. --- ctx.on('tools/post-execute', async (exec, result, next): Promise => { const turn = lastTurn(exec.agent) - const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) const context = contextFrom(merged) if (merged.decision === 'deny') { return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} } @@ -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(ctx, agent), { agent, turn }) + ctx.on('agent/turn-continuation', async (agent, turn, _default, signal, next): Promise => { + const merged = await runPoint('Stop', '', stopPayload(ctx, 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-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 2525c64421..df6d55180a 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -15,6 +15,8 @@ import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +const testToolSignal = new AbortController().signal + /** Targeted branch coverage for the CC bridge: option arms, warn paths, no-agent * fallbacks, contextFrom-empty, and the detached-listener catch handlers. */ @@ -150,7 +152,7 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) // Call execute() directly with NO agent — the bridge's no-agent/no-turn path. const { CallId } = await import('@deepseek-ai/dsh-llm') - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} }) expect(ran).toBe(false) expect(result.isError).toBe(true) }) diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 75c33e2d92..7d05950957 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -106,7 +106,12 @@ export function apply(ctx: Context, config: Config): void { point: string, matchQuery: string, payload: unknown, - opts: { agent?: Agent; turn?: number; signal?: AbortSignal; plainStdoutAsContext?: boolean }, + opts: { + agent?: Agent + turn?: number + readonly signal: AbortSignal + plainStdoutAsContext?: boolean + }, ): Promise { const groups: MatcherGroup[] = parsed[point] ?? [] const outputs: HookOutput[] = [] @@ -129,7 +134,7 @@ export function apply(ctx: Context, config: Config): void { payload, defaultTimeoutMs, ...workdir !== undefined ? { cwd: workdir } : {}, - ...opts.signal ? { signal: opts.signal } : {}, + signal: opts.signal, trailingNewline: false, // Codex writes stdin without a trailing newline. // Discard a `hookSpecificOutput` block naming a different event. expectedEventName: point, @@ -183,9 +188,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(ctx, agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true }) + const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(ctx, 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 @@ -203,7 +208,7 @@ export function apply(ctx: Context, config: Config): void { // PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored). ctx.on('tools/pre-execute', async (exec, next): Promise => { const turn = lastTurn(exec.agent) - const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) /* jscpd:ignore-end */ if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } return next() @@ -213,7 +218,7 @@ export function apply(ctx: Context, config: Config): void { ctx.on('tools/post-execute', async (exec, result, next): Promise => { const turn = lastTurn(exec.agent) /* jscpd:ignore-start */ - const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) const context = contextFrom(merged) if (merged.decision === 'deny') { return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} } @@ -236,8 +241,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(ctx, 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(ctx, 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/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 2cb4cb0bc5..684650104a 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -105,6 +105,32 @@ describe('hooks-codex bridge', () => { expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal') }) + it('turn cancellation aborts and reaps a running UserPromptSubmit hook before idle', async () => { + const dir = configDir() + const pidFile = join(dir, 'pid') + const marker = join(dir, 'started') + const slow = script(dir, 'slow-prompt.sh', `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`) + writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: slow }] }] }) + + const adapter = new MockAdapter([textResponse('must not run')]) + const ctx = await harness(dir, adapter) + const agent = ctx.agentLoop.create(SessionId('cancel-prompt-hook'), { provider: 'mock', model: 'mock' }) + agent.send([{ type: 'text', text: 'cancel the hook' }]) + await waitFor(() => existsSync(marker)) + const pid = Number(readFileSync(pidFile, 'utf8').trim()) + + const idle = agent.whenIdle() + agent.cancel({ kind: 'user' }) + await idle + + expect(() => process.kill(pid, 0)).toThrow() + expect(adapter.requests).toHaveLength(0) + expect(events(agent).findLast(event => event.type === 'turn/end')).toMatchObject({ + data: { reason: { kind: 'aborted' } }, + }) + expect(events(agent).some(event => event.type === 'hook/result' && event.data.point === 'UserPromptSubmit')).toBe(true) + }) + it('only the five bridge-supported Codex events are honored — a SubagentStop entry is ignored', async () => { const dir = configDir() const s = script(dir, 'x.sh', '#!/usr/bin/env bash\nexit 2\n') diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index e02ea52df1..9bf14da46a 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -13,6 +13,8 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +const testToolSignal = new AbortController().signal + const dirs: string[] = [] afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hx-cov-')); dirs.push(d); return d } @@ -451,7 +453,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const { CallId } = await import('@deepseek-ai/dsh-llm') - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) expect(ran).toBe(false) // denied expect(result.isError).toBe(true) }) @@ -462,7 +464,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { CallId } = await import('@deepseek-ai/dsh-llm') - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } }) expect(result.isError).toBeFalsy() expect(result.additionalContexts?.[0]?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true) }) diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 81b1aca16d..7f9bc6b061 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -101,7 +101,7 @@ describe('llm-retry invariants', () => { }).toThrow(/latest closed step is 1/) const closedTurn = closeStep(ctx, 'retry-invariant-closed-turn') - closedTurn.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: 'cancelled' } }) + closedTurn.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) expect(() => { closedTurn.append('llm/retry', { turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 3e5a9c8dcd..1668c36d73 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -43,7 +43,7 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) delayMs: 750, failure: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }, }) - session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: 'cancelled in backoff' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } }) expect(session.deriveMessages()).toEqual([]) await ctx.sessions.flush(session) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index bf6871668d..b424718187 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -400,13 +400,13 @@ describe('bounded transient retry policy', () => { agent.send([{ type: 'text', text: 'go' }]) await scheduled const idle = waitForIdle(context, agent) - agent.cancel('user cancelled during retry') + agent.cancel({ kind: 'user' }) await idle expect(adapter.requests).toHaveLength(1) expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', - data: { reason: { kind: 'aborted', reason: 'user cancelled during retry' } }, + data: { reason: { kind: 'aborted' } }, }) expect(vi.getTimerCount()).toBe(0) }) @@ -419,7 +419,7 @@ describe('bounded transient retry policy', () => { ]) ;({ ctx: context } = await harness(adapter, {}, (ctx) => { ctx.on('agent/request-error', async (agent, _turn, _step, _error, _failure, _history, _signal, next) => { - agent.cancel('cancelled by earlier recovery policy') + agent.cancel({ kind: 'user' }) return next() }) })) @@ -433,7 +433,7 @@ describe('bounded transient retry policy', () => { expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', - data: { reason: { kind: 'aborted', reason: 'cancelled by earlier recovery policy' } }, + data: { reason: { kind: 'aborted' } }, }) }) @@ -446,7 +446,7 @@ describe('bounded transient retry policy', () => { ;({ ctx: context } = await harness(adapter)) const agent = context.agentLoop.create(SessionId('retry-event-cancel'), { provider: 'mock', model: 'mock' }) context.on('session/event', (session, event) => { - if (session === agent.session && event.type === 'llm/retry') agent.cancel('cancelled by retry observer') + if (session === agent.session && event.type === 'llm/retry') agent.cancel({ kind: 'user' }) }) const idle = waitForIdle(context, agent) diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index 8b4cb79de6..c6b8ce60f3 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -66,8 +66,10 @@ async function mount(hang: boolean, timeoutMs?: number): Promise { } let seq = 0 +const testToolSignal = new AbortController().signal function call(ctx: Context, args: unknown) { return ctx.tools.execute({ + signal: testToolSignal, callId: `int-${++seq}` as never, name: 'lsp', arguments: args, diff --git a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts index 37c995a3a6..143e923b38 100644 --- a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts +++ b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts @@ -39,9 +39,11 @@ async function mount( } let seq = 0 +const testToolSignal = new AbortController().signal /** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */ function call(ctx: Context, args: unknown, cwd: string | null = '/ws') { return ctx.tools.execute({ + signal: testToolSignal, callId: `c-${++seq}` as never, name: 'lsp', arguments: args, diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts index 7b9217e814..92f3742547 100644 --- a/packages/mcp/mcp-client/src/tools.ts +++ b/packages/mcp/mcp-client/src/tools.ts @@ -165,7 +165,7 @@ function createExecutor( { name: rawName, arguments: argsObj }, undefined, { - ...exec.signal ? { signal: exec.signal } : {}, + signal: exec.signal, timeout: opts.toolCallTimeoutMs, }, ) diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index dc783b3edf..803d4d81f9 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -26,6 +26,8 @@ import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts' import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' import type { Config } from '@deepseek-ai/dsh-mcp-client' +const testToolSignal = new AbortController().signal + const fixtureServerPath = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) // Resolve package-local .bin for pnpm-hoisted MCP server binaries. @@ -119,6 +121,7 @@ describe('fixture server — controlled scenarios', () => { it('executes the dotted tool via its normalized public name', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: publicToolName('fixture', 'admin.reset'), arguments: {}, }) expect(result.isError).toBe(false) @@ -127,6 +130,7 @@ describe('fixture server — controlled scenarios', () => { it('executes add(2, 3) → "5"', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__fixture__add', arguments: { a: 2, b: 3 }, }) expect(result.isError).toBe(false) @@ -135,6 +139,7 @@ describe('fixture server — controlled scenarios', () => { it('executes greet("World") → "Hello, World!"', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__fixture__greet', arguments: { name: 'World' }, }) expect(result.isError).toBe(false) @@ -143,6 +148,7 @@ describe('fixture server — controlled scenarios', () => { it('executes fail() → isError result', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__fixture__fail', arguments: {}, }) expect(result.isError).toBe(true) @@ -151,6 +157,7 @@ describe('fixture server — controlled scenarios', () => { it('executes image() → image placeholder', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__fixture__image', arguments: {}, }) expect(result.isError).toBe(false) @@ -241,6 +248,7 @@ describe('server-everything — official test server', () => { it('executes echo({ message: "hello" }) → "Echo: hello"', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__everything__echo', arguments: { message: 'hello' }, }) expect(result.isError).toBe(false) @@ -249,6 +257,7 @@ describe('server-everything — official test server', () => { it('executes get-sum({ a: 3, b: 7 }) → contains "10"', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__everything__get-sum', arguments: { a: 3, b: 7 }, }) expect(result.isError).toBe(false) @@ -257,6 +266,7 @@ describe('server-everything — official test server', () => { it('executes get-tiny-image → image placeholder', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__everything__get-tiny-image', arguments: {}, }) expect(result.isError).toBe(false) @@ -306,6 +316,7 @@ describe('server-filesystem — real filesystem operations', () => { // Write via MCP tool const writeResult = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content }, }) expect(writeResult.isError).toBe(false) @@ -316,6 +327,7 @@ describe('server-filesystem — real filesystem operations', () => { // Read back via MCP tool const readResult = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__filesystem__read_file', arguments: { path: filePath }, }) expect(readResult.isError).toBe(false) @@ -327,6 +339,7 @@ describe('server-filesystem — real filesystem operations', () => { await writeFile(join(tempDir, 'listed.txt'), 'listed') const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__filesystem__list_directory', arguments: { path: tempDir }, }) expect(result.isError).toBe(false) @@ -418,6 +431,7 @@ describe('streamable-http — in-process MCP server', () => { it('executes ping() → "pong" over HTTP', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__web__ping', arguments: {}, }) expect(result.isError).toBe(false) @@ -426,6 +440,7 @@ describe('streamable-http — in-process MCP server', () => { it('executes shout({ message }) with args over HTTP', async () => { const result = await ctx.tools.execute({ + signal: testToolSignal, callId: nextCallId(), name: 'mcp__web__shout', arguments: { message: 'quiet' }, }) expect(result.isError).toBe(false) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 8fff832434..557379808c 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -7,6 +7,8 @@ import { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/ import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts' import type { Config } from '@deepseek-ai/dsh-mcp-client' +const testToolSignal = new AbortController().signal + // ---- Mock MCP Client ---- interface MockTool { @@ -122,7 +124,7 @@ describe('syncTools', () => { expect(ctx.tools.get('search')).toBeDefined() expect(ctx.tools.get('mcp__srv__search')).toBeDefined() - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'search', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'search', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: 'native' }) }) @@ -217,7 +219,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } }) expect(result.isError).toBe(false) expect(result.content).toEqual([{ type: 'text', text: 'hello world' }]) @@ -237,7 +239,7 @@ describe('tool execution', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) const publicName = publicToolName('srv', 'admin.reset') - const result = await ctx.tools.execute({ callId: CallId('c1'), name: publicName, arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: publicName, arguments: {} }) expect(result.isError).toBe(false) expect(client.callTool).toHaveBeenCalledWith( @@ -254,7 +256,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__multi', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__multi', arguments: {} }) expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }]) }) @@ -266,7 +268,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' }) }) @@ -278,7 +280,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__fail', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__fail', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' }) @@ -308,7 +310,7 @@ describe('tool execution', () => { client.callTool.mockResolvedValue({ toolResult: { key: 'value' } }) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__legacy', arguments: {} }) expect(result.isError).toBe(false) expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' }) @@ -329,7 +331,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[audio: audio/mp3, content discarded]' }) }) @@ -341,7 +343,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' }) }) @@ -353,7 +355,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' }) }) @@ -365,7 +367,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported content type: video]' }) }) @@ -377,7 +379,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[image: unknown, content discarded]' }) }) @@ -389,7 +391,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[audio: unknown, content discarded]' }) }) @@ -401,7 +403,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' }) }) @@ -413,7 +415,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' }) }) @@ -426,7 +428,7 @@ describe('tool execution edge cases', () => { client.callTool.mockResolvedValue({}) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' }) }) @@ -438,7 +440,7 @@ describe('tool execution edge cases', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toEqual({ type: 'text', text: 'Error: [image: image/png, content discarded]' }) @@ -575,7 +577,7 @@ describe('tool execution — non-object args fallback', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) // Simulate model emitting `null` as tool arguments (malformed). - await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null }) + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null }) expect(client.callTool).toHaveBeenCalledWith( { name: 'coerce', arguments: {} }, @@ -591,7 +593,7 @@ describe('tool execution — non-object args fallback', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce2', arguments: 'bad' }) + await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__coerce2', arguments: 'bad' }) expect(client.callTool).toHaveBeenCalledWith( { name: 'coerce2', arguments: {} }, diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md index 01a2ee6c2d..c2966b1f38 100644 --- a/packages/sdk/telemetry/README.md +++ b/packages/sdk/telemetry/README.md @@ -7,7 +7,7 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li | `SecretRedactor` | Conservative safety backstop: replaces secret-shaped values (secret-like keys, known token shapes, PEM blocks, URL credentials, high-entropy opaque tokens) with a placeholder in both parsed values (`redactValue`) and raw text (`redactText`). Never drops a field or line. | | `ConsentResolver` | Parses (never boots) a project `cordis.yml` and reads the telemetry entry's enabled/disabled state as consent; `DO_NOT_TRACK`/CI env force a hard opt-out. | | `buildTelemetryPayload` | Assembles `{command, durationMs, success, cordisYmlContent, packageJsonContent}`, running the redactor over the full `cordis.yml` and `package.json` text. Never reads `.env`; `package.json` ships only alongside a `cordis.yml`, so a command run in a non-SDK directory never uploads that directory's unrelated manifest. | -| `getOrCreateAnonymousId` | Random UUID persisted in a per-user GLOBAL config file (never in the project, never derived from git). | +| `getOrCreateAnonymousId` | Random UUID persisted in the harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`$DSH_HOME` > `~/.dsh`), scoped to that home rather than the machine, never derived from git. | | `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. | Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`. diff --git a/packages/sdk/telemetry/package.json b/packages/sdk/telemetry/package.json index 7dd1ffd0de..aeb75c4b1f 100644 --- a/packages/sdk/telemetry/package.json +++ b/packages/sdk/telemetry/package.json @@ -32,11 +32,13 @@ "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/sdk/telemetry/src/anonymous-id.ts b/packages/sdk/telemetry/src/anonymous-id.ts index 030fefa19f..dcfe08c158 100644 --- a/packages/sdk/telemetry/src/anonymous-id.ts +++ b/packages/sdk/telemetry/src/anonymous-id.ts @@ -1,63 +1,50 @@ /** - * Per-machine anonymous telemetry id. + * Per-harness-home anonymous telemetry id. * - * The id is a random UUID persisted in a per-user GLOBAL config file — never in - * the project, and never derived from the git remote, repository URL, or any - * other identifying source (a derived id would make "anonymous" a fiction). The - * same id is reused across projects on one machine so telemetry counts machines, - * not repositories. + * The id is a random UUID persisted directly in the harness home resolved by + * {@link resolveDshHome} (`$DSH_HOME` > `~/.dsh`), and never derived from the + * git remote, repository URL, or any other identifying source (a derived id + * would make "anonymous" a fiction). The id is scoped to the harness home, not + * the machine: every command sharing one `$DSH_HOME` reuses the same id, so the + * default `~/.dsh` counts per-OS-user home directories, while a relocated + * `$DSH_HOME` moves the id with the rest of the harness data — the single-root + * convention this package shares, not a telemetry-specific policy. * * @module @deepseek-ai/dsh-telemetry/anonymous-id */ import { randomUUID } from 'node:crypto' import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { homedir } from 'node:os' import { dirname, join } from 'node:path' import type { Branded } from '@deepseek-ai/dsh-brand' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' -/** A machine-scoped anonymous telemetry id (random UUID v4). */ +/** A harness-home-scoped anonymous telemetry id (random UUID v4). */ export type AnonymousId = Branded<'AnonymousId'> -/** Config directory name owned by the DeepSeek Harness across tools. */ -const CONFIG_NAMESPACE = 'deepseek-harness' - -/** Default file, inside the global config dir, storing the anonymous id. */ +/** Default file, inside the harness home, storing the anonymous id. */ export const ANONYMOUS_ID_FILE_NAME = 'telemetry.json' const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i /** Ambient seams for locating and generating the id; every field has a default. */ export interface AnonymousIdOptions { - /** Environment consulted for `DSH_CONFIG_HOME`/`XDG_CONFIG_HOME`/`APPDATA`; defaults to `process.env`. */ + /** Environment consulted for `DSH_HOME`; defaults to `process.env`. */ env?: NodeJS.ProcessEnv - /** Platform string used to pick the Windows path; defaults to `process.platform`. */ - platform?: NodeJS.Platform - /** Home directory resolver; defaults to `os.homedir`. */ - homeDir?: () => string /** UUID generator; defaults to `crypto.randomUUID` (test seam). */ randomUUID?: () => string } /** - * Resolve the per-user global config directory for harness tooling. - * Precedence: `DSH_CONFIG_HOME` (explicit override) > `XDG_CONFIG_HOME` > - * platform default (`%APPDATA%` on Windows, else `~/.config`). - * @param options - environment, platform, and home-directory seams. - * @returns absolute config directory path for the harness namespace. + * Resolve the single-root harness home that stores the anonymous id. + * Delegates to {@link resolveDshHome} so telemetry shares the harness's one + * home-resolution policy (`DSH_HOME` > `~/.dsh`) instead of maintaining a + * second config-directory convention. + * @param options - environment seam. + * @returns absolute harness home path. */ export function globalConfigDir(options: AnonymousIdOptions = {}): string { - const env = options.env ?? process.env - const platform = options.platform ?? process.platform - const home = options.homeDir ?? homedir - if (env.DSH_CONFIG_HOME !== undefined && env.DSH_CONFIG_HOME.length > 0) return env.DSH_CONFIG_HOME - if (env.XDG_CONFIG_HOME !== undefined && env.XDG_CONFIG_HOME.length > 0) { - return join(env.XDG_CONFIG_HOME, CONFIG_NAMESPACE) - } - if (platform === 'win32' && env.APPDATA !== undefined && env.APPDATA.length > 0) { - return join(env.APPDATA, CONFIG_NAMESPACE) - } - return join(home(), '.config', CONFIG_NAMESPACE) + return resolveDshHome(undefined, options.env ?? process.env) } /** Read a valid persisted id from the store, or `undefined` when absent/corrupt. */ @@ -84,11 +71,11 @@ async function readPersistedId(file: string): Promise { } /** - * Return the machine's anonymous id, creating and persisting one on first use. + * Return the harness home's anonymous id, creating and persisting one on first use. * Persistence is best-effort: a write failure still returns a usable id for the * current run so telemetry is never blocked by config-dir permissions. * @param options - config-location and UUID-generation seams. - * @returns the stable per-machine anonymous id. + * @returns the stable per-harness-home anonymous id. */ export async function getOrCreateAnonymousId(options: AnonymousIdOptions = {}): Promise { const file = join(globalConfigDir(options), ANONYMOUS_ID_FILE_NAME) diff --git a/packages/sdk/telemetry/tests/anonymous-id.spec.ts b/packages/sdk/telemetry/tests/anonymous-id.spec.ts index df8bcffea2..7bd5fb1924 100644 --- a/packages/sdk/telemetry/tests/anonymous-id.spec.ts +++ b/packages/sdk/telemetry/tests/anonymous-id.spec.ts @@ -1,6 +1,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { isAbsolute, join, resolve } from 'node:path' +import { defaultDshHome } from '@deepseek-ai/dsh-paths' import { afterEach, describe, expect, it } from 'vitest' import { ANONYMOUS_ID_FILE_NAME, @@ -23,37 +24,26 @@ afterEach(async () => { const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i describe('globalConfigDir', () => { - it('prefers an explicit DSH_CONFIG_HOME override', () => { - expect(globalConfigDir({ env: { DSH_CONFIG_HOME: '/custom/dsh' } })).toBe('/custom/dsh') + it('prefers an explicit DSH_HOME override', () => { + expect(globalConfigDir({ env: { DSH_HOME: '/custom/dsh' } })).toBe('/custom/dsh') }) - it('falls back to XDG_CONFIG_HOME under the harness namespace', () => { - expect(globalConfigDir({ env: { XDG_CONFIG_HOME: '/xdg' } })).toBe(join('/xdg', 'deepseek-harness')) - }) - - it('uses %APPDATA% on Windows', () => { - expect(globalConfigDir({ env: { APPDATA: 'C:/Users/x/AppData/Roaming' }, platform: 'win32' })) - .toBe(join('C:/Users/x/AppData/Roaming', 'deepseek-harness')) - }) - - it('falls back to ~/.config on Windows without APPDATA and on posix', () => { - const home = () => '/home/dev' - expect(globalConfigDir({ env: {}, platform: 'win32', homeDir: home })) - .toBe(join('/home/dev', '.config', 'deepseek-harness')) - expect(globalConfigDir({ env: {}, platform: 'linux', homeDir: home })) - .toBe(join('/home/dev', '.config', 'deepseek-harness')) + it('falls back to ~/.dsh when DSH_HOME is unset', () => { + expect(globalConfigDir({ env: {} })).toBe(resolve(defaultDshHome())) }) it('reads process.env by default', () => { // No override supplied: the call must not throw and must return an absolute path. - expect(globalConfigDir()).toContain('deepseek-harness') + // The ambient DSH_HOME is unknown here, so assert only the invariant the + // resolver guarantees rather than a specific location. + expect(isAbsolute(globalConfigDir())).toBe(true) }) }) describe('getOrCreateAnonymousId', () => { it('creates, persists, and returns a UUID on first use', async () => { const dir = await tempDir() - const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) + const id = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } }) expect(id).toMatch(UUID) const stored: unknown = JSON.parse(await readFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'utf8')) expect(stored).toEqual({ anonymousId: id }) @@ -61,15 +51,15 @@ describe('getOrCreateAnonymousId', () => { it('returns the same persisted id on subsequent calls', async () => { const dir = await tempDir() - const first = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) - const second = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) + const first = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } }) + const second = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } }) expect(second).toBe(first) }) it('uses the injected UUID generator', async () => { const dir = await tempDir() const id = await getOrCreateAnonymousId({ - env: { DSH_CONFIG_HOME: dir }, + env: { DSH_HOME: dir }, randomUUID: () => '00000000-0000-4000-8000-000000000000', }) expect(id).toBe('00000000-0000-4000-8000-000000000000') @@ -78,23 +68,23 @@ describe('getOrCreateAnonymousId', () => { it('regenerates when the stored file is corrupt JSON', async () => { const dir = await tempDir() await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'not json', 'utf8') - const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } }) + const id = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } }) expect(id).toMatch(UUID) }) it('regenerates when the stored value is not a valid UUID or object', async () => { const dir = await tempDir() await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), JSON.stringify({ anonymousId: 'nope' }), 'utf8') - expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID) + expect(await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })).toMatch(UUID) await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), '123', 'utf8') - expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID) + expect(await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })).toMatch(UUID) }) it('returns a usable id even when persistence fails', async () => { const dir = await tempDir() // A regular file where a directory is expected makes mkdir/writeFile fail. await writeFile(join(dir, 'blocker'), 'x', 'utf8') - const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: join(dir, 'blocker') } }) + const id = await getOrCreateAnonymousId({ env: { DSH_HOME: join(dir, 'blocker') } }) expect(id).toMatch(UUID) }) }) diff --git a/packages/sdk/telemetry/tsconfig.json b/packages/sdk/telemetry/tsconfig.json index 4667bad772..3d97e58b03 100644 --- a/packages/sdk/telemetry/tsconfig.json +++ b/packages/sdk/telemetry/tsconfig.json @@ -9,6 +9,7 @@ ], "references": [ { "path": "../../util/brand" }, + { "path": "../../util/paths" }, { "path": "../../support/invariants" } ] } diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 69abe82ec8..5abc155103 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -12,7 +12,7 @@ Requires `ctx.skills` (`inject: ['skills']`). | Field | Default | Meaning | |---|---|---| -| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root resolved by [`@deepseek-ai/dsh-home`](../../util/home/README.md); scans `skills` under this directory. | +| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md); scans `skills` under this directory. | | `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. | | `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. | diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index be7c7baae2..1bc655fb39 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -28,8 +28,8 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", - "@deepseek-ai/dsh-home": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -39,8 +39,8 @@ }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index ee109fbb16..90e378f8fe 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -17,7 +17,7 @@ import z from 'schemastery' import type Schema from 'schemastery' import { parse as parseYaml } from 'yaml' import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs' -import { resolveDshHome } from '@deepseek-ai/dsh-home' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { isSkillName, type SkillCandidate, diff --git a/packages/skill/skill-local/tsconfig.json b/packages/skill/skill-local/tsconfig.json index d471e269d0..1cac0aa313 100644 --- a/packages/skill/skill-local/tsconfig.json +++ b/packages/skill/skill-local/tsconfig.json @@ -6,26 +6,12 @@ }, "include": ["src"], "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../util/home" - }, - { - "path": "../../fs/fs" - }, - { - "path": "../skill" - }, - { - "path": "../../support/invariants" - } + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../fs/fs" }, + { "path": "../../util/paths" }, + { "path": "../skill" }, + { "path": "../../support/invariants" } ] } diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index cab7f4aa02..90d891c20e 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -12,6 +12,8 @@ import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' +const testToolSignal = new AbortController().signal + async function tempDir(name: string): Promise { return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) } @@ -219,6 +221,7 @@ describe('dsh-tool-skill', () => { const ctx = await setup(home) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('c1'), name: 'skill', arguments: { name: 'project-skill' }, @@ -271,9 +274,9 @@ describe('dsh-tool-skill', () => { content: 'Provider instructions.', }) - const opaque = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } }) - const url = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'url-skill' } }) - const provider = await ctx.tools.execute({ callId: CallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } }) + const opaque = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } }) + const url = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c3'), name: 'skill', arguments: { name: 'url-skill' } }) + const provider = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } }) if (opaque.content[0]?.type !== 'text' || url.content[0]?.type !== 'text' || provider.content[0]?.type !== 'text') { throw new Error('expected text tool results') @@ -295,7 +298,7 @@ describe('dsh-tool-skill', () => { content: 'Rogue instructions.', }) - const result = await ctx.tools.execute({ callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } }) expect(result.isError).toBe(true) const block = result.content[0] @@ -309,9 +312,9 @@ describe('dsh-tool-skill', () => { await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n') const ctx = await setup(home) - const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } }) - const invalid = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } }) - const disabled = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } }) + const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } }) + const invalid = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } }) + const disabled = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } }) expect(unknown.isError).toBe(true) expect(invalid.isError).toBe(true) diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index d249430ab1..3342150580 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -21,6 +21,8 @@ import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy' +const testToolSignal = new AbortController().signal + /** A stub spill backend recording its saves; `fail` exercises the best-effort fallback. */ class StubStore extends SpillStore { saves: SaveTextSpill[] = [] @@ -51,7 +53,7 @@ function textTool(name: string, text: string) { function exec(name: string, session = 's1'): ToolExecution { // Only agent.session.header.id is read by the policy; a structural stub suffices. const agent = { session: { header: { id: SessionId(session) } } } - return { callId: CallId(`call-${name}`), name, arguments: {}, agent } as unknown as ToolExecution + return { callId: CallId(`call-${name}`), name, arguments: {}, agent, signal: testToolSignal } as unknown as ToolExecution } /** @@ -209,7 +211,7 @@ describe('best-effort fallback', () => { const { ctx, spill } = await setup({ maxInlineBytes: 10 }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) ctx.tools.register(textTool('big', 'x'.repeat(1000))) - const result = await ctx.tools.execute({ callId: CallId('c'), name: 'big', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c'), name: 'big', arguments: {} }) expect(textOf(result.content)).toBe('x'.repeat(1000)) expect(spill?.saves).toHaveLength(0) expect(warn).toHaveBeenCalled() diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index f0e9731c9d..950ee09c4c 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -135,7 +135,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 9d2cce1570..1ad2f63ccf 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: ObjectJsonSch // 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 0c2a239305..6a8fee12d5 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -19,6 +19,8 @@ import { STRUCTURED_OUTPUT_TOOL, } from '../src/structured.ts' +const testToolSignal = new AbortController().signal + type Script = ConstructorParameters[0] async function mountInvariants(ctx: Context): Promise { @@ -225,7 +227,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' } @@ -251,7 +253,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' }]) @@ -650,6 +652,7 @@ describe('in-process structured output', () => { it('a structured_output call from an agent WITHOUT a structured run is UNKNOWN_TOOL (the tool does not exist for it)', async () => { const { ctx, parent } = await setup([]) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: 'x' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 1 }, @@ -662,6 +665,7 @@ describe('in-process structured output', () => { it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => { const { ctx } = await setup([]) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: 'x' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 1 }, @@ -694,6 +698,7 @@ describe('in-process structured output', () => { // …and a LATER invalid call (its own body staged nothing) must not // resurrect c1's discarded value: drive the pipeline directly. const invalid = await ctx.tools.execute({ + signal: testToolSignal, callId: 'c2' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 'not-a-number' }, @@ -702,6 +707,7 @@ describe('in-process structured output', () => { expect(invalid.isError).toBe(true) // A fresh valid call still captures ITS OWN value. const valid = await ctx.tools.execute({ + signal: testToolSignal, callId: 'c3' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 9 }, @@ -732,6 +738,7 @@ describe('in-process structured output', () => { // (invalid args throw before the stage): the discarded value must not ride // its acceptance. const reused = await ctx.tools.execute({ + signal: testToolSignal, callId: 'c1' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 'not-a-number' }, @@ -740,6 +747,7 @@ describe('in-process structured output', () => { expect(reused.isError).toBe(true) // Nothing was ever committed: a fresh valid call is still required. const valid = await ctx.tools.execute({ + signal: testToolSignal, callId: 'c1' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 5 }, @@ -774,6 +782,7 @@ describe('in-process structured output', () => { return undefined as never }, { prepend: true }) const denied = await ctx.tools.execute({ + signal: testToolSignal, callId: 'c1' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 2 }, @@ -784,6 +793,7 @@ describe('in-process structured output', () => { // The discarded value was never promoted: a fresh valid call is required // (and succeeds, proving the runtime is not wedged). const valid = await ctx.tools.execute({ + signal: testToolSignal, callId: 'c1' as never, name: STRUCTURED_OUTPUT_TOOL, arguments: { answer: 5 }, diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 4826931e9d..27b79c9d62 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -27,9 +27,10 @@ async function setup(script: Script) { await mountInvariants(ctx) 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(SessionId('parent'), { provider: 'mock', model: 'mock' }) - return { ctx, parent } + return { ctx, parent, adapter } } function request(parent: Agent, signal = new AbortController().signal) { @@ -133,12 +134,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/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index bcd28c6ab1..a84041d290 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -285,9 +285,6 @@ export function apply(ctx: Context, config: Config): void { if (tasks === undefined) { throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') } - // Reject cancellation before spawning; after return, the task-owned - // signal covers both pending startup and the ready child. - if (exec.signal?.aborted) throw new Error('subagent delegation aborted') // Task preflight finishes before the starter can spawn a child. const id = tasks.start({ kind: 'subagent', @@ -315,7 +312,7 @@ export function apply(ctx: Context, config: Config): void { config, args.prompt, parent, - exec.signal ?? new AbortController().signal, + exec.signal, ) const run: SubagentRun = await ctx.subagents.start(config.provider, request) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 9e4d68c08c..8dda16f6df 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { type Agent } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -15,6 +15,8 @@ import * as tool from '../src/index.ts' import { runOutcome, settleRun } from '../src/index.ts' import { SessionId } from '@deepseek-ai/dsh-session' +const testToolSignal = new AbortController().signal + /** * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real * `ToolRegistry` + `SubagentService`, with a package-local scripted child @@ -45,6 +47,7 @@ function callSubagent(ctx: Context, args: unknown, over: { agent?: Agent | undef // exactOptionalPropertyTypes the key is omitted rather than set to undefined. const agent = 'agent' in over ? over.agent : fakeAgent() return ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name: 'subagent', arguments: args, @@ -100,11 +103,13 @@ describe('dsh-tool-subagent', () => { it('keeps foreground and background calls exclusive', async () => { const ctx = await setup({ provider: 'mock' }) expect(ctx.tools.executionMode({ + signal: testToolSignal, callId: CallId('subagent-foreground'), name: 'subagent', arguments: { description: 'do work', prompt: 'Reply OK' }, })).toEqual({ kind: 'exclusive' }) expect(ctx.tools.executionMode({ + signal: testToolSignal, callId: CallId('subagent-background'), name: 'subagent', arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true }, @@ -139,8 +144,8 @@ describe('dsh-tool-subagent', () => { const names = ctx.tools.schemas().map(s => s.name).filter(n => n.startsWith('subagent')).sort() expect(names).toEqual(['subagent', 'subagent_acp']) - const viaSpawn = await ctx.tools.execute({ callId: CallId('c-spawn'), name: 'subagent', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() }) - const viaAcp = await ctx.tools.execute({ callId: CallId('c-acp'), name: 'subagent_acp', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() }) + const viaSpawn = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c-spawn'), name: 'subagent', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() }) + const viaAcp = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c-acp'), name: 'subagent_acp', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() }) expect(text(viaSpawn)).toBe('from spawn') expect(text(viaAcp)).toBe('from acp') }) @@ -418,7 +423,7 @@ describe('dsh-tool-subagent', () => { expect(result.isError).toBe(true) }) - it('passes an already-aborted signal so provider startup rejects', async () => { + it('skips provider startup for an already-aborted signal', async () => { const sawAborted = vi.fn() const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -438,8 +443,9 @@ describe('dsh-tool-subagent', () => { const controller = new AbortController() controller.abort() // already aborted BEFORE the tool runs const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal }) - expect(sawAborted).toHaveBeenCalledTimes(1) + expect(sawAborted).not.toHaveBeenCalled() expect(result.isError).toBe(true) + expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) }) it('tools depend on the service: no `subagent` tool without ctx.subagents', async () => { @@ -640,6 +646,7 @@ describe('dsh-tool-subagent background mode', () => { expect(text(start)).toBe('started background subagent task subagent-1') const collected = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('collect-1'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, @@ -649,6 +656,7 @@ describe('dsh-tool-subagent background mode', () => { // Final-output reads are idempotent (not consumed). const again = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('collect-2'), name: 'task_output', arguments: { task_id: 'subagent-1' }, @@ -664,14 +672,15 @@ describe('dsh-tool-subagent background mode', () => { expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks') }) - it('refuses to start when the tool signal is already aborted', async () => { + it('skips background startup when the tool signal is already aborted', async () => { const ctx = await backgroundSetup({ provider: 'mock' }) const parent = ownerAgent(ctx, 'sess-parent') const controller = new AbortController() controller.abort() const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent, signal: controller.signal }) expect(result.isError).toBe(true) - expect(text(result)).toContain('subagent delegation aborted') + expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(text(result)).toBe('Error: tool call aborted before dispatch') }) it('settles an asynchronous provider-start failure as a failed task', async () => { @@ -686,6 +695,7 @@ describe('dsh-tool-subagent background mode', () => { tool.apply(ctx, { provider: 'broken-start', toolName: 'subagent_broken' }) const started = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('broken-start'), name: 'subagent_broken', arguments: { description: 'broken', prompt: 'p', run_in_background: true }, @@ -693,6 +703,7 @@ describe('dsh-tool-subagent background mode', () => { }) expect(text(started)).toBe('started background subagent task subagent-1') const output = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('broken-output'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, @@ -715,18 +726,21 @@ describe('dsh-tool-subagent background mode', () => { tool.apply(ctx, { provider: 'pending-start', toolName: 'subagent_pending' }) await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('pending-start'), name: 'subagent_pending', arguments: { description: 'pending', prompt: 'p', run_in_background: true }, agent: parent, }) await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('pending-kill'), name: 'task_kill', arguments: { task_id: 'subagent-1', reason: 'no longer needed' }, agent: parent, }) const output = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('pending-output'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, @@ -764,19 +778,19 @@ describe('dsh-tool-subagent background mode', () => { // Direct apply preserves omitted agentOptions instead of applying schema defaults. tool.apply(ctx, { provider: 'hanging', toolName: 'subagent_hang' }) - const startOne = await ctx.tools.execute({ callId: CallId('h1'), name: 'subagent_hang', arguments: { description: 'one', prompt: 'p', run_in_background: true }, agent: parent }) - const startTwo = await ctx.tools.execute({ callId: CallId('h2'), name: 'subagent_hang', arguments: { description: 'two', prompt: 'p', run_in_background: true }, agent: parent }) + const startOne = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('h1'), name: 'subagent_hang', arguments: { description: 'one', prompt: 'p', run_in_background: true }, agent: parent }) + const startTwo = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('h2'), name: 'subagent_hang', arguments: { description: 'two', prompt: 'p', run_in_background: true }, agent: parent }) expect(text(startOne)).toBe('started background subagent task subagent-1') expect(text(startTwo)).toBe('started background subagent task subagent-2') - const withReason = await ctx.tools.execute({ callId: CallId('k1'), name: 'task_kill', arguments: { task_id: 'subagent-1', reason: 'superseded' }, agent: parent }) - const withoutReason = await ctx.tools.execute({ callId: CallId('k2'), name: 'task_kill', arguments: { task_id: 'subagent-2' }, agent: parent }) + const withReason = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('k1'), name: 'task_kill', arguments: { task_id: 'subagent-1', reason: 'superseded' }, agent: parent }) + const withoutReason = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('k2'), name: 'task_kill', arguments: { task_id: 'subagent-2' }, agent: parent }) expect(text(withReason)).toBe('requested cancellation of task subagent-1') expect(text(withoutReason)).toBe('requested cancellation of task subagent-2') expect(cancels).toEqual(['superseded', 'background subagent task killed']) // The aborted children settle as killed tasks. - const killed = await ctx.tools.execute({ callId: CallId('w1'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, agent: parent }) + const killed = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('w1'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, agent: parent }) expect(text(killed)).toBe('(no new output)\n[status: killed]') }) @@ -870,6 +884,7 @@ describe('background preflight failure (no orphaned child, by construction)', () tool.apply(ctx, { provider: 'probe', toolName: 'subagent_probe' }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('probe-1'), name: 'subagent_probe', arguments: { description: 'd', prompt: 'p', run_in_background: true }, diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index 0a2d89431e..4c09845eae 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -11,6 +11,8 @@ import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek- import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { statusLine } from '@deepseek-ai/dsh-tool-tasks' +const testToolSignal = new AbortController().signal + const agentRegistryDisposers = new WeakMap void>() async function setup(config: ToolTasks.Config = {}) { @@ -62,7 +64,7 @@ function producer(overrides: Partial & TaskHooks> = {}) { let callCounter = 0 function call(ctx: Context, name: string, args: unknown, agent?: Agent) { - return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} }) + return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} }) } function text(result: { content: { type: string; text?: string }[] }): string { diff --git a/packages/timeout/timeout-policy/src/index.ts b/packages/timeout/timeout-policy/src/index.ts index e946c2af61..e9fe3a46af 100644 --- a/packages/timeout/timeout-policy/src/index.ts +++ b/packages/timeout/timeout-policy/src/index.ts @@ -54,8 +54,7 @@ export function apply(ctx: Context): void { using d = deadline(exec.signal, timeoutMs, TOOL_TIMEOUT) // Swap the derived deadline onto exec for dispatch, then restore the // caller's own signal so post-execute listeners never see this plugin's - // (possibly already-aborted) timeout signal. `undefined` is not assignable to - // the optional `signal` under exactOptionalPropertyTypes, so branch on it. + // (possibly already-aborted) timeout signal. const upstream = exec.signal exec.signal = d.signal try { @@ -69,8 +68,7 @@ export function apply(ctx: Context): void { } return result } finally { - if (upstream === undefined) delete exec.signal - else exec.signal = upstream + exec.signal = upstream } }) } diff --git a/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts b/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts index bd06ed6e16..77974cd4a0 100644 --- a/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts +++ b/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts @@ -11,10 +11,12 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineTool, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { defineTool, TOOL_ABORTED, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools' import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy' import { TOOL_TIMEOUT } from '@deepseek-ai/dsh-timeout-policy' +const testToolSignal = new AbortController().signal + /** Mount the registry + the zero-config timeout-policy enforcer. */ async function setup() { const ctx = new Context() @@ -29,8 +31,8 @@ const cooperativeTool = defineTool({ name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100, execute(_args, exec): Promise<{ type: 'text'; text: string }[]> { const done = [{ type: 'text' as const, text: 'stopped cooperatively' }] - if (exec.signal?.aborted) return Promise.resolve(done) - return new Promise((resolve) => { exec.signal?.addEventListener('abort', () => { resolve(done) }) }) + if (exec.signal.aborted) return Promise.resolve(done) + return new Promise((resolve) => { exec.signal.addEventListener('abort', () => { resolve(done) }) }) }, }) @@ -38,8 +40,8 @@ const cooperativeTool = defineTool({ const abortThrowingTool = defineTool({ name: 'aborter', description: 'throws WEB_ABORTED when aborted', parameters: {}, timeoutMs: 100, execute(_args, exec): Promise { - if (exec.signal?.aborted) return Promise.reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) - return new Promise((_resolve, reject) => { exec.signal?.addEventListener('abort', () => { reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) }) }) + if (exec.signal.aborted) return Promise.reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) + return new Promise((_resolve, reject) => { exec.signal.addEventListener('abort', () => { reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) }) }) }, }) @@ -59,7 +61,7 @@ describe('timeout-policy delegation (unconfigured / fast)', () => { const ctx = await setup() ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000, async execute() { return [{ type: 'text' as const, text: 'ok' }] } })) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} }) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'fast', arguments: {} }) expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false }) }) @@ -86,16 +88,6 @@ describe('timeout-policy signal restoration', () => { await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {}, signal: upstream }) expect(postSignal).toBe(upstream) }) - - it('deletes exec.signal again when the caller passed none', async () => { - const ctx = await setup() - ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000, - async execute() { return [{ type: 'text' as const, text: 'ok' }] } })) - let hadSignal: boolean | undefined - ctx.on('tools/post-execute', async (exec, _result, next): Promise => { hadSignal = 'signal' in exec && exec.signal !== undefined; return next() }) - await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} }) - expect(hadSignal).toBe(false) - }) }) describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { @@ -105,7 +97,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { it('replaces a cooperative tool result with TOOL_TIMEOUT when its own deadline fires', async () => { const ctx = await setup() ctx.tools.register(cooperativeTool) - const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {} }) + const pending = ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'slow', arguments: {} }) await vi.advanceTimersByTimeAsync(150) const result = await pending expect(result).toEqual({ @@ -118,7 +110,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { it('replaces a provider-owned abort ERROR result with TOOL_TIMEOUT when the signal was ours', async () => { const ctx = await setup() ctx.tools.register(abortThrowingTool) - const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'aborter', arguments: {} }) + const pending = ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'aborter', arguments: {} }) await vi.advanceTimersByTimeAsync(150) const result = await pending expect(result.isError).toBe(true) @@ -126,16 +118,62 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { expect(result.content[0]).toMatchObject({ text: 'Error: tool call timed out after 100ms' }) }) - it('does NOT replace when the caller aborts first (upstream cancel, not our timeout)', async () => { + it('preserves registry ABORTED when the caller aborts first (upstream cancel, not our timeout)', async () => { const ctx = await setup() - ctx.tools.register(cooperativeTool) + const entered = Promise.withResolvers() + ctx.tools.register(defineTool({ + name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100, + execute(_args, exec) { + entered.resolve(undefined) + const done = [{ type: 'text' as const, text: 'stopped cooperatively' }] + if (exec.signal.aborted) return Promise.resolve(done) + return new Promise((resolve) => { + exec.signal.addEventListener('abort', () => { resolve(done) }, { once: true }) + }) + }, + })) const upstream = new AbortController() const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {}, signal: upstream.signal }) + await entered.promise upstream.abort('user cancelled') await vi.advanceTimersByTimeAsync(0) const result = await pending - expect(result.isError).toBe(false) - expect(result.content[0]).toMatchObject({ text: 'stopped cooperatively' }) + expect(result.isError).toBe(true) + expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED }) + expect(result.content[0]).toMatchObject({ text: 'Error: tool call aborted' }) + }) + + it('preserves TOOL_TIMEOUT when the deadline wins before a later caller abort', async () => { + const ctx = await setup() + const sawAbort = Promise.withResolvers() + const releaseCleanup = Promise.withResolvers() + ctx.tools.register(defineTool({ + name: 'slow-cleanup', description: 'settles after abort cleanup', parameters: {}, timeoutMs: 100, + async execute(_args, exec) { + if (!exec.signal.aborted) { + await new Promise((resolve) => { + exec.signal.addEventListener('abort', () => { resolve(undefined) }, { once: true }) + }) + } + sawAbort.resolve(undefined) + await releaseCleanup.promise + return [{ type: 'text' as const, text: 'cleanup complete' }] + }, + })) + const upstream = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('timeout-first'), name: 'slow-cleanup', arguments: {}, signal: upstream.signal, + }) + + await vi.advanceTimersByTimeAsync(100) + await sawAbort.promise + upstream.abort('too late to replace timeout') + releaseCleanup.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + isError: true, + error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, + }) }) }) @@ -183,7 +221,7 @@ describe('dsh-timeout-policy real-load-path guard', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters[0] const fiber = await ctx.plugin(unwrapped) - const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput) expect(result.isError).toBe(false) await fiber.dispose() }) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 2059bf13e8..47cfc27390 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -10,6 +10,8 @@ import { type Agent } from '@deepseek-ai/dsh-agent' import * as tool from '../src/index.ts' +const testToolSignal = new AbortController().signal + /** * Drives the REAL plugin body: mounts `dsh-tool-todo` on a real `ToolRegistry` * and invokes the registered `todo_write` tool through `ctx.tools.execute`, @@ -36,6 +38,7 @@ let callCounter = 0 function callTodo(ctx: Context, args: unknown, over: { agent?: Agent | undefined } = {}) { const agent = 'agent' in over ? over.agent : agentWithSession() return ctx.tools.execute({ + signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name: 'todo_write', arguments: args, diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 47a479a56b..17e51908bf 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -662,7 +662,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // Prompt-submit is inside the new turn but before prompt assembly. Promptless // injection turns leave the switch pending because they execute no request. - ctx.on('agent/prompt-submit', (agent, _content, _source, next) => { + ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) => { const rec = ownedRecord(agent) if (rec !== undefined) flushPendingSwitches(rec) return next() @@ -926,7 +926,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 agent.cancel({ kind: 'user' }): 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, while a prompt accepted afterward @@ -941,7 +941,7 @@ export function apply(ctx: Context, config: AcpConfig): void { if (rec.commandAbort !== undefined) { rec.commandAbort.abort(new Error('session/cancel')) } else { - rec.agent.cancel('session/cancel') + rec.agent.cancel({ kind: 'user' }) settlePrompt(rec, 'cancelled') } return Promise.resolve() 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/config-options.spec.ts b/packages/ui/acp/tests/config-options.spec.ts index de5fd93941..c6d385185c 100644 --- a/packages/ui/acp/tests/config-options.spec.ts +++ b/packages/ui/acp/tests/config-options.spec.ts @@ -186,7 +186,7 @@ describe('acp bridge — session config options', () => { const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const agent = h.ctx.agents.list()[0] if (agent === undefined) throw new Error('expected an agent') - agent.ctx.on('agent/request', async (_agent, _turn, _step, callConfig, _next) => ({ + agent.ctx.on('agent/request', async (_agent, _turn, _step, callConfig, _signal, _next) => ({ ...callConfig, provider: 'mock', model: 'mock', diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index e3e8d679ea..4ca6775d06 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -325,6 +325,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(SessionId(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/packages/ui/tool-ask-user/src/index.ts b/packages/ui/tool-ask-user/src/index.ts index 47cb6e8d22..b3b0cfc90f 100644 --- a/packages/ui/tool-ask-user/src/index.ts +++ b/packages/ui/tool-ask-user/src/index.ts @@ -65,7 +65,7 @@ export function apply(ctx: Context): void { ...question.multi_select !== undefined ? { multiSelect: question.multi_select } : {}, })), ...exec.agent !== undefined ? { agent: exec.agent } : {}, - ...exec.signal !== undefined ? { signal: exec.signal } : {}, + signal: exec.signal, }) return [{ type: 'text', text: JSON.stringify(result) }] }, diff --git a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts index ceff7df388..0f03322dcd 100644 --- a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts @@ -7,6 +7,8 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' +const testToolSignal = new AbortController().signal + interface OptionSchemaShape { properties: { questions: { @@ -75,6 +77,7 @@ describe('ask_user_question tool', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('ask-1'), name: 'ask_user_question', arguments: { @@ -110,6 +113,7 @@ describe('ask_user_question tool', () => { }) await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('ask-recommended'), name: 'ask_user_question', arguments: { @@ -144,6 +148,7 @@ describe('ask_user_question tool', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('ask-multi'), name: 'ask_user_question', arguments: { @@ -198,6 +203,7 @@ describe('ask_user_question tool', () => { const agent = { id: 'main' } as unknown as Agent const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('ask-3'), name: 'ask_user_question', arguments: { questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }] }, @@ -212,6 +218,7 @@ describe('ask_user_question tool', () => { const ctx = await setup() const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('ask-no-provider'), name: 'ask_user_question', arguments: { questions: [{ id: 'continue', question: 'Continue?' }] }, @@ -227,6 +234,7 @@ describe('ask_user_question tool', () => { const ctx = await setup() const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('ask-empty'), name: 'ask_user_question', arguments: { questions: [] }, diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 137ef7a264..0e0442c882 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1337,7 +1337,7 @@ export function createTuiChat( : event.data.reason.message if (!liveErrors.delete(key)) appendNotice(message, 'error') } else if (event.data.reason.kind === 'aborted') { - appendNotice(event.data.reason.reason ?? 'Turn cancelled.', 'warning') + appendNotice('Turn cancelled.', 'warning') } else if (event.data.reason.kind === 'max-tokens') { appendNotice('The model reached its output-token limit.', 'warning') } else if (event.data.reason.kind === 'rejected') { @@ -1484,7 +1484,7 @@ export function createTuiChat( const requestExit = (): void => { if (agent.status === 'running') { - agent.cancel('terminal exit requested') + agent.cancel({ kind: 'user' }) appendNotice('Cancelling the active turn before exit…', 'warning') void agent.whenIdle().then(() => shutdown(true)) return @@ -1567,7 +1567,7 @@ export function createTuiChat( description: 'Cancel the active turn', handler: () => { if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' } - agent.cancel('cancelled from terminal') + agent.cancel({ kind: 'user' }) return { kind: 'success', text: 'Cancellation requested.' } }, }) @@ -1647,12 +1647,12 @@ export function createTuiChat( return { consume: true } } if (matchesKey(data, Key.escape) && agent.status === 'running') { - agent.cancel('cancelled from terminal') + agent.cancel({ kind: 'user' }) return { consume: true } } if (matchesKey(data, Key.ctrl('c'))) { if (agent.status === 'running') { - agent.cancel('cancelled from terminal') + agent.cancel({ kind: 'user' }) } else if (editor.getText() !== '') { editor.setText('') } else { diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 2402e85eda..404b538fd3 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -1,6 +1,11 @@ import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { type Agent, type AgentOptions, type AgentStatus } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { + type Agent, + type AgentCancelCause, + type AgentOptions, + type AgentStatus, +} from '@deepseek-ai/dsh-agent' import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' @@ -13,7 +18,7 @@ interface FakeAgent extends Agent { status: AgentStatus sent: ContentBlock[][] steered: ContentBlock[][] - cancelled: string[] + cancelled: AgentCancelCause[] } export interface TuiHarnessOptions { @@ -110,7 +115,7 @@ export async function createTuiTestHarness -13| " cancelled during retry delay " - style 1-28 fg=yellow +13| " Turn cancelled. " + style 1-15 fg=yellow 14| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim 15| " " diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index eb901a3728..54d703d4a8 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -279,7 +279,7 @@ describe('TUI terminal-state snapshots', () => { }) harness.session.append('turn/end', { turn: 1, - reason: { kind: 'aborted', reason: 'cancelled during retry delay' }, + reason: { kind: 'aborted' }, }) }) await checkpoint('retry-cancelled', harness.terminal, { includeScrollback: true }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 76aab4f045..163bf51191 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -495,7 +495,7 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('\x0f') result.terminal.send('/cancel') result.terminal.send('\r') - expect(result.agent.cancelled).toContain('cancelled from terminal') + expect(result.agent.cancelled).toContainEqual({ kind: 'user' }) result.agent.status = 'idle' for (const command of ['/help', '/reasoning', '/tools', '/redraw']) { @@ -600,7 +600,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' }) const seed: LlmCallConfig = { provider: 'alpha', model: 'a1', temperature: 0.2 } const request = await agentEvents(result.ctx, result.agent).waterfall( - 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + 'agent/request', 1, 0, seed, new AbortController().signal, () => Promise.resolve(seed), ) expect(request).toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) await dispose(result) @@ -652,7 +652,7 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(assembly.variables).toEqual({}) const seed: LlmCallConfig = { provider: 'fallback', model: 'fallback' } await expect(agentEvents(empty.ctx, empty.agent).waterfall( - 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + 'agent/request', 1, 0, seed, new AbortController().signal, () => Promise.resolve(seed), )).resolves.toBe(seed) await dispose(empty) @@ -828,7 +828,7 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('/exit') result.terminal.send('\r') await tick() - expect(result.agent.cancelled).toContain('terminal exit requested') + expect(result.agent.cancelled).toContainEqual({ kind: 'user' }) expect(result.exit).toHaveBeenCalledWith(0) const events = await setup() @@ -845,7 +845,7 @@ describe('pi-tui chat lifecycle and transcript', () => { events.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) events.session.append('turn/end', { turn: 2, reason: { kind: 'error', step: 1, message: 'durable failure' } }) events.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }) - events.session.append('turn/end', { turn: 3, reason: { kind: 'aborted', reason: 'stopped' } }) + events.session.append('turn/end', { turn: 3, reason: { kind: 'aborted' } }) events.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } }) events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } }) events.session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -861,8 +861,8 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(events.terminal.output).toContain('live failure') expect(events.terminal.output).toContain('durable failure') + expect(events.terminal.output).toContain('Turn cancelled') expect(events.terminal.output).toContain('structured provider failure') - expect(events.terminal.output).toContain('stopped') expect(events.terminal.output).toContain('output-token limit') expect(events.terminal.output).toContain('Turn rejected') expect(events.terminal.output).toContain('previous process ended') diff --git a/packages/util/README.md b/packages/util/README.md index 954026c99c..5a9f626de5 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -5,14 +5,13 @@ Zero-dependency primitives shared across the other groups. A package lands here | Package | Role | |---|---| | `brand/` | The type-only `Branded` nominal-typing primitive (no runtime code, no harness deps) | -| `home/` | Canonical `DSH_HOME` resolution from explicit config, environment, or `~/.dsh` (no harness deps) | -| `paths/` | Shared filesystem path constants and helpers for harness user data | +| `paths/` | Canonical single-root `DSH_HOME` resolution plus shared filesystem path constants and helpers for harness user data (no harness deps) | | `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability | | `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool | `dsh-brand` is the canonical case: it owns ONLY the `Branded` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`. -`dsh-home` gives every package the same configurable Harness home without assigning that cross-cutting fact to bash, skills, or a composition bundle. It resolves an explicit value before `$DSH_HOME`, falls back to `~/.dsh`, and returns an absolute path without caching, creating, or mutating anything. +`dsh-paths` gives every package the same configurable Harness home without assigning that cross-cutting fact to bash, skills, telemetry, or a composition bundle. It resolves an explicit value before `$DSH_HOME`, falls back to `~/.dsh`, and returns an absolute path without caching, creating, or mutating anything. The harness keeps all user data under one root. `dsh-timeout` follows the same shape for the timeout family: `dsh-bash` and `dsh-web-fetch-local` each fuse a caller's cancellation with a deadline and later classify "timed out" vs "cancelled" by depending on `dsh-timeout` alone. It deliberately owns only the timing/classification half — the *termination* (SIGKILL a process group, tear down a fetch socket) stays in each capability, because no shared layer can own every capability's kill (see [the timeout-library Agent Note](../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). diff --git a/packages/util/home/README.md b/packages/util/home/README.md deleted file mode 100644 index 876d05b3f1..0000000000 --- a/packages/util/home/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# @deepseek-ai/dsh-home - -`@deepseek-ai/dsh-home` is the single owner of DeepSeek Harness home-directory resolution. `resolveDshHome(configured?)` returns an absolute path using this precedence: - -1. The explicit `configured` path. -2. The `DSH_HOME` environment variable. -3. The `.dsh` directory under the current user's home directory. - -The resolver reads its inputs at call time. It does not cache a result, create the directory, or mutate `process.env`; consumers keep ownership of their own configuration fields and pass the configured value when resolving the shared home. - -## Model Experience - -Indirectly, through `dsh-tool-bash`, which exposes the resolved path to model bash as `DSH_HOME` without adding a prompt section. - -#### KV Cache effect - -No direct invalidation; the named consumer owns any request-prefix changes. - -## Known Limitations and Deferred Work - -- **Resolution only** — the resolver makes a path absolute but does not create it, check access, or canonicalize symlinks; each consumer owns those filesystem decisions. diff --git a/packages/util/home/package.json b/packages/util/home/package.json deleted file mode 100644 index 07795b1a07..0000000000 --- a/packages/util/home/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-home", - "description": "Canonical DeepSeek Harness home-directory resolver", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.6" - }, - "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.6" - } -} diff --git a/packages/util/home/src/index.ts b/packages/util/home/src/index.ts deleted file mode 100644 index 4e3d56b54b..0000000000 --- a/packages/util/home/src/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Canonical DeepSeek Harness home-directory resolution. - * - * @module @deepseek-ai/dsh-home - */ - -import { homedir } from 'node:os' -import { join, resolve } from 'node:path' - -const DEFAULT_DSH_HOME_DIRNAME = '.dsh' - -/** Environment variable that overrides the default Harness home directory. */ -export const DSH_HOME_ENV = 'DSH_HOME' as const - -/** - * Resolve the DeepSeek Harness home directory without caching or mutating the environment. - * - * @param configured - Optional configured path, which takes precedence over the environment. - * @returns The absolute configured path, `$DSH_HOME`, or `~/.dsh`, in that order. - */ -export function resolveDshHome(configured?: string): string { - return resolve(configured ?? process.env[DSH_HOME_ENV] ?? join(homedir(), DEFAULT_DSH_HOME_DIRNAME)) -} diff --git a/packages/util/home/src/invariant.ts b/packages/util/home/src/invariant.ts deleted file mode 100644 index dbd8e38f51..0000000000 --- a/packages/util/home/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-home`. - * @module @deepseek-ai/dsh-home/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-home' - -/** Cordis companion plugin name. */ -export const name = 'home-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value - * algebra is enforced by unit tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/util/home/tests/home.spec.ts b/packages/util/home/tests/home.spec.ts deleted file mode 100644 index 3ebde50bee..0000000000 --- a/packages/util/home/tests/home.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { homedir } from 'node:os' -import { join, resolve } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home' - -afterEach(() => vi.unstubAllEnvs()) - -describe('resolveDshHome', () => { - it('prefers an explicit configured path and resolves it absolutely', () => { - vi.stubEnv(DSH_HOME_ENV, './environment-home') - - expect(resolveDshHome('./configured-home')).toBe(resolve('./configured-home')) - }) - - it('uses DSH_HOME when no configured path is supplied', () => { - vi.stubEnv(DSH_HOME_ENV, './environment-home') - - expect(resolveDshHome()).toBe(resolve('./environment-home')) - }) - - it('defaults to the .dsh directory under the user home', () => { - vi.stubEnv(DSH_HOME_ENV, undefined) - - expect(resolveDshHome()).toBe(join(homedir(), '.dsh')) - }) -}) diff --git a/packages/util/home/tsconfig.json b/packages/util/home/tsconfig.json deleted file mode 100644 index 67d4461281..0000000000 --- a/packages/util/home/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": ["src"], - "references": [ - { - "path": "../../support/invariants" - } - ] -} diff --git a/packages/util/paths/README.md b/packages/util/paths/README.md index 2668e679b6..3691417289 100644 --- a/packages/util/paths/README.md +++ b/packages/util/paths/README.md @@ -4,6 +4,10 @@ Shared filesystem path helpers for DeepSeek Harness user data. ## DSH home +`resolveDshHome()` resolves the single-root DeepSeek Harness home. Precedence, highest first: an explicit configured path, `$DSH_HOME`, then `~/.dsh`. The harness keeps all user data under one root. + +`dshHomeDisplay()` names an active root symbolically for user-facing paths: `~/.dsh` for the default home, `$DSH_HOME` for any configured home. It never leaks an absolute machine path. + `DSH_HOME_DIR_NAME` owns the default user-data directory name: `.dsh`. `defaultDshHome()` returns the default DeepSeek Harness home by joining the operating-system home directory with `.dsh`, using Node's platform path rules. diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts index 89e188cedd..c54a5e0a5f 100644 --- a/packages/util/paths/src/index.ts +++ b/packages/util/paths/src/index.ts @@ -36,12 +36,30 @@ export function expandHomePath(path: string): string { } /** - * Resolve an explicitly configured, environment-selected, or default DSH home. + * Resolve the single-root DeepSeek Harness home. + * + * Precedence, highest first: an explicit configured path, `$DSH_HOME`, then + * `~/.dsh`. The harness keeps all user data under one root. An empty or + * whitespace-only `$DSH_HOME` is treated as unset, so a blank override never + * resolves the home to the current working directory. * @param configured - explicit harness-home override, which has highest precedence. * @param env - environment mapping used to read `DSH_HOME`. * @returns the normalized absolute harness home path. */ export function resolveDshHome(configured?: string, env: Record = process.env): string { - const selected = configured ?? env[DSH_HOME_ENV] ?? defaultDshHome() + const fromEnv = env[DSH_HOME_ENV] + const selected = configured ?? (fromEnv !== undefined && fromEnv.trim().length > 0 ? fromEnv : defaultDshHome()) return resolve(expandHomePath(selected)) } + +/** + * Describe a resolved harness home symbolically for user-facing display. + * + * It never returns an absolute machine path: the default home is labelled + * `~/.dsh`, and any configured home is labelled `$DSH_HOME`. + * @param resolvedHome - the absolute path returned by {@link resolveDshHome}. + * @returns `~/.dsh` for the default home, otherwise `$DSH_HOME`. + */ +export function dshHomeDisplay(resolvedHome: string): string { + return resolvedHome === resolve(defaultDshHome()) ? DEFAULT_DSH_HOME_DISPLAY : `$${DSH_HOME_ENV}` +} diff --git a/packages/util/paths/tests/paths.spec.ts b/packages/util/paths/tests/paths.spec.ts index 97e91a556e..e7b3804b23 100644 --- a/packages/util/paths/tests/paths.spec.ts +++ b/packages/util/paths/tests/paths.spec.ts @@ -1,10 +1,11 @@ import { homedir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { DEFAULT_DSH_HOME_DISPLAY, DSH_HOME_DIR_NAME, defaultDshHome, + dshHomeDisplay, expandHomePath, resolveDshHome, } from '@deepseek-ai/dsh-paths' @@ -24,11 +25,21 @@ describe('dsh path helpers', () => { expect(expandHomePath('~other/.dsh')).toBe('~other/.dsh') }) - it('resolves explicit DSH home before environment and default locations', () => { + it('resolves explicit path before DSH_HOME and the default', () => { const envHome = join(homedir(), 'env-dsh') - expect(resolveDshHome(undefined, { DSH_HOME: '~/env-dsh' })).toBe(envHome) expect(resolveDshHome('/tmp/explicit-dsh', { DSH_HOME: '~/env-dsh' })).toBe('/tmp/explicit-dsh') + expect(resolveDshHome(undefined, { DSH_HOME: '~/env-dsh' })).toBe(envHome) expect(resolveDshHome(undefined, {})).toBe(defaultDshHome()) }) + + it('treats an empty or whitespace-only DSH_HOME as unset', () => { + expect(resolveDshHome(undefined, { DSH_HOME: '' })).toBe(defaultDshHome()) + expect(resolveDshHome(undefined, { DSH_HOME: ' ' })).toBe(defaultDshHome()) + }) + + it('labels a resolved home by whether it is the default root', () => { + expect(dshHomeDisplay(resolve(defaultDshHome()))).toBe('~/.dsh') + expect(dshHomeDisplay('/some/other/root')).toBe('$DSH_HOME') + }) }) diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts index e99f524b3c..abc5256b9f 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -19,6 +19,8 @@ import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa' import * as ToolWeb from '@deepseek-ai/dsh-tool-web' import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy' +const testToolSignal = new AbortController().signal + type Handler = (req: IncomingMessage, res: ServerResponse) => void let server: Server @@ -56,7 +58,7 @@ afterEach(async () => { let counter = 0 type ToolResult = { isError: boolean; content: { type: string; text?: string }[]; error?: { code: string } } function call(name: string, args: unknown): Promise { - return ctx.tools.execute({ callId: CallId(`call-${++counter}`), name, arguments: args }) + return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++counter}`), name, arguments: args }) } describe('web_fetch integration over the real backend', () => { @@ -147,7 +149,7 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc }) it('returns a structured TOOL_TIMEOUT (not the provider WEB_FETCH_TIMEOUT) when the tool-call budget wins', async () => { - const out = await tctx.tools.execute({ callId: CallId('slow-1'), name: 'web_fetch', arguments: { url: slowBase } }) + const out = await tctx.tools.execute({ signal: testToolSignal, callId: CallId('slow-1'), name: 'web_fetch', arguments: { url: slowBase } }) expect(out.isError).toBe(true) // The outer tool-call deadline won: TOOL_TIMEOUT, owned by dsh-timeout-policy, // NOT the provider's own WEB_FETCH_TIMEOUT (its 30s backstop never fired). diff --git a/packages/web/tool-web/tests/spill.spec.ts b/packages/web/tool-web/tests/spill.spec.ts index a2b1f9889d..33279fc9e9 100644 --- a/packages/web/tool-web/tests/spill.spec.ts +++ b/packages/web/tool-web/tests/spill.spec.ts @@ -19,6 +19,8 @@ import { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import type { ToolExecution } from '@deepseek-ai/dsh-tools' + +const testToolSignal = new AbortController().signal import WebService from '@deepseek-ai/dsh-web' import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local' import LocalSpillStore from '@deepseek-ai/dsh-spill-local' @@ -63,7 +65,7 @@ afterEach(async () => { /** A web_fetch call carrying a session owner (so the policy can scope the spill). */ function fetchCall(): Promise<{ isError: boolean; content: { type: string; text?: string }[] }> { const agent = { session: { header: { id: SessionId('web-sess') } } } - const exec = { callId: CallId('call-1'), name: 'web_fetch', arguments: { url: base }, agent } as unknown as ToolExecution + const exec = { callId: CallId('call-1'), name: 'web_fetch', arguments: { url: base }, agent, signal: testToolSignal } as unknown as ToolExecution return ctx.tools.execute(exec) } diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 088ace395e..c7d8dd572a 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -18,6 +18,8 @@ import { WEB_SEARCH_MAX_RESULTS, } from '@deepseek-ai/dsh-tool-web' +const testToolSignal = new AbortController().signal + const available = true function searchProvider(result: WebSearchResult, isAvailable = available): WebSearchProvider { @@ -39,7 +41,7 @@ async function mountTools(opts: { if (opts.fetchProvider) ctx.web.registerFetchProvider(opts.fetchProvider) const fiber = await ctx.plugin(ToolWeb, opts.config ?? {}) let counter = 0 - const call = (name: string, args: unknown) => ctx.tools.execute({ callId: CallId(`call-${++counter}`), name, arguments: args }) as never + const call = (name: string, args: unknown) => ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++counter}`), name, arguments: args }) as never return { ctx, fiber, call } } @@ -166,9 +168,9 @@ describe('tool-web registration', () => { const names = ctx.tools.schemas().map(s => s.name) expect(names).toContain('web_search') expect(names).toContain('web_fetch') - expect(ctx.tools.executionMode({ callId: CallId('search-safe'), name: 'web_search', arguments: { query: 'q' } })) + expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('search-safe'), name: 'web_search', arguments: { query: 'q' } })) .toEqual({ kind: 'parallel' }) - expect(ctx.tools.executionMode({ callId: CallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } })) + expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } })) .toEqual({ kind: 'parallel' }) await fiber.dispose() expect(ctx.tools.schemas().map(s => s.name)).not.toContain('web_search') @@ -274,7 +276,7 @@ describe('tool-web execution through the real registry', () => { await fiber.dispose() }) - it('executes web_fetch with no caller signal (forwards undefined to the seam)', async () => { + it('forwards the required caller signal to web_fetch', async () => { const seen: { signal?: AbortSignal | undefined; passedSignal?: boolean } = {} const fetchProvider = { id: 'stub-fetch', @@ -286,11 +288,10 @@ describe('tool-web execution through the real registry', () => { }, } const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider }) - // No signal on the execution: the tool passes `undefined`. - const out = await ctx.tools.execute({ callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } }) + const out = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } }) expect(out.isError).toBe(false) - expect(seen.passedSignal).toBe(false) - expect(seen.signal).toBeUndefined() + expect(seen.passedSignal).toBe(true) + expect(seen.signal).toBe(testToolSignal) await fiber.dispose() }) diff --git a/packages/workflow/tool-ralph/src/index.ts b/packages/workflow/tool-ralph/src/index.ts index 0f23ae062a..3bfd438cf7 100644 --- a/packages/workflow/tool-ralph/src/index.ts +++ b/packages/workflow/tool-ralph/src/index.ts @@ -432,11 +432,11 @@ export function apply(ctx: Context, config: Config): void { subagentProvider: resolved.subagentProvider, maxTotalAgents: maxRounds, parent, - ...exec.signal === undefined ? {} : { signal: exec.signal }, + signal: exec.signal, }) const onAbort = (): void => { run.cancel('parent step aborted') } - exec.signal?.addEventListener('abort', onAbort, { once: true }) - if (exec.signal?.aborted) run.cancel('parent step aborted') + exec.signal.addEventListener('abort', onAbort, { once: true }) + if (exec.signal.aborted) run.cancel('parent step aborted') try { const settled = await run.result @@ -446,7 +446,7 @@ export function apply(ctx: Context, config: Config): void { if (value.status === 'round-failed') throw new Error(renderRoundFailure(value, resolved.maxResultChars)) return [{ type: 'text', text: renderResult(value, resolved.maxResultChars) }] } finally { - exec.signal?.removeEventListener('abort', onAbort) + exec.signal.removeEventListener('abort', onAbort) await run.dispose() } }, diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index a5b44712e8..dcc3b947fb 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -13,6 +13,7 @@ import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from ' import * as toolRalph from '../src/index.ts' type MockScript = ConstructorParameters[0] +const testToolSignal = new AbortController().signal /** Mount the shipped Ralph execution stack around one keyless model script. */ async function mountRalph(script: MockScript, config: toolRalph.Config) { @@ -81,6 +82,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { children.push(agent!) }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('ralph-integration'), name: 'ralph', arguments: { objective: 'Complete both migration slices.', maxRounds: 2 }, @@ -132,6 +134,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { }) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('ralph-child-failure'), name: 'ralph', arguments: { objective: 'Complete both migration slices.', maxRounds: 2 }, @@ -220,6 +223,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { ], config) const result = await ctx.tools.execute({ + signal: testToolSignal, callId: CallId('ralph-script-enforcement'), name: 'ralph', arguments: { objective: 'Complete the scoped work.', maxRounds: config.maxRounds }, diff --git a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts index 119da3def3..974c4aaf88 100644 --- a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts +++ b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts @@ -7,23 +7,27 @@ import { SessionId } from '@deepseek-ai/dsh-session' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow' import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' import * as toolRalph from '../src/index.ts' +const testToolSignal = new AbortController().signal + class StubEngine extends WorkflowService { requests: WorkflowStartRequest[] = [] cancels: string[] = [] disposed = 0 settle!: (result: WorkflowResult) => void startError: Error | undefined + onStart: (() => void) | undefined start(request: WorkflowStartRequest): WorkflowRun { if (this.startError !== undefined) throw this.startError this.requests.push(request) const result = new Promise((resolve) => { this.settle = resolve }) + this.onStart?.() return { id: WorkflowRunId(`ralph-${this.requests.length}`), meta: request.meta, @@ -94,11 +98,11 @@ function execute( extra?: { agent?: Agent; signal?: AbortSignal }, ): Promise { return ctx.tools.execute({ + signal: extra?.signal ?? testToolSignal, callId: CallId('ralph-call'), name: 'ralph', arguments: args, ...extra?.agent === undefined ? {} : { agent: extra.agent }, - ...extra?.signal === undefined ? {} : { signal: extra.signal }, }) } @@ -256,7 +260,7 @@ describe('dsh-tool-ralph', () => { expect(engine.disposed).toBe(4) }) - it('bridges mid-flight and already-aborted parent signals to cancellation', async () => { + it('bridges mid-flight cancellation and skips dispatch for an already-aborted parent signal', async () => { const { ctx, engine, parent } = await setup() const controller = new AbortController() const pending = execute(ctx, { objective: 'Work.' }, { agent: parent, signal: controller.signal }) @@ -266,9 +270,24 @@ describe('dsh-tool-ralph', () => { const already = new AbortController() already.abort() - expect((await execute(ctx, { objective: 'Work.' }, { agent: parent, signal: already.signal })).isError).toBe(true) - expect(engine.cancels).toEqual(['parent step aborted', 'parent step aborted']) - expect(engine.disposed).toBe(2) + const skipped = await execute(ctx, { objective: 'Work.' }, { agent: parent, signal: already.signal }) + expect(skipped.error?.code).toBe(TOOL_ABORTED_BEFORE_DISPATCH) + expect(engine.requests).toHaveLength(1) + expect(engine.cancels).toEqual(['parent step aborted']) + expect(engine.disposed).toBe(1) + }) + + it('bridges cancellation that arrives while the workflow is starting', async () => { + const { ctx, engine, parent } = await setup() + const controller = new AbortController() + engine.onStart = () => { controller.abort() } + + const result = await execute(ctx, { objective: 'Work.' }, { agent: parent, signal: controller.signal }) + + expect(result.isError).toBe(true) + expect(engine.requests[0]?.signal).toBe(controller.signal) + expect(engine.cancels).toEqual(['parent step aborted']) + expect(engine.disposed).toBe(1) }) it('rejects absent authority, empty objectives, bad round caps, and schema-invalid calls before start', async () => { diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 0fcdc77786..653d274135 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -177,17 +177,14 @@ export function apply(ctx: Context, config: Config): void { meta: args.meta, ...args.args !== undefined ? { args: args.args } : {}, parent, - ...exec.signal ? { signal: exec.signal } : {}, + signal: exec.signal, }) // Bridge the tool's abort signal to the run: if the parent step is aborted while the // script is in flight, cancel the whole run. The signal also enters the engine directly, but // this local bridge preserves the tool contract even if an implementation ignores it. const onAbort = (): void => { run.cancel('parent step aborted') } - exec.signal?.addEventListener('abort', onAbort, { once: true }) - // `addEventListener` does NOT fire for a signal already aborted before - // this line — cancel explicitly in that case. - if (exec.signal?.aborted) run.cancel('parent step aborted') + exec.signal.addEventListener('abort', onAbort, { once: true }) try { const result = await run.result @@ -199,7 +196,7 @@ export function apply(ctx: Context, config: Config): void { } return [{ type: 'text', text: renderResult(run, result, maxResultChars) }] } finally { - exec.signal?.removeEventListener('abort', onAbort) + exec.signal.removeEventListener('abort', onAbort) // Always reach run quiescence — never leak a live script or children. await run.dispose() } diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index d8c72ed567..3295af07eb 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow' @@ -13,6 +13,8 @@ import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' import * as toolWorkflow from '../src/index.ts' import { SessionId } from '@deepseek-ai/dsh-session' +const testToolSignal = new AbortController().signal + /** A controllable engine standing in behind ctx.workflows (the tool's only seam). */ class StubEngine extends WorkflowService { requests: WorkflowStartRequest[] = [] @@ -60,6 +62,7 @@ const META = { name: 'audit', description: 'd' } function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise { return ctx.tools.execute({ + signal: testToolSignal, callId: CallId('call-1'), name: 'workflow', arguments: args, @@ -154,14 +157,16 @@ describe('dsh-tool-workflow', () => { expect(result.error?.code).toBe('INVALID_ARGS') }) - it('cancels the run when exec.signal is ALREADY aborted at call time', async () => { + it('skips workflow startup when exec.signal is already aborted', async () => { const { ctx, engine, parent } = await setup() const controller = new AbortController() controller.abort() const result = await execute(ctx, { script: SCRIPT, meta: META }, { agent: parent, signal: controller.signal }) expect(result.isError).toBe(true) - expect(engine.cancels).toContain('parent step aborted') - expect(engine.disposed).toBe(1) + expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }) + expect(engine.requests).toHaveLength(0) + expect(engine.cancels).toHaveLength(0) + expect(engine.disposed).toBe(0) }) it('truncates an oversized rendered value with a notice (maxResultChars)', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b22f624f8..4a41b9a48b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -335,15 +335,15 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../bash-local - '@deepseek-ai/dsh-home': - specifier: workspace:^ - version: link:../../util/home '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -837,9 +837,6 @@ importers: '@deepseek-ai/dsh-goal-session': specifier: workspace:^ version: link:../../goal/goal-session - '@deepseek-ai/dsh-home': - specifier: workspace:^ - version: link:../../util/home '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -849,6 +846,9 @@ importers: '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../llm/llm-retry + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -1843,6 +1843,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -1947,12 +1950,12 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs - '@deepseek-ai/dsh-home': - specifier: workspace:^ - version: link:../../util/home '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill @@ -2810,15 +2813,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/util/home: - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/util/paths: devDependencies: '@deepseek-ai/dsh-invariants': @@ -3205,9 +3199,6 @@ importers: '@deepseek-ai/dsh-goal-session': specifier: workspace:^ version: link:../../packages/goal/goal-session - '@deepseek-ai/dsh-home': - specifier: workspace:^ - version: link:../../packages/util/home '@deepseek-ai/dsh-hook-protocol': specifier: workspace:^ version: link:../../packages/hooks/hook-protocol diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 5661d88de1..7b40675b2d 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -27,7 +27,6 @@ "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", - "@deepseek-ai/dsh-home": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-hooks-claude": "workspace:^", "@deepseek-ai/dsh-hooks-codex": "workspace:^", diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a3e8adffcc..3491b87ba1 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -28,6 +28,7 @@ const FENCE = 'ts cordis-catalog' */ export const LINK_MAP: Record = { Agent: 'core.md', + AgentCancelCause: 'core.md', AgentOptions: 'core.md', AgentStatus: 'core.md', ContentBlock: 'core.md', @@ -128,6 +129,7 @@ export const LINK_MAP: Record = { PreToolDecision: 'tools.md', ToolDefinition: 'tools.md', ToolExecution: 'tools.md', + ToolDispatchExecution: 'tools.md', ToolExecutionInput: 'tools.md', ToolExecutionMode: 'tools.md', ToolExecutionResult: 'tools.md', diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 748f3fa13c..b34c66880f 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -24,6 +24,7 @@ "docs/user/guide/quickstart.md", "docs/user/index.md", ".agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md", + ".agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md", ".agents/notes/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 92bae674c3..f9eae24584 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -14,6 +14,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": "InjectOptions", "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": "HookContext", "source": "packages/core/agent/src/types.ts" }, @@ -100,6 +101,7 @@ { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" }, + { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDispatchExecution", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionMode", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRunContext", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" }, diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 0f41f7be03..11eef97698 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -78,7 +78,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' }, 'packages/ui/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' }, 'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' }, - 'packages/util/home': { kind: 'indirect', reason: 'Only dsh-tool-bash exposes the resolved home to model commands.' }, 'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' }, 'packages/util/retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' }, 'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' }, diff --git a/tsconfig.build.json b/tsconfig.build.json index d7d93cd445..15f492998c 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -11,7 +11,6 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, - { "path": "./packages/util/home" }, { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, { "path": "./packages/util/retention" }, diff --git a/tsconfig.json b/tsconfig.json index c6ebded4a5..5f1d32e2de 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,6 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, - { "path": "./packages/util/home" }, { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, { "path": "./packages/util/retention" },