feat(tools): require cancellation signal on every invocation
This commit is contained in:
@@ -113,6 +113,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<B>` 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.
|
||||
|
||||
@@ -116,7 +116,7 @@ Tool-time context—including async `agent.inject()` notices and post-tool `addi
|
||||
|
||||
The turn is the containment boundary. Final adapter-path and terminal in-band failures close the step before `agent/request-error`; retry opens a numbered step; otherwise, the provider error survives. Attempts reset on success.
|
||||
|
||||
Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched model tool calls receive synthetic `tool/call` and `ABORTED` result pairs before `turn/end`. `cancel()` clears queues and aborts active work; disposal awaits quiescence before unregistering.
|
||||
Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs before `turn/end`. `cancel()` clears queues and aborts active work; disposal awaits quiescence before unregistering.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -1230,7 +1230,7 @@ export interface Config {
|
||||
export type ToolPresentationMode = 'native' | 'code' | 'both'
|
||||
```
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:399`](../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:419`](../packages/core/tools/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tui`
|
||||
|
||||
|
||||
@@ -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: da214702939e01fedf3d0d69be7560bbafe0696a
|
||||
adding-a-tool.zh.md: b216d18b1593cd7e6074685bd39684f1b9694eac
|
||||
adding-a-tool.md: 2057009e3a656a51011c73b4d4b94b97ed4c3930
|
||||
adding-a-tool.zh.md: 42b56c54c9fe511cef943a765d8de6e6eb7a58a9
|
||||
@@ -37,7 +37,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w
|
||||
|
||||
- **Args are validated for you.** `defineTool` validates the model-generated `arguments` against the `SchemaSpec` before `execute` runs (type, required keys, enum membership, nested objects/arrays — [runtime arg validation](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args already match `InferArgs`. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input.
|
||||
- **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 RFC](../rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer.
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export function apply(ctx: Context) {
|
||||
|
||||
- **参数已为你校验。** `defineTool` 在 `execute` 运行前,会根据 `SchemaSpec` 校验模型生成的 `arguments`(类型、必填键、枚举成员、嵌套对象/数组——见[运行时参数校验](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)),因此 `execute` 内部的 args 已匹配 `InferArgs`。你仍需手动检查 DSL 无法表达的值约束(非空字符串、正数、跨字段规则),对这些情况抛出描述性 Error。直接注册的原始 JSON-Schema 工具(MCP)不由 harness 校验,它们自行校验输入。
|
||||
- **注册借用你的只读定义。** 类型化的同进程贡献不是序列化边界;注册后不要修改其 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 运行时 RFC](../rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)与 `dsh-tool-bash`。
|
||||
|
||||
|
||||
@@ -697,7 +697,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
|
||||
'tools/change'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:122`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:123`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
### `tools/execute` — waterfall
|
||||
|
||||
@@ -714,23 +714,24 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
|
||||
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
```
|
||||
|
||||
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:93`](../../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. Async listeners must observe `exec.signal`; after they settle, caller cancellation replaces only a successful accepted outcome with `ABORTED`. 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. Async
|
||||
* listeners must observe `exec.signal`; after they settle, caller
|
||||
* cancellation replaces only a successful accepted outcome with `ABORTED`.
|
||||
* 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.
|
||||
@@ -741,7 +742,7 @@ 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:104`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:105`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
### `tools/pre-execute` — waterfall
|
||||
|
||||
@@ -781,7 +782,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:112`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
## `workflow/*`
|
||||
|
||||
|
||||
@@ -1176,9 +1176,9 @@ executionMode(exec: ToolExecutionInput): ToolExecutionMode
|
||||
* results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
|
||||
* the same lossless, frozen snapshot final observers receive. Cancellation
|
||||
* arriving after entry and before final result materialization skips a
|
||||
* not-yet-started body or replaces a successful pipeline outcome with
|
||||
* `ABORTED`; already-started work is still drained and may retain a
|
||||
* tool-owned structured error.
|
||||
* 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.
|
||||
@@ -1188,7 +1188,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
|
||||
|
||||
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:467`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:493`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
## `ctx.userInteraction` — `UserInteractionService`
|
||||
|
||||
|
||||
@@ -147,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. */
|
||||
@@ -170,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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -212,11 +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`; immediately
|
||||
* before the body, the registry re-fuses the original caller signal so a
|
||||
* wrapper cannot detach caller cancellation. 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. */
|
||||
@@ -224,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, and the registry re-fuses the caller signal before invoking the body. 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<ToolExecution, 'signal'> {
|
||||
/** 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.
|
||||
|
||||
|
||||
@@ -38,11 +38,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:103`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
|
||||
| `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`) | [`acp`](../packages/ui/acp) |
|
||||
| `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:122`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:123`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../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:104`](../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/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:105`](../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:82`](../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:112`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:113`](../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/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-19-cooperative-tool-cancellation.md: d810b8d31aceffd10c10014c3f53173ca81f283f
|
||||
2026-07-19-cooperative-tool-cancellation.zh.md: b0e0d6107b385ca19917bc8379a04814504d0283
|
||||
2026-07-19-cooperative-tool-cancellation.md: 24bd16a26f2f1a82810ce0fd47697c6eaf62bcc4
|
||||
2026-07-19-cooperative-tool-cancellation.zh.md: 037c540e7ce912264355793e445f34af6e222f45
|
||||
@@ -6,60 +6,68 @@ English | [中文](2026-07-19-cooperative-tool-cancellation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Every registered tool receives an optional `AbortSignal`, but a signal alone does not define a reliable cancellation boundary. Cancellation can arrive while pre-execution policy or approval is waiting, while an around-dispatch wrapper is waiting before or after delegation, or after the tool body has started. If each tool and wrapper interprets those races independently, a body can start after its caller has cancelled or a late success can escape after cancellation.
|
||||
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.
|
||||
|
||||
Around-dispatch plugins also need to replace `exec.signal` to add deadlines or other operational cancellation. Treating that mutable slot as the only caller signal lets a wrapper accidentally detach caller cancellation. Forbidding replacement would remove the lexical composition used by the [tool-call timeout policy](2026-07-07-tool-call-timeout-policy.md).
|
||||
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.
|
||||
|
||||
Returning `ABORTED` by racing the tool promise is not a safe fallback. Same-process JavaScript keeps running after the losing promise is abandoned, so subprocesses, network activity, nested dispatches, and deferred context production can outlive the reported result. The registry cannot generically hard-kill that work because termination belongs to the capability that owns it, as established by the [timeout/deadline decision](2026-07-06-timeout-deadline-library.md).
|
||||
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
|
||||
|
||||
`ToolRegistry` owns a cooperative, quiescent cancellation boundary for every call through `ctx.tools.execute()`. It preserves caller cancellation independently of around-dispatch mutation, prevents a body from starting after live cancellation, awaits every body that did start, and lets cancellation that wins before final result materialization supersede every successful pipeline outcome.
|
||||
`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.
|
||||
|
||||
This is a control-plane guarantee, not universal hard termination. Every asynchronous `ToolDefinition.execute()` observes or forwards `exec.signal` and settles only after its owned work stops. The registry does not claim bounded-time settlement for same-process code that violates that contract.
|
||||
`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.
|
||||
|
||||
### Caller cancellation survives the pipeline
|
||||
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.
|
||||
|
||||
The registry captures the caller's signal and whether it was already aborted when it materializes the execution. That state is kept outside the wrapper-mutable `ToolRunContext`.
|
||||
### Mutability follows the pipeline stage
|
||||
|
||||
A signal that was live on entry is rechecked after `tools/pre-execute`, approval, and immediately before the tool body. Cancellation during any of those waits yields structured `ABORTED` without starting the body. Immediately before dispatch, the registry fuses the original caller signal with the current wrapper-supplied `exec.signal`, so adding, replacing, or removing the public slot cannot detach the caller from a running body. Dispatch-scoped listeners are removed when the body settles.
|
||||
`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.
|
||||
|
||||
The registry also rechecks the original caller after the around-dispatch waterfall and post-result policy settle. A wrapper or post-policy listener cannot return a late successful result after caller cancellation merely because the body completed earlier. A wrapper- or policy-owned failure remains a failure; the timeout-policy wrapper may therefore classify its own winning deadline as `TOOL_TIMEOUT` instead of losing that information to generic cancellation.
|
||||
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.
|
||||
|
||||
### Started work reaches quiescence
|
||||
### Cancellation codes record whether dispatch occurred
|
||||
|
||||
Once `ToolDefinition.execute()` starts, the registry awaits it. Cancellation that arrives after the body starts notifies it through the fused signal but does not race or abandon its promise. If the body settles successfully after that cancellation, the registry replaces success with `{ name: 'AbortError', code: 'ABORTED' }`; a structured tool failure remains the more specific result. Context deferred by a composite tool is retained when generic cancellation replaces success.
|
||||
`dsh-tools` exports `TOOL_ABORTED = 'ABORTED'` and `TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'`. The registry records body invocation immediately before calling `ToolDefinition.execute()`.
|
||||
|
||||
This applies even to an uncooperative body: the registry remains pending until the body settles. That cost is deliberate because returning early would make the call appear complete while its side effects remain live. Process, worker, network, and provider implementations supply their own termination mechanism and use the signal to reach quiescence; the registry only owns dispatch and result integrity.
|
||||
`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.
|
||||
|
||||
A cancellation result produced before `tools/post-execute` continues through that policy; cancellation while an asynchronous post listener is waiting replaces only its successful outcome. The frozen `tools/result` notification is the completion boundary, and the agent loop records the resulting model-visible `tool/result`, preserving reconstructability.
|
||||
`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 is a distinct direct-call contract
|
||||
### Pre-aborted entry short-circuits after materialization
|
||||
|
||||
A signal already aborted when registry entry begins still reaches the tool body. Direct service callers use that state for capability-specific cleanup or error translation, and the more specific result remains observable. The agent-loop scheduler does not start a new model-driven body under an already-aborted turn signal, so this exception does not reopen late model dispatch.
|
||||
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
|
||||
|
||||
[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) pins cancellation during pre-policy and around/post waits, signal replacement and removal, no-late-success behavior, context retention, started-body drainage, and pre-aborted direct entry. [`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) pin the no-late-start rule and balanced session-log results for undispatched sibling calls. [`timeout-policy.spec.ts`](../../../../packages/timeout/timeout-policy/tests/timeout-policy.spec.ts) pins caller-cancel-first and timeout-owned classification.
|
||||
[`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 stops in bounded time. Capability tests remain responsible for proving their subprocess, worker, socket, or provider cancellation reaches quiescence.
|
||||
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
|
||||
|
||||
**Race the tool promise against cancellation.** Rejected because it reports completion while the losing promise and its side effects remain live. This violates the [quiescent-disposal rule](../../../defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it) and can let work mutate state after the session records `ABORTED`.
|
||||
**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.
|
||||
|
||||
**Make the registry hard-kill every tool.** Rejected because same-process JavaScript has no safe generic preemption mechanism, while real termination differs by capability: process groups need signals and escalation, workers need termination, and network clients need protocol-aware abort. Moving those mechanisms into `ToolRegistry` would couple the core registry to every implementation.
|
||||
**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.
|
||||
|
||||
**Trust each tool and around wrapper to preserve caller cancellation.** Rejected because the mutable signal slot and asynchronous pre/around waits form one shared scheduling boundary. Central capture and rechecks give every registered tool the same no-late-start and no-late-success rules without duplicating race handling.
|
||||
**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.
|
||||
|
||||
**Forbid around wrappers from replacing `exec.signal`.** Rejected because deadlines and nested operational scopes need to derive a signal for one lexical dispatch. Re-fusing the caller immediately before the body preserves both composition and cancellation.
|
||||
**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.
|
||||
|
||||
**Skip every call whose signal is aborted at entry.** Rejected because direct callers may need the tool body to perform cleanup or translate cancellation into a capability-specific result. The registry distinguishes that explicit entry state from a live signal that aborts during scheduling, while the agent loop independently prevents new model-driven dispatch after turn cancellation.
|
||||
**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](../../../defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every registry invocation has one service-layer cancellation contract, including tools supplied by plugins or MCP bridges, but only cooperative implementations are guaranteed to stop promptly.
|
||||
- Caller cancellation is monotonic across pre-policy, around-dispatch, and post-policy success: once a live caller signal aborts before final materialization, a body does not start late and a normal success does not become authoritative.
|
||||
- Started work can delay cancellation indefinitely when an implementation ignores its signal. The registry deliberately exposes that defect as a non-quiescent call instead of hiding it behind an early result.
|
||||
- Capability-specific failures and timeout ownership remain intact. Generic `ABORTED` replaces success, not a more informative error result.
|
||||
- Around wrappers retain signal replacement as their composition mechanism, while the original caller signal remains non-detachable at dispatch.
|
||||
- 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 RFC is accepted and implemented.
|
||||
@@ -1,4 +1,4 @@
|
||||
# RFC: 注册表边界上的协作式工具取消
|
||||
# RFC:注册表边界上的协作式工具取消
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,60 +6,68 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
每个已注册工具都会收到可选的 `AbortSignal`,但仅提供信号不足以构成可靠的取消边界。取消可能发生在执行前策略或审批等待期间、环绕调度包装层委托前后的等待期间,或工具主体启动后。若各工具和包装层各自处理这些竞态,调用方取消后工具主体仍可能启动,延迟完成的成功结果也可能在取消后生效。
|
||||
每次类型化工具调用都需要一个由调用方持有的取消信号。可选的 `ToolExecutionInput.signal` 允许直接调用方不承担所有权,使每个工具主体中的 `exec.signal` 都成为可选值,也会诱使注册表提供无法表达真实调用方生命周期的后备信号。
|
||||
|
||||
环绕调度插件还需要替换 `exec.signal`,以加入截止时间或其他运行时取消来源。若把这个可变槽位视为唯一的调用方信号,包装层就可能意外切断调用方的取消。禁止替换又会移除[工具调用超时策略](2026-07-07-tool-call-timeout-policy.md)所采用的词法作用域组合方式。
|
||||
流水线各阶段对可变性的需求也不同。工具实现、前置策略、后置策略和结果观察者只借用取消状态,而环绕调度包装层必须临时替换信号,以加入截止时间或其他词法取消作用域。单一的可变公开类型要么把修改权限授予过多阶段,要么阻止这种组合。
|
||||
|
||||
通过工具 promise 与取消竞速来返回 `ABORTED` 也不安全。同进程 JavaScript 即使在竞速中落败、其 promise 被丢弃,仍会继续运行,因此子进程、网络活动、嵌套调度和延后产生的上下文都可能超过已报告结果的生命周期。注册表无法用通用方式强制终止这些工作,因为终止机制属于工作所属的能力,正如[超时与截止时间决策](2026-07-06-timeout-deadline-library.md)所规定。
|
||||
取消可能发生在策略之前、审批期间、环绕调度等待期间、工具主体启动之后,或后置策略等待期间。单一的 `ABORTED` 结果无法让持久化结果的使用方判断工具主体是否可能产生过副作用。让工具 promise 与取消竞速也不是安全的后备方案,因为注册表报告完成后,被丢弃的同进程工作仍会继续运行。
|
||||
|
||||
## 决策
|
||||
|
||||
`ToolRegistry` 为每次通过 `ctx.tools.execute()` 发起的调用提供协作式、保证完全停稳的取消边界。它独立于环绕调度对执行对象的修改来保留调用方取消,阻止工具主体在取消后才启动,等待所有已经启动的工具主体完成,并让最终结果物化前先发生的取消覆盖所有成功的流水线结果。
|
||||
`ToolExecutionInput.signal` 是必填且只读的 `AbortSignal`,因此 `ToolExecution.signal` 和 `ToolRunContext.signal` 也都是必填且只读。每个类型化调用方显式提供自己持有的信号;注册表不提供重载、默认控制器、永不中止哨兵或便捷执行路径。
|
||||
|
||||
这项保证只覆盖控制平面,不等同于通用的强制终止。所有异步 `ToolDefinition.execute()` 都必须观察或转发 `exec.signal`,并且仅在自己负责的工作停止后完成。同进程代码若违反这项契约,注册表不保证其能在有界时间内完成。
|
||||
`ToolDefinition.execute(args, exec)` 保持现有签名。`defineTool()` 会把 `exec.signal` 上下文推断为必填的 `AbortSignal`,因此每个已注册的 TypeScript 工具都能在无需类型断言的情况下观察或转发取消。所有第一方直接调用方和 Code Mode 嵌套调度都会显式传入当前操作的信号。
|
||||
|
||||
### 调用方取消不会在流水线中丢失
|
||||
注册表信任这份类型化同进程契约。它不在运行时校验 `AbortSignal`,也不为缺失或畸形信号添加敌意输入测试。校验仍位于解析器与配置、队列、模型与工具 JSON、持久化与文件、worker、进程和线协议边界;违反 TypeScript 接口的无类型 JavaScript 不享有兼容性契约。
|
||||
|
||||
注册表在物化执行对象时捕获调用方信号,并记录该信号在进入时是否已经中止。这份状态存放在包装层可修改的 `ToolRunContext` 之外。
|
||||
### 可变性由流水线阶段决定
|
||||
|
||||
对于进入时仍有效的信号,注册表会在 `tools/pre-execute`、审批以及工具主体启动前再次检查。若取消发生在这些等待期间,注册表会返回结构化 `ABORTED`,且不会启动工具主体。调度前一刻,注册表把原始调用方信号与包装层当前提供的 `exec.signal` 融合,因此无论包装层新增、替换还是移除公开槽位,都无法让运行中的工具主体脱离调用方取消。仅属于本次调度的监听器会在工具主体完成时移除。
|
||||
`ToolDispatchExecution` 与 `ToolExecution` 相同,唯一差异是其必填 `signal` 可修改。只有 `tools/execute` waterfall(瀑布式事件)接收这个类型。前置策略、后置策略、结果观察者、守卫和工具实现接收注册表私有可变运行对象的只读视图。
|
||||
|
||||
环绕调度 waterfall(瀑布式事件)和结果后置策略完成后,注册表还会再次检查原始调用方信号。即使工具主体更早完成,包装层或后置策略监听器也不能在调用方取消后返回延迟成功结果。包装层或策略自身产生的失败仍按失败处理,因此 timeout-policy 包装层可以把自身先到达的截止时间归类为 `TOOL_TIMEOUT`,而不会被通用取消覆盖。
|
||||
环绕调度包装层可以在委托期间替换 `exec.signal`,但无法通过类型系统删除它或赋值为 `undefined`。注册表在可变对象之外捕获必填的调用方信号,在工具主体调用前把每次包装层替换与调用方信号融合,在完成后移除仅属于本次调度的监听器,并无条件恢复必填的上游信号。
|
||||
|
||||
### 已启动的工作必须完全停稳
|
||||
### 取消代码记录是否发生过调度
|
||||
|
||||
`ToolDefinition.execute()` 一旦启动,注册表就会等待它完成。工具主体启动后发生的取消会通过融合信号通知它,但注册表不会与其 promise 竞速,也不会丢弃该 promise。若工具主体在这次取消后仍以成功结果完成,注册表会用 `{ name: 'AbortError', code: 'ABORTED' }` 替换成功结果;工具自身的结构化失败仍是信息更具体的结果。通用取消替换成功结果时,会保留组合工具延后附加的上下文。
|
||||
`dsh-tools` 导出 `TOOL_ABORTED = 'ABORTED'` 和 `TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'`。注册表在调用 `ToolDefinition.execute()` 的前一刻记录工具主体已经开始。
|
||||
|
||||
即使工具主体不协作,这项规则仍然适用:注册表调用会保持未完成,直到工具主体完成。这项代价是刻意保留的,因为提前返回会让调用看似已经结束,但其副作用仍在运行。进程、worker、网络和提供方实现各自提供终止机制,并使用信号使工作完全停稳;注册表只负责调度与结果完整性。
|
||||
`ABORTED_BEFORE_DISPATCH` 携带 `{ name: 'AbortError' }` 和模型可见文本 `Error: tool call aborted before dispatch`。凡取消阻止工具主体调用时都使用该结果,包括进入时已中止、前置策略或审批期间取消、包装层信号已中止、包装层在委托前返回的成功结果被调用方取消抢先,以及轮次取消后 agent loop 跳过的同批调用。
|
||||
|
||||
在 `tools/post-execute` 之前产生的取消结果会继续经过该策略;若取消发生在异步后置监听器等待期间,注册表只替换其成功结果。冻结的 `tools/result` 通知是完成边界,agent loop(智能体循环)会记录最终的模型可见 `tool/result`,从而保持可重建性。
|
||||
`ABORTED` 携带模型可见文本 `Error: tool call aborted`,并且只在工具主体已经调用后使用,包括工具主体完成后环绕包装层或后置策略监听器等待期间发生的取消。拒绝、包装层失败、工具失败或后置策略失败比通用取消更具体。timeout-policy 自身拥有的超时仍为 `TOOL_TIMEOUT`,成功结果被取消替换前延后附加的上下文仍会保留。
|
||||
|
||||
### 进入时已中止属于独立的直接调用契约
|
||||
### 进入时已中止会在物化后短路
|
||||
|
||||
若信号在进入注册表时已经中止,工具主体仍会收到它。直接调用服务的代码可利用该状态执行能力特定的清理或错误转换,信息更具体的结果也会保持可见。agent loop 调度器不会在轮次信号已经中止时启动新的模型驱动工具主体,因此这项例外不会重新允许模型工具延迟调度。
|
||||
注册表先创建调用 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)。
|
||||
|
||||
## 验证
|
||||
|
||||
[`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) 固定了禁止延迟启动的规则,以及未调度同批调用在会话日志中仍具有配对结果。[`timeout-policy.spec.ts`](../../../../packages/timeout/timeout-policy/tests/timeout-policy.spec.ts) 固定了调用方先取消和超时归属方分类行为。
|
||||
[`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) 保持超时归属。
|
||||
|
||||
任何注册表测试都无法证明任意第三方同进程代码会在有界时间内停止。各能力的测试仍需证明其子进程、worker、套接字或提供方取消能够使工作完全停稳。
|
||||
任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在落败的 promise 及其副作用仍在运行时报告完成。这违反了[资源释放必须完全停稳的规则](../../../defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it),并可能让会话记录 `ABORTED` 后仍有工作修改状态。
|
||||
**保留可选信号并生成后备值。** 不予采纳,因为注册表持有的后备信号不代表任何调用方生命周期,也会保留类型系统本应阻止的缺失情况。
|
||||
|
||||
**由注册表强制终止每个工具。** 不予采纳,因为同进程 JavaScript 没有安全、通用的抢占机制,而各能力的实际终止方式不同:进程组需要信号和升级处理,worker 需要终止,网络客户端则需要按协议中止。把这些机制移入 `ToolRegistry` 会让核心注册表耦合到每种实现。
|
||||
**在运行时校验 `AbortSignal`。** 不予采纳,因为这是类型化同进程接缝,不是序列化边界。运行时检查只会重复静态契约,仍无法强制实现协作式使用信号。
|
||||
|
||||
**相信各工具和环绕包装层自行保留调用方取消。** 不予采纳,因为可变信号槽位与异步执行前、环绕调度等待共同构成一处共享调度边界。集中捕获并重复检查可以让所有已注册工具遵守相同的禁止延迟启动和禁止延迟成功规则,无需重复实现竞态处理。
|
||||
**添加 `supportsCancellation` 元数据、回调参数数量检查或信号使用 lint。** 不予采纳,因为这些方法都无法证明异步工作会观察或正确转发取消。信号可用性属于类型契约;具体行为仍由工具和能力负责。
|
||||
|
||||
**禁止环绕包装层替换 `exec.signal`。** 不予采纳,因为截止时间和嵌套运行时作用域需要为一次词法调度派生信号。在工具主体启动前重新融合调用方信号,可以同时保留组合能力与取消语义。
|
||||
**向所有阶段公开同一个可变执行类型。** 不予采纳,因为观察者和工具实现只需要借用信号。按阶段划分类型可以把替换权限限制在流水线拥有该操作的位置。
|
||||
|
||||
**跳过进入时信号已经中止的所有调用。** 不予采纳,因为直接调用方可能需要工具主体执行清理,或把取消转换为能力特定的结果。注册表会区分这种显式进入状态与调度期间由有效变为中止的信号,而 agent loop 会独立阻止轮次取消后产生新的模型驱动调度。
|
||||
**禁止环绕包装层替换信号。** 不予采纳,因为截止时间和嵌套运行时作用域需要词法派生信号。捕获并融合调用方信号既保留组合能力,也不允许切断调用方取消。
|
||||
|
||||
**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[资源释放必须完全停稳的规则](../../../defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it)。
|
||||
|
||||
## 后果
|
||||
|
||||
- 每次注册表调用都遵循同一份服务层取消契约,包括插件或 MCP 桥接提供的工具;但只有协作式实现才能保证及时停止。
|
||||
- 调用方取消在执行前策略、环绕调度和后置策略的成功路径上保持单调:只要进入时有效的调用方信号在最终结果物化前发生中止,工具主体就不会延迟启动,普通成功也不会成为权威结果。
|
||||
- 若实现忽略信号,已启动的工作可以无限期推迟取消。注册表会刻意把这一缺陷暴露为无法完全停稳的调用,而不是用提前返回的结果掩盖它。
|
||||
- 能力特定失败与超时归属保持不变。通用 `ABORTED` 只替换成功结果,不替换信息更具体的错误结果。
|
||||
- 环绕包装层继续通过替换信号来完成组合,而原始调用方信号在调度时无法被切断。
|
||||
- TypeScript 会拒绝所有缺少 `signal` 的 `ToolExecutionInput`、工具或观察者对只读信号的修改,以及环绕调度删除信号的尝试。
|
||||
- 持久化结果的使用方可以区分工具主体可能产生过副作用的调用(`ABORTED`)和从未进入工具主体的调用(`ABORTED_BEFORE_DISPATCH`)。
|
||||
- 根据仓库的预发布原则,这项变更刻意保持破坏性;不保留兼容重载或运行时后备行为。
|
||||
- 协作式工具会及时停止并完全停稳;忽略信号的实现会表现为仍在等待的调用。
|
||||
- 下游能力接口保持不变,直到关联的提议 RFC 被接受并实现。
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"}}}
|
||||
@@ -4,4 +4,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```"}}]}}}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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')
|
||||
|
||||
@@ -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.<name>` 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](../docs/rfc/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](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
|
||||
@@ -366,7 +366,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
toolName: 'bash',
|
||||
callId: exec.callId,
|
||||
reason: `escalate sandbox to ${mode}: ${justification}`,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
signal: exec.signal,
|
||||
})
|
||||
switch (outcome) {
|
||||
case 'allowed-once': return mode as SandboxMode
|
||||
@@ -437,8 +437,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 pre-start cancellation; returned tasks use their own lifecycle.
|
||||
if (exec.signal?.aborted) throw new Error('command aborted')
|
||||
// Task preflight finishes before the starter can spawn a process.
|
||||
const id = tasks.start({
|
||||
kind: 'bash',
|
||||
@@ -457,7 +455,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) }]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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_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 {
|
||||
@@ -451,7 +453,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 +472,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)
|
||||
})
|
||||
|
||||
@@ -730,7 +733,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 +1015,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 +1036,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 +1063,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 +1085,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 +1116,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 +1138,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: {
|
||||
|
||||
@@ -501,7 +501,7 @@ export async function dynamicInstructionContext(
|
||||
{
|
||||
touchedPath,
|
||||
includeBaselineScopes: baselineInstructionStates.has(agent.session),
|
||||
...exec.signal === undefined ? {} : { signal: exec.signal },
|
||||
signal: exec.signal,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -40,6 +40,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<string> {
|
||||
return mkdtemp(join(tmpdir(), 'dsh-workspace-context-'))
|
||||
}
|
||||
@@ -797,6 +799,7 @@ describe('workspace context request injection', () => {
|
||||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||||
|
||||
const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('no-fs-post-execute'),
|
||||
name: 'read',
|
||||
arguments: { file_path: 'pkg/file.txt' },
|
||||
@@ -832,6 +835,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' },
|
||||
@@ -977,6 +981,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,
|
||||
})
|
||||
|
||||
@@ -1005,6 +1010,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,
|
||||
})
|
||||
|
||||
@@ -1030,6 +1036,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,
|
||||
})
|
||||
|
||||
@@ -1108,6 +1115,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')
|
||||
@@ -1687,6 +1713,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' },
|
||||
@@ -1747,6 +1774,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' },
|
||||
@@ -1775,12 +1803,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' },
|
||||
@@ -1813,10 +1843,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,
|
||||
})
|
||||
|
||||
@@ -1848,14 +1880,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,
|
||||
})
|
||||
|
||||
@@ -1886,9 +1921,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),
|
||||
})
|
||||
|
||||
@@ -1914,11 +1951,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,
|
||||
})
|
||||
|
||||
@@ -1954,15 +1993,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,
|
||||
})
|
||||
|
||||
@@ -1993,11 +2035,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,
|
||||
})
|
||||
|
||||
@@ -2031,17 +2075,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,
|
||||
})
|
||||
|
||||
@@ -2073,11 +2120,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,
|
||||
})
|
||||
|
||||
@@ -2101,6 +2150,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' },
|
||||
@@ -2113,6 +2163,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' },
|
||||
@@ -2138,6 +2189,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)
|
||||
@@ -2168,6 +2220,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' },
|
||||
@@ -2175,6 +2228,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' },
|
||||
@@ -2190,6 +2244,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' },
|
||||
@@ -2219,6 +2274,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' },
|
||||
@@ -2227,6 +2283,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' },
|
||||
@@ -2254,6 +2311,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' },
|
||||
@@ -2262,6 +2320,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' },
|
||||
@@ -2323,6 +2382,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' },
|
||||
@@ -2349,12 +2409,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') },
|
||||
@@ -2388,11 +2450,13 @@ describe('dynamic nested workspace context injection', () => {
|
||||
}
|
||||
|
||||
const failedStat = await ctx.waterfall('tools/post-execute', 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 ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
|
||||
}), result, async () => ({ kind: 'accept' as const }))
|
||||
|
||||
@@ -2418,6 +2482,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' },
|
||||
@@ -2452,6 +2517,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' },
|
||||
@@ -2496,6 +2562,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' },
|
||||
@@ -2536,6 +2603,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' },
|
||||
@@ -2543,6 +2611,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' },
|
||||
@@ -2583,7 +2652,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
|
||||
@@ -2600,10 +2669,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,
|
||||
})
|
||||
|
||||
@@ -2627,19 +2698,23 @@ describe('dynamic nested workspace context injection', () => {
|
||||
const plainResult = { callId: CallId('plain'), content: [], isError: false }
|
||||
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('agentless-child'), name: 'read', arguments: {}, parent,
|
||||
}), plainResult)
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('contextless-child'), name: 'read', arguments: {}, agent, parent,
|
||||
}), { ...plainResult, additionalContexts: [{ content: [], source: { kind: 'plugin', plugin: 'workspace-context' } }] })
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('first-child'), name: 'read', arguments: {}, agent, parent,
|
||||
}), { ...plainResult, additionalContexts: [workspaceChangeContext('first', 'one')] })
|
||||
ctx.emit('tools/result', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('second-child'), name: 'read', arguments: {}, agent, parent,
|
||||
}), { ...plainResult, additionalContexts: [workspaceChangeContext('second', 'two')] })
|
||||
ctx.emit('tools/result', {
|
||||
...stubToolExecution({ callId: CallId('agentless-parent'), name: 'composite', arguments: {} }),
|
||||
...stubToolExecution({ signal: testToolSignal, callId: CallId('agentless-parent'), name: 'composite', arguments: {} }),
|
||||
token: parent,
|
||||
}, plainResult)
|
||||
|
||||
@@ -2675,6 +2750,7 @@ describe('dynamic nested workspace context injection', () => {
|
||||
|
||||
for (const item of cases) {
|
||||
const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({
|
||||
signal: testToolSignal,
|
||||
callId: CallId(`manual-${item.name}-${cases.indexOf(item)}`),
|
||||
name: item.name,
|
||||
arguments: item.arguments,
|
||||
@@ -2699,6 +2775,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' },
|
||||
@@ -2723,6 +2800,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' },
|
||||
@@ -2749,6 +2827,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' },
|
||||
|
||||
@@ -552,7 +552,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>',
|
||||
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 or replaces a successful pipeline outcome with\n * `ABORTED`; already-started work is still drained and may retain a\n * 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 */',
|
||||
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 */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -833,7 +833,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
{
|
||||
name: 'tools/execute',
|
||||
mode: 'waterfall',
|
||||
signature: '\'tools/execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>',
|
||||
signature: '\'tools/execute\'(this: Scoped<ToolRegistry>, exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>',
|
||||
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.',
|
||||
},
|
||||
@@ -841,7 +841,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
name: 'tools/post-execute',
|
||||
mode: 'waterfall',
|
||||
signature: '\'tools/post-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>',
|
||||
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 `ABORTED`.\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.',
|
||||
},
|
||||
{
|
||||
@@ -1514,7 +1514,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',
|
||||
|
||||
@@ -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<ToolExecutionResult> {
|
||||
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. */
|
||||
|
||||
@@ -54,7 +54,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 observes a closed failed step, and a retry rebuilds the request from the durable log in a new numbered step. Cancellation clears pending work and aborts the current step without leaking to the next prompt; 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 observes a closed failed step, and a retry rebuilds the request from the durable log in a new numbered step. Cancellation clears pending work and aborts the current step without leaking to the next prompt; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. 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.
|
||||
|
||||
@@ -102,7 +102,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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -239,6 +241,7 @@ describe('AgentLoop initiator scope', () => {
|
||||
}))
|
||||
|
||||
const direct = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('direct'),
|
||||
name: 'agentless-probe',
|
||||
arguments: {},
|
||||
|
||||
@@ -12,7 +12,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'
|
||||
@@ -181,7 +181,7 @@ describe('Agent.cancel()', () => {
|
||||
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')
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
|
||||
import LlmService, { CallId, ContentBlock, MessageSource, 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'
|
||||
@@ -259,7 +259,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' ? 'aborted' : 'completed'
|
||||
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
|
||||
}
|
||||
@@ -308,12 +311,12 @@ describe('abort during tool execution ends the turn', () => {
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
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 },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -476,8 +476,8 @@ 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 } },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -508,8 +508,8 @@ 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 } },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -541,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))
|
||||
@@ -584,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 } })
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,7 @@ tools:
|
||||
|
||||
### Cancellation
|
||||
|
||||
Cancellation is cooperative and quiescent. A cancellation that arrives after registry entry is rechecked after pre-policy, approval, around-dispatch, and post-result policy waits, so a body cannot start late and cancellation that wins before final result materialization supersedes a successful pipeline outcome; if the body has started, the registry preserves the caller signal through wrapper replacement and awaits settlement. A tool-owned structured error still wins. The registry never races away from a live same-process promise: every async tool must observe or forward `exec.signal` and settle only after owned work stops. A signal already aborted on entry still reaches the body for domain-specific cleanup; the agent-loop scheduler prevents model-driven calls from entering in that state. A timeout wrapper may replace the intermediate `ABORTED` with its owned `TOOL_TIMEOUT` when its deadline won. The [tool-cancellation RFC](../../../docs/rfc/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md) owns the service boundary and its hard-termination limit.
|
||||
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 RFC](../../../docs/rfc/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md) owns the full contract and hard-termination limit.
|
||||
|
||||
### Live events
|
||||
|
||||
@@ -38,9 +38,9 @@ The live registry pipeline has three transformable waterfalls followed by the ob
|
||||
### Key types
|
||||
|
||||
- `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, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
|
||||
- `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; the registry separately retains and re-fuses the original caller signal. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
|
||||
- `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, envelope, 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -90,12 +90,13 @@ declare module 'cordis' {
|
||||
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
/**
|
||||
* 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 `ABORTED`.
|
||||
* 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.
|
||||
@@ -218,7 +219,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,17 +234,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`; immediately
|
||||
* before the body, the registry re-fuses the original caller signal so a
|
||||
* wrapper cannot detach caller cancellation. 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<ToolExecution, 'signal'> {
|
||||
/** 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
|
||||
@@ -258,6 +268,9 @@ export interface ToolRunContext extends ToolExecution {
|
||||
deferContext(context: HookContext): void
|
||||
}
|
||||
|
||||
/** Registry-owned live execution object; public pipeline views stay readonly. */
|
||||
type MutableToolRunContext = Omit<ToolRunContext, 'signal'> & { signal: AbortSignal }
|
||||
|
||||
/**
|
||||
* Scheduler-only result after ordered pre-execute and guards. A `post-result`
|
||||
* still receives post-execute; a `final-result` bypasses it.
|
||||
@@ -299,6 +312,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
|
||||
@@ -448,15 +468,21 @@ interface ToolGuardRegistration {
|
||||
guard: ToolGuard
|
||||
}
|
||||
|
||||
/** Caller cancellation captured before around-dispatch wrappers may replace the public signal slot. */
|
||||
/** Approval decision plus whether the approval channel reported cancellation. */
|
||||
interface ToolAskResolution {
|
||||
readonly decision: Extract<PreToolDecision, { kind: 'allow' | 'deny' }>
|
||||
readonly approvalCancelled: boolean
|
||||
}
|
||||
|
||||
/** Caller cancellation and dispatch state kept outside the around-wrapper view. */
|
||||
interface ToolCancellationState {
|
||||
readonly callerSignal: AbortSignal | undefined
|
||||
readonly abortedAtEntry: boolean
|
||||
readonly callerSignal: AbortSignal
|
||||
bodyInvoked: boolean
|
||||
}
|
||||
|
||||
/** One dispatch-scoped fused signal plus listener cleanup after the body settles. */
|
||||
interface FusedToolSignal {
|
||||
readonly signal: AbortSignal | undefined
|
||||
readonly signal: AbortSignal
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
@@ -807,9 +833,9 @@ export class ToolRegistry extends Service {
|
||||
* results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
|
||||
* the same lossless, frozen snapshot final observers receive. Cancellation
|
||||
* arriving after entry and before final result materialization skips a
|
||||
* not-yet-started body or replaces a successful pipeline outcome with
|
||||
* `ABORTED`; already-started work is still drained and may retain a
|
||||
* tool-owned structured error.
|
||||
* 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.
|
||||
@@ -836,7 +862,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
|
||||
@@ -848,9 +874,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)
|
||||
},
|
||||
@@ -860,15 +886,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,
|
||||
abortedAtEntry: signal?.aborted === true,
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -890,15 +916,21 @@ 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<PreToolDecision>({ kind: 'allow' }),
|
||||
)
|
||||
const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
|
||||
if (this.callerCancelledAfterEntry(exec)) {
|
||||
return await next({ kind: 'post-result', exec, result: toolAbortedResult() })
|
||||
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)
|
||||
@@ -913,20 +945,31 @@ 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 this.callerCancelledAfterEntry(exec)
|
||||
? await next({ kind: 'post-result', exec, result: toolAbortedResult() })
|
||||
: next({ kind: 'final-result', exec, result: toolErrorResult(error) })
|
||||
return next({ kind: 'final-result', exec, result: toolErrorResult(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the original live caller signal aborted after this execution entered the registry. */
|
||||
private callerCancelledAfterEntry(exec: ToolRunContext): boolean {
|
||||
/** 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.abortedAtEntry && state.callerSignal?.aborted === true
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -934,24 +977,23 @@ export class ToolRegistry extends Service {
|
||||
* into any around-wrapper replacement. Cancellation never abandons the body:
|
||||
* a started promise reaches quiescence before its outcome becomes `ABORTED`.
|
||||
*/
|
||||
private async dispatchToolBody(exec: ToolRunContext): Promise<ToolExecutionResult> {
|
||||
private async dispatchToolBody(exec: MutableToolRunContext): Promise<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')
|
||||
const wrapperSignal = exec.signal
|
||||
const fused = fuseToolSignals(state.callerSignal, wrapperSignal)
|
||||
const signal = fused.signal
|
||||
const abortedBeforeBody = isAborted(signal)
|
||||
|
||||
if (!state.abortedAtEntry && abortedBeforeBody) {
|
||||
if (isAborted(signal)) {
|
||||
fused.dispose()
|
||||
return toolAbortedResult()
|
||||
return toolAbortedBeforeDispatchResult()
|
||||
}
|
||||
if (signal === undefined) delete exec.signal
|
||||
else exec.signal = signal
|
||||
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
|
||||
@@ -960,15 +1002,14 @@ export class ToolRegistry extends Service {
|
||||
isError: false,
|
||||
...meta !== undefined ? { meta } : {},
|
||||
}
|
||||
return !abortedBeforeBody && isAborted(signal)
|
||||
return isAborted(signal)
|
||||
? toolAbortedResult(result)
|
||||
: result
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(error)
|
||||
} finally {
|
||||
fused.dispose()
|
||||
if (wrapperSignal === undefined) delete exec.signal
|
||||
else exec.signal = wrapperSignal
|
||||
exec.signal = wrapperSignal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -981,10 +1022,11 @@ export class ToolRegistry extends Service {
|
||||
*/
|
||||
private async dispatchScheduledExecution(exec: ToolRunContext): Promise<ScheduledToolDispatch> {
|
||||
try {
|
||||
const mutableExec = exec as MutableToolRunContext
|
||||
const carrier = scopeTarget(this, exec.agent)
|
||||
const result = await this.ctx.waterfall(
|
||||
carrier, 'tools/execute', exec,
|
||||
() => this.dispatchToolBody(exec),
|
||||
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 */
|
||||
@@ -1000,8 +1042,8 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
return {
|
||||
kind: 'post-result',
|
||||
result: this.callerCancelledAfterEntry(exec) && !resultWithDeferredContexts.isError
|
||||
? toolAbortedResult(resultWithDeferredContexts)
|
||||
result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError
|
||||
? this.cancellationResult(exec, resultWithDeferredContexts)
|
||||
: resultWithDeferredContexts,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
@@ -1021,8 +1063,8 @@ export class ToolRegistry extends Service {
|
||||
const postResult = await this.postExecute(exec, result)
|
||||
return this.finishScheduledExecution(
|
||||
exec,
|
||||
this.callerCancelledAfterEntry(exec) && !postResult.isError
|
||||
? toolAbortedResult(postResult)
|
||||
this.callerCancelled(exec) && !postResult.isError
|
||||
? this.cancellationResult(exec, postResult)
|
||||
: postResult,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
@@ -1050,8 +1092,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 callbacks = this.ctx.events.dispatch('emit', [
|
||||
scopeTarget(this, exec.agent), 'tools/result', exec, result,
|
||||
@@ -1079,26 +1121,41 @@ export class ToolRegistry extends Service {
|
||||
private async serviceAsk(
|
||||
exec: ToolExecution,
|
||||
ask: Extract<PreToolDecision, { kind: 'ask' }>,
|
||||
): Promise<Extract<PreToolDecision, { kind: 'allow' | 'deny' }>> {
|
||||
): Promise<ToolAskResolution> {
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -1165,19 +1222,16 @@ function toolErrorResult(error: unknown): ToolExecutionResult {
|
||||
}
|
||||
|
||||
/** Read live abort state across an await without treating it as synchronously immutable. */
|
||||
function isAborted(signal: AbortSignal | undefined): boolean {
|
||||
return signal?.aborted === true
|
||||
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 | undefined, wrapper: AbortSignal | undefined): FusedToolSignal {
|
||||
if (caller === undefined || caller === wrapper) {
|
||||
return { signal: wrapper ?? caller, dispose() {} }
|
||||
}
|
||||
if (wrapper === undefined) return { signal: caller, dispose() {} }
|
||||
function fuseToolSignals(caller: AbortSignal, wrapper: AbortSignal): FusedToolSignal {
|
||||
if (caller === wrapper) return { signal: caller, dispose() {} }
|
||||
|
||||
const controller = new AbortController()
|
||||
let listening = false
|
||||
@@ -1205,13 +1259,24 @@ function fuseToolSignals(caller: AbortSignal | undefined, wrapper: AbortSignal |
|
||||
return { signal: controller.signal, dispose }
|
||||
}
|
||||
|
||||
/** Canonical result when cancellation prevents dispatch or supersedes a successful outcome. */
|
||||
/** 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: 'ABORTED' },
|
||||
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 } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 RFC'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<ToolExecutionResult> {
|
||||
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) => {
|
||||
@@ -577,7 +579,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
seen.push(args.id)
|
||||
await new Promise<void>((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 }]
|
||||
},
|
||||
@@ -613,7 +615,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
started()
|
||||
await new Promise<void>((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 }]
|
||||
},
|
||||
@@ -841,7 +843,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) => {
|
||||
@@ -853,7 +855,12 @@ 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([])
|
||||
})
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<AbortSignal>()
|
||||
// @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<ToolExecutionInput['signal']>().toEqualTypeOf<AbortSignal>()
|
||||
expectTypeOf<ToolExecution['signal']>().toEqualTypeOf<AbortSignal>()
|
||||
expectTypeOf<ToolRunContext['signal']>().toEqualTypeOf<AbortSignal>()
|
||||
expectTypeOf<ToolDispatchExecution['signal']>().toEqualTypeOf<AbortSignal>()
|
||||
expectTypeOf<typeof inferredTool.execute>().toBeFunction()
|
||||
})
|
||||
})
|
||||
@@ -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<Context> {
|
||||
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<string> {
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -584,7 +593,7 @@ describe('scoped execution dispatch', () => {
|
||||
})
|
||||
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 })
|
||||
expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] })
|
||||
expect(seen).toEqual([true, true])
|
||||
expect(dispatchModes).toEqual(['emit'])
|
||||
|
||||
@@ -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, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
|
||||
TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH,
|
||||
type InferArgs, type SchemaSpec, 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<PreToolDecision> =>
|
||||
({ 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<PreToolDecision> => ({ 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<ApprovalOutcome>('rejected'))
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ 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<ApprovalOutcome>('cancelled'))
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ 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<undefined>()
|
||||
const release = Promise.withResolvers<ApprovalOutcome>()
|
||||
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<PreToolDecision> => ({ 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<PreToolDecision> => ({ 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<PreToolDecision> => ({ 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<PreToolDecision> => ({ 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<PostToolDecision> =>
|
||||
({ 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<PostToolDecision> =>
|
||||
({ 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<PostToolDecision> =>
|
||||
({ 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' },
|
||||
@@ -433,7 +473,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' }])
|
||||
|
||||
@@ -442,7 +482,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' }])
|
||||
})
|
||||
@@ -465,7 +505,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'])
|
||||
@@ -485,7 +525,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<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
||||
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
||||
order.push('execute:before')
|
||||
const result = await next()
|
||||
order.push('execute:after')
|
||||
@@ -493,7 +533,7 @@ 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'])
|
||||
@@ -524,14 +564,45 @@ describe('ToolRegistry', () => {
|
||||
release.resolve(undefined)
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted' }],
|
||||
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 },
|
||||
})
|
||||
expect(dispatched).toBe(0)
|
||||
})
|
||||
|
||||
it('materializes ABORTED when an async pre-execute gate throws after cancellation', async () => {
|
||||
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<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
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({
|
||||
@@ -555,9 +626,9 @@ describe('ToolRegistry', () => {
|
||||
controller.abort('cancelled in policy')
|
||||
release.resolve(undefined)
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
await expect(pending).resolves.toEqual({
|
||||
content: [{ type: 'text', text: 'Error: gate interrupted' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
})
|
||||
expect(dispatched).toBe(0)
|
||||
})
|
||||
@@ -581,8 +652,7 @@ describe('ToolRegistry', () => {
|
||||
await release.promise
|
||||
return await next()
|
||||
} finally {
|
||||
if (upstream === undefined) delete exec.signal
|
||||
else exec.signal = upstream
|
||||
exec.signal = upstream
|
||||
}
|
||||
})
|
||||
|
||||
@@ -596,7 +666,7 @@ describe('ToolRegistry', () => {
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
})
|
||||
expect(dispatched).toBe(0)
|
||||
})
|
||||
@@ -616,8 +686,7 @@ describe('ToolRegistry', () => {
|
||||
try {
|
||||
return await next()
|
||||
} finally {
|
||||
if (upstream === undefined) delete exec.signal
|
||||
else exec.signal = upstream
|
||||
exec.signal = upstream
|
||||
}
|
||||
})
|
||||
|
||||
@@ -626,7 +695,50 @@ describe('ToolRegistry', () => {
|
||||
callId: CallId('cancelled-wrapper'), name: 'must-not-run', arguments: {}, signal: controller.signal,
|
||||
})
|
||||
|
||||
expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' })
|
||||
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<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -662,7 +774,7 @@ describe('ToolRegistry', () => {
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
error: { name: 'AbortError', code: TOOL_ABORTED },
|
||||
additionalContexts: [{ source: { kind: 'plugin', plugin: 'child' } }],
|
||||
})
|
||||
})
|
||||
@@ -713,6 +825,94 @@ describe('ToolRegistry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
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<undefined>()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'tool-failure',
|
||||
execute(_args, exec) {
|
||||
entered.resolve(undefined)
|
||||
return new Promise<never[]>((_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<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
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<undefined>()
|
||||
@@ -724,9 +924,9 @@ describe('ToolRegistry', () => {
|
||||
execute(_args, exec) {
|
||||
bodySignal = exec.signal
|
||||
entered.resolve(undefined)
|
||||
if (exec.signal?.aborted) return Promise.resolve([])
|
||||
if (exec.signal.aborted) return Promise.resolve([])
|
||||
return new Promise((resolve) => {
|
||||
exec.signal?.addEventListener('abort', () => { resolve([]) }, { once: true })
|
||||
exec.signal.addEventListener('abort', () => { resolve([]) }, { once: true })
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -736,8 +936,7 @@ describe('ToolRegistry', () => {
|
||||
try {
|
||||
return await next()
|
||||
} finally {
|
||||
if (upstream === undefined) delete exec.signal
|
||||
else exec.signal = upstream
|
||||
exec.signal = upstream
|
||||
}
|
||||
})
|
||||
|
||||
@@ -758,30 +957,29 @@ describe('ToolRegistry', () => {
|
||||
expect(replacement.signal.aborted).toBe(false)
|
||||
})
|
||||
|
||||
it('restores a removed caller signal for dispatch', async () => {
|
||||
it('restores the required caller signal after around dispatch', async () => {
|
||||
const ctx = await setup()
|
||||
let bodySignal: AbortSignal | undefined
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'signal-probe',
|
||||
async execute(_args, exec) { bodySignal = exec.signal; return [] },
|
||||
})
|
||||
let postSignal: AbortSignal | undefined
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
const upstream = exec.signal
|
||||
delete exec.signal
|
||||
exec.signal = new AbortController().signal
|
||||
try {
|
||||
return await next()
|
||||
} finally {
|
||||
if (upstream !== undefined) exec.signal = upstream
|
||||
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: 'signal-probe', arguments: {}, signal: controller.signal,
|
||||
callId: CallId('restored-signal'), name: 'echo', arguments: {}, signal: controller.signal,
|
||||
})
|
||||
|
||||
expect(bodySignal).toBe(controller.signal)
|
||||
expect(postSignal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('waits for an uncooperative started body before returning ABORTED', async () => {
|
||||
@@ -820,25 +1018,75 @@ describe('ToolRegistry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('lets an already-aborted entry signal reach the body for domain-specific cleanup', async () => {
|
||||
it('materializes a pre-aborted call and publishes one result without entering pipeline phases', async () => {
|
||||
const ctx = await setup()
|
||||
let dispatched = 0
|
||||
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(_args, exec) {
|
||||
dispatched += 1
|
||||
expect(exec.signal?.aborted).toBe(true)
|
||||
throw new HarnessError('domain cleanup completed', 'DOMAIN_ABORTED')
|
||||
},
|
||||
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', arguments: {}, signal: AbortSignal.abort(),
|
||||
callId: CallId('pre-aborted'),
|
||||
name: 'domain-abort',
|
||||
get arguments() { argumentReads += 1; return callerArguments },
|
||||
signal: callerSignal,
|
||||
})
|
||||
|
||||
expect(dispatched).toBe(1)
|
||||
expect(result.error).toEqual({ name: 'HarnessError', code: 'DOMAIN_ABORTED' })
|
||||
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 () => {
|
||||
@@ -847,12 +1095,12 @@ describe('ToolRegistry', () => {
|
||||
|
||||
let entered = false
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'deny', reason: 'nope' }))
|
||||
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
||||
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
||||
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
|
||||
@@ -867,7 +1115,7 @@ describe('ToolRegistry', () => {
|
||||
})
|
||||
|
||||
let seen: { isError: boolean; error?: unknown } | undefined
|
||||
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
||||
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
||||
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.
|
||||
@@ -875,7 +1123,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' })
|
||||
@@ -890,13 +1138,13 @@ describe('ToolRegistry', () => {
|
||||
})
|
||||
|
||||
let postSaw: boolean | undefined
|
||||
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => next())
|
||||
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => 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' })
|
||||
@@ -916,7 +1164,7 @@ describe('ToolRegistry', () => {
|
||||
|
||||
const upstream = new AbortController().signal
|
||||
const replacement = new AbortController().signal
|
||||
ctx.on('tools/execute', async (exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
||||
ctx.on('tools/execute', async (exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
|
||||
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).
|
||||
@@ -939,10 +1187,10 @@ describe('ToolRegistry', () => {
|
||||
async execute() { dispatched = true; return [] },
|
||||
})
|
||||
|
||||
ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
|
||||
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
|
||||
({ 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' })
|
||||
})
|
||||
@@ -960,6 +1208,7 @@ describe('ToolRegistry', () => {
|
||||
}))
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('around-context'), name: 'echo', arguments: {},
|
||||
})
|
||||
expect(result.additionalContexts).toEqual([{
|
||||
@@ -973,7 +1222,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,
|
||||
@@ -987,7 +1236,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' }],
|
||||
@@ -1002,7 +1251,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' }],
|
||||
@@ -1017,7 +1266,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,
|
||||
@@ -1204,6 +1453,7 @@ describe('defineTool / schema DSL', () => {
|
||||
}])
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('c1'),
|
||||
name: 'typed-echo',
|
||||
arguments: { text: 'hello', uppercase: true },
|
||||
@@ -1258,6 +1508,7 @@ describe('defineTool / schema DSL', () => {
|
||||
|
||||
// Execution round-trip
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('c1'),
|
||||
name: 'roundtrip',
|
||||
arguments: { req: 'hello' },
|
||||
@@ -1290,6 +1541,7 @@ describe('defineTool / schema DSL', () => {
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('c1'),
|
||||
name: 'raw-tool',
|
||||
arguments: { path: '/tmp' },
|
||||
@@ -1463,7 +1715,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' })
|
||||
})
|
||||
@@ -1478,7 +1730,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' })
|
||||
})
|
||||
@@ -1493,7 +1745,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')
|
||||
@@ -1630,7 +1882,7 @@ describe('defineTool validation (the runtime-validation RFC, 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"',
|
||||
@@ -1647,7 +1899,7 @@ describe('defineTool validation (the runtime-validation RFC, 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 })
|
||||
})
|
||||
|
||||
@@ -1670,7 +1922,7 @@ describe('defineTool validation (the runtime-validation RFC, 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' })
|
||||
})
|
||||
@@ -1685,7 +1937,7 @@ describe('defineTool validation (the runtime-validation RFC, 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' })
|
||||
@@ -1700,7 +1952,7 @@ describe('defineTool validation (the runtime-validation RFC, 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' })
|
||||
@@ -1719,7 +1971,7 @@ describe('defineTool validation (the runtime-validation RFC, 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)
|
||||
})
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-a
|
||||
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
declare module '@deepseek-ai/dsh-tasks' {
|
||||
interface TaskKindMap {
|
||||
probe: 'probe'
|
||||
@@ -264,6 +266,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',
|
||||
@@ -335,11 +338,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()
|
||||
})
|
||||
|
||||
@@ -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<NonNullable<cliDemo.Config['skills']>> {
|
||||
@@ -122,6 +124,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',
|
||||
@@ -139,11 +142,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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,8 @@ import {
|
||||
toWorkdirRelative,
|
||||
} from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/** A successful run result over the given stdout; overrides script the failure shapes. */
|
||||
function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunResult {
|
||||
return {
|
||||
@@ -55,6 +57,7 @@ class FakeBash extends BashExecutor {
|
||||
requests: BashExecRequest[] = []
|
||||
specs: BashExecSpec[] = []
|
||||
startCalls = 0
|
||||
forwardSignal = true
|
||||
handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
|
||||
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
@@ -64,7 +67,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,
|
||||
}
|
||||
}
|
||||
@@ -117,6 +120,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,
|
||||
@@ -248,16 +252,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 () => {
|
||||
@@ -269,20 +270,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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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<ReturnType<Context['plugin']>>
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ import { STREAM_MIN_SIZE } from '../src/read.ts'
|
||||
import { formatReadOutput } from '../src/read-render.ts'
|
||||
import type { FileReadOutcome } from '../src/read-render.ts'
|
||||
|
||||
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<string, string>()
|
||||
@@ -91,6 +93,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,
|
||||
@@ -110,11 +113,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' })
|
||||
})
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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<PreToolDecision> => {
|
||||
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<PostToolDecision> => {
|
||||
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] } : {} }
|
||||
|
||||
@@ -14,6 +14,8 @@ import { 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. */
|
||||
|
||||
@@ -145,7 +147,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)
|
||||
})
|
||||
|
||||
@@ -203,7 +203,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<PreToolDecision> => {
|
||||
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 +213,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
|
||||
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] } : {} }
|
||||
|
||||
@@ -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 }
|
||||
@@ -455,7 +457,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)
|
||||
})
|
||||
@@ -466,7 +468,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)
|
||||
})
|
||||
|
||||
@@ -165,7 +165,7 @@ function createExecutor(
|
||||
{ name: rawName, arguments: argsObj },
|
||||
undefined,
|
||||
{
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
signal: exec.signal,
|
||||
timeout: opts.toolCallTimeoutMs,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
@@ -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<string> {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,7 +210,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()
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
STRUCTURED_OUTPUT_TOOL,
|
||||
} from '../src/structured.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
interface CodeRunRequestLike {
|
||||
@@ -640,6 +642,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 },
|
||||
@@ -652,6 +655,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 },
|
||||
@@ -684,6 +688,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' },
|
||||
@@ -692,6 +697,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 },
|
||||
@@ -722,6 +728,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' },
|
||||
@@ -730,6 +737,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 },
|
||||
@@ -764,6 +772,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 },
|
||||
@@ -774,6 +783,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 },
|
||||
|
||||
@@ -270,9 +270,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',
|
||||
@@ -300,7 +297,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)
|
||||
|
||||
@@ -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'
|
||||
@@ -14,6 +14,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
|
||||
@@ -44,6 +46,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,
|
||||
@@ -99,11 +102,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 },
|
||||
@@ -138,8 +143,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')
|
||||
})
|
||||
@@ -417,7 +422,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)
|
||||
@@ -437,8 +442,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 () => {
|
||||
@@ -639,6 +645,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 },
|
||||
@@ -648,6 +655,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' },
|
||||
@@ -663,14 +671,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 () => {
|
||||
@@ -685,6 +694,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 },
|
||||
@@ -692,6 +702,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 },
|
||||
@@ -714,18 +725,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 },
|
||||
@@ -763,19 +777,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]')
|
||||
})
|
||||
|
||||
@@ -868,6 +882,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 },
|
||||
|
||||
@@ -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<Agent, () => void>()
|
||||
|
||||
async function setup(config: ToolTasks.Config = {}) {
|
||||
@@ -62,7 +64,7 @@ function producer(overrides: Partial<Omit<TaskStart, 'run'> & 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<never> {
|
||||
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<PostToolDecision> => { 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)
|
||||
@@ -128,14 +120,26 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
|
||||
|
||||
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<undefined>()
|
||||
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(true)
|
||||
expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' })
|
||||
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED })
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: tool call aborted' })
|
||||
})
|
||||
|
||||
@@ -146,9 +150,9 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'slow-cleanup', description: 'settles after abort cleanup', parameters: {}, timeoutMs: 100,
|
||||
async execute(_args, exec) {
|
||||
if (!exec.signal?.aborted) {
|
||||
if (!exec.signal.aborted) {
|
||||
await new Promise<undefined>((resolve) => {
|
||||
exec.signal?.addEventListener('abort', () => { resolve(undefined) }, { once: true })
|
||||
exec.signal.addEventListener('abort', () => { resolve(undefined) }, { once: true })
|
||||
})
|
||||
}
|
||||
sawAbort.resolve(undefined)
|
||||
@@ -217,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<Context['plugin']>[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()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -63,7 +63,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) }]
|
||||
},
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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<ToolResult> {
|
||||
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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -174,17 +174,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
|
||||
@@ -196,7 +193,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()
|
||||
}
|
||||
|
||||
@@ -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<ToolExecutionResult> {
|
||||
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 () => {
|
||||
|
||||
@@ -114,6 +114,7 @@ export const LINK_MAP: Record<string, string> = {
|
||||
PreToolDecision: 'tools.md',
|
||||
ToolDefinition: 'tools.md',
|
||||
ToolExecution: 'tools.md',
|
||||
ToolDispatchExecution: 'tools.md',
|
||||
ToolExecutionInput: 'tools.md',
|
||||
ToolExecutionMode: 'tools.md',
|
||||
ToolExecutionResult: 'tools.md',
|
||||
|
||||
@@ -78,6 +78,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" },
|
||||
|
||||
@@ -794,7 +794,7 @@ Emitted when any prompt provider changes. This registry notification is unfilter
|
||||
|
||||
A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L122)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L123)
|
||||
|
||||
### tools/execute
|
||||
|
||||
@@ -811,7 +811,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
|
||||
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -829,7 +829,8 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
|
||||
* 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 `ABORTED`.
|
||||
* 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.
|
||||
@@ -838,12 +839,12 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
|
||||
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
||||
```
|
||||
|
||||
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 `ABORTED`. 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.
|
||||
|
||||
- `exec` — the call that just ran (name, parsed arguments, caller agent).
|
||||
- `result` — the dispatch outcome a listener may accept, replace, or block.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L104)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L105)
|
||||
|
||||
### tools/pre-execute
|
||||
|
||||
@@ -888,7 +889,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
|
||||
- `exec` — the execution object that traversed the pipeline.
|
||||
- `result` — a deep-frozen snapshot of the final returned result.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L112)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L113)
|
||||
|
||||
## workflow/*
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L467)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L493)
|
||||
|
||||
### ctx.tools.register(definition)
|
||||
|
||||
@@ -26,7 +26,7 @@ Register globally or in the calling agent scope. Scoped tools shadow globals; du
|
||||
|
||||
**Returns** the exact disposer that unregisters the tool.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L569)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L595)
|
||||
|
||||
### ctx.tools.restrict(filter)
|
||||
|
||||
@@ -47,7 +47,7 @@ Restrict global tools for the calling agent scope. Empty filters, unknown names,
|
||||
|
||||
**Returns** the exact disposer that lifts this restriction.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L609)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L635)
|
||||
|
||||
### ctx.tools.guard(guard)
|
||||
|
||||
@@ -71,7 +71,7 @@ Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A
|
||||
|
||||
**Returns** the exact disposer that unregisters the guard.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L660)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L686)
|
||||
|
||||
### ctx.tools.get(name, scope?)
|
||||
|
||||
@@ -95,7 +95,7 @@ Look up a tool as one scope sees it (scoped shadows global; a restricted-away gl
|
||||
|
||||
**Returns** the definition the scope resolves, or undefined when none is visible.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L762)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L788)
|
||||
|
||||
### ctx.tools.schemas(scope?)
|
||||
|
||||
@@ -115,7 +115,7 @@ Project visible definitions onto the allowlisted model-facing schema fields, exc
|
||||
|
||||
**Returns** one deep-cloned schema per visible tool.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L772)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L798)
|
||||
|
||||
### ctx.tools.executionMode(exec)
|
||||
|
||||
@@ -136,7 +136,7 @@ Classify a pending call through the caller's visible tool definition. Only an ex
|
||||
|
||||
**Returns** the fail-closed scheduling mode.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L793)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L819)
|
||||
|
||||
### ctx.tools.execute(exec)
|
||||
|
||||
@@ -147,9 +147,9 @@ Classify a pending call through the caller's visible tool definition. Only an ex
|
||||
* results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
|
||||
* the same lossless, frozen snapshot final observers receive. Cancellation
|
||||
* arriving after entry and before final result materialization skips a
|
||||
* not-yet-started body or replaces a successful pipeline outcome with
|
||||
* `ABORTED`; already-started work is still drained and may retain a
|
||||
* tool-owned structured error.
|
||||
* 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.
|
||||
@@ -157,10 +157,10 @@ Classify a pending call through the caller's visible tool definition. Only an ex
|
||||
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
|
||||
```
|
||||
|
||||
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. Cancellation arriving after entry and before final result materialization skips a not-yet-started body or replaces a successful pipeline outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.
|
||||
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. 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.
|
||||
|
||||
- `exec` — the typed same-process call input. The registry assigns its correlation token before policy begins.
|
||||
|
||||
**Returns** the materialized final result.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L817)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L843)
|
||||
Reference in New Issue
Block a user