From 2bc4e05a089ca25d6187649f8a997803a1bf5395 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:05:54 +0800 Subject: [PATCH] fix(tools): enforce cooperative cancellation --- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 22 +- docs/cordis-catalog/services.md | 7 +- docs/core-data-structures/tools.md | 17 +- docs/event-producer-consumer.md | 10 +- .../cordis-inspect-jsdoc/session.jsonl | 4 +- .../cordis-inspect-jsdoc/stdout.golden.jsonl | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 6 +- .../core/agent-loop/tests/tool-calls.spec.ts | 12 +- packages/core/tools/README.md | 14 +- packages/core/tools/src/index.ts | 176 ++++++++-- packages/core/tools/tests/code-mode.spec.ts | 7 +- packages/core/tools/tests/tools.spec.ts | 302 +++++++++++++++++- .../tests/timeout-policy.spec.ts | 40 ++- website/zh-CN/api/harness/events.md | 22 +- website/zh-CN/api/harness/tools.md | 23 +- 16 files changed, 583 insertions(+), 85 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c3b185d13e..cc293cbde7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1222,7 +1222,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:382`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:397`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f522790fd0..acb2eccb4b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -654,17 +654,19 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:116`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:120`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall -Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. +Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. The registry re-fuses the original caller signal before the body, so replacement cannot detach caller cancellation; wrappers must still restore their signal and reach quiescence. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog /** * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns * a normalized result; wrappers may change only `exec.signal`, while call - * identity remains immutable. + * identity remains immutable. The registry re-fuses the original caller + * signal before the body, so replacement cannot detach caller cancellation; + * wrappers must still restore their signal and reach quiescence. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). * @mode waterfall @@ -674,7 +676,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor 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:89`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:93`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall @@ -694,16 +696,18 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:98`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:102`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall -Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. +Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Async gates must observe `exec.signal`; the registry rechecks cancellation after they settle but never abandons their promise. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog /** * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing - * approval support turns `ask` into denial. + * approval support turns `ask` into denial. Async gates must observe + * `exec.signal`; the registry rechecks cancellation after they settle but + * never abandons their promise. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall @@ -713,7 +717,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:80`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:82`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit @@ -732,7 +736,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:106`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:110`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 267c2f0caa..a0c7c42d8d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1174,7 +1174,10 @@ executionMode(exec: ToolExecutionInput): ToolExecutionMode * Execute through pre-policy, guards, around-dispatch, post-policy, and final * notification. Tool and listener failures resolve as materialized error * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is - * the same lossless, frozen snapshot final observers receive. + * the same lossless, frozen snapshot final observers receive. Cancellation + * arriving after entry skips a not-yet-started body or replaces a successful + * dispatch 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. @@ -1184,7 +1187,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:438`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:465`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 7cc0910d90..c20e3ac0d7 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -11,6 +11,15 @@ A `ToolSchema` (the model-facing fields) plus the `execute` function, host-only ```ts type-equiv /** A registered tool: its schema plus the execution function. */ interface ToolDefinition extends ToolSchema { + /** + * Run one accepted call. Async work must observe or forward `exec.signal` and + * settle only after its owned work reaches quiescence. The registry preserves + * caller cancellation through around-dispatch signal replacement and does + * not abandon this promise, but it cannot hard-kill same-process code. + * @param args - losslessly snapshotted, frozen model arguments. + * @param exec - execution identity, cancellation signal, and context deferral. + * @returns model-facing content plus optional private presentation metadata. + */ execute(args: unknown, exec: ToolRunContext): Promise /** * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. @@ -204,8 +213,10 @@ type ToolExecutionMode = * One pending tool call inside the registry pipeline. Parsed arguments cross * one lossless-JSON materialization boundary before policy and are deep-frozen; * call identity and the registry-assigned {@link token} are readonly. An - * around-dispatch wrapper may set, replace, or remove `signal`. The registry - * freezes the complete object before `tools/result` observers run. + * 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. */ interface ToolExecution extends ToolExecutionInput { /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ @@ -213,7 +224,7 @@ interface ToolExecution extends ToolExecutionInput { } ``` -`ToolExecutionToken` is an opaque runtime `Symbol` used only for identity comparison. Before policy, `execute()` materializes and freezes arguments, rejects non-JSON input, and assigns the token. Identity fields and the optional parent token remain readonly; only `signal` may change around dispatch. Final observers receive the frozen execution identity. +`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. A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 78f878106c..8abb26814e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,11 +36,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:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../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:98`](../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:80`](../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:106`](../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/change` | `emit` | [`packages/core/tools/src/index.ts:120`](../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:102`](../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:110`](../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`) | - | diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 94ea8a8fec..37a63a33c3 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -10,7 +10,7 @@ {"type":"assistant/chunk","seq":8,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":11,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export type ContextEnvelope = 'context' | 'raw';\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry skips a not-yet-started body or replaces a successful\n * dispatch outcome with `ABORTED`; already-started work is still drained and\n * may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export type ContextEnvelope = 'context' | 'raw';\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -20,7 +20,7 @@ {"type":"assistant/chunk","seq":18,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"inspect-tools-event","name":"cordis_inspect","arguments":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} {"type":"tool/call","seq":20,"time":1784449176734,"data":{"turn":1,"step":2,"callId":"inspect-tools-event","name":"cordis_inspect","arguments":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}} -{"type":"tool/result","seq":21,"time":1784449176734,"data":{"turn":1,"step":2,"callId":"inspect-tools-event","content":[{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}],"isError":false},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"tool/result","seq":21,"time":1784449176734,"data":{"turn":1,"step":2,"callId":"inspect-tools-event","content":[{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}],"isError":false},"sourceEventSeqs":[20],"surfaceOp":"append"} {"type":"step/end","seq":22,"time":1784449176734,"data":{"turn":1,"step":2}} {"type":"step/start","seq":23,"time":1784449176735,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":24,"time":1784449176735,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl index 321c5499a2..46545ce422 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl @@ -1,8 +1,8 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export type ContextEnvelope = 'context' | 'raw';\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry skips a not-yet-started body or replaces a successful\n * dispatch outcome with `ABORTED`; already-started work is still drained and\n * may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export type ContextEnvelope = 'context' | 'raw';\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 4813f91e1b..178132feb6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -552,7 +552,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async execute(exec: ToolExecutionInput): Promise', - jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */', + jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry skips a not-yet-started body or replaces a successful\n * dispatch outcome with `ABORTED`; already-started work is still drained and\n * 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 */', }, ], }, @@ -820,7 +820,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'tools/execute', mode: 'waterfall', signature: '\'tools/execute\'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise', - jsDoc: '/**\n * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns\n * a normalized result; wrappers may change only `exec.signal`, while call\n * identity remains immutable.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).\n * @mode waterfall\n */', + 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.', }, { @@ -834,7 +834,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'tools/pre-execute', mode: 'waterfall', signature: '\'tools/pre-execute\'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise', - jsDoc: '/**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */', + jsDoc: '/**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial. Async gates must observe\n * `exec.signal`; the registry rechecks cancellation after they settle but\n * never abandons their promise.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */', summary: 'Allow, deny, or ask before dispatch.', }, { diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 84c2fb34eb..1b5cdf1c21 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -473,7 +473,7 @@ describe('tool-call scheduler: abort handling', () => { expect(events(agent).filter(e => e.type === 'tool/result')).toEqual([]) }) - it('stops starting siblings when abort fires during ordered pre-execute', async () => { + it('skips dispatch and stops starting siblings when abort fires during ordered pre-execute', async () => { const adapter = new MockAdapter([ multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), textResponse('should never be requested'), @@ -490,16 +490,14 @@ describe('tool-call scheduler: abort handling', () => { }) agent.send([{ type: 'text', text: 'go' }]) - await until(() => gated.started.length === 1) - await new Promise(r => setTimeout(r, 5)) - expect(gated.started).toEqual(['1']) - gated.release('1') await waitForIdle(ctx, agent) + expect(gated.started).toEqual([]) expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) .toEqual([CallId('c1')]) - expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) - .toEqual([CallId('c1')]) + const results = events(agent).filter(e => e.type === 'tool/result') + expect(results.map(e => e.data.callId)).toEqual([CallId('c1')]) + expect(results[0]?.data.error).toEqual({ name: 'AbortError', code: 'ABORTED' }) }) it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => { diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index bfd75fdcb6..9369407050 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -20,24 +20,28 @@ tools: - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. -- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body; around wrappers may replace only `signal`. +- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body. Around wrappers may replace only `signal`; the registry re-fuses the original caller signal immediately before the body. - `ctx.tools.executionMode(exec)` returns `parallel` only when the visible definition's `isConcurrencySafe(exec.arguments)` classifier returns exactly `true`; unknown, hidden, undeclared, invalid, or throwing classifications are exclusive. ### Injected services `SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`. The approval seam is consumed opportunistically instead (`ctx.get('approval')`, no static inject): a deployment without it keeps the ask→deny degrade, and the registry stays active either way. +### Cancellation + +Cancellation is cooperative and quiescent. A cancellation that arrives after registry entry is rechecked after pre-policy, approval, and around-dispatch waits, so a body cannot start late; if the body has started, the registry preserves the caller signal through wrapper replacement, awaits settlement, and replaces a successful dispatch outcome with structured `ABORTED`. 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. See the [quiescent-disposal rule](../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it) and [timeout ownership decision](../../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md). + ### Live events The live registry pipeline has three transformable waterfalls followed by the observe-only `tools/result` boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards. ### Key types -- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. +- `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. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. -- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. -- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately. +- `ToolExecution` — the 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. +- `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. - `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. @@ -74,7 +78,7 @@ ctx.tools.register(defineTool({ }, async execute(args, exec) { // args is typed: { path: string; offset?: number; limit?: number } - const text = await readFile(args.path, 'utf8') + const text = await readFile(args.path, { encoding: 'utf8', signal: exec.signal }) return [{ type: 'text', text }] }, })) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 5fc045ace2..3dd330a3e9 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -72,7 +72,9 @@ declare module 'cordis' { interface Events { /** * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing - * approval support turns `ask` into denial. + * approval support turns `ask` into denial. Async gates must observe + * `exec.signal`; the registry rechecks cancellation after they settle but + * never abandons their promise. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall @@ -81,7 +83,9 @@ declare module 'cordis' { /** * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns * a normalized result; wrappers may change only `exec.signal`, while call - * identity remains immutable. + * identity remains immutable. The registry re-fuses the original caller + * signal before the body, so replacement cannot detach caller cancellation; + * wrappers must still restore their signal and reach quiescence. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). * @mode waterfall @@ -122,6 +126,15 @@ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta /** A registered tool: its schema plus the execution function. */ export interface ToolDefinition extends ToolSchema { + /** + * Run one accepted call. Async work must observe or forward `exec.signal` and + * settle only after its owned work reaches quiescence. The registry preserves + * caller cancellation through around-dispatch signal replacement and does + * not abandon this promise, but it cannot hard-kill same-process code. + * @param args - losslessly snapshotted, frozen model arguments. + * @param exec - execution identity, cancellation signal, and context deferral. + * @returns model-facing content plus optional private presentation metadata. + */ execute(args: unknown, exec: ToolRunContext): Promise /** * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. @@ -218,8 +231,10 @@ export type ToolExecutionMode = * One pending tool call inside the registry pipeline. Parsed arguments cross * one lossless-JSON materialization boundary before policy and are deep-frozen; * call identity and the registry-assigned {@link token} are readonly. An - * around-dispatch wrapper may set, replace, or remove `signal`. The registry - * freezes the complete object before `tools/result` observers run. + * 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. */ export interface ToolExecution extends ToolExecutionInput { /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ @@ -431,6 +446,18 @@ interface ToolGuardRegistration { guard: ToolGuard } +/** Caller cancellation captured before around-dispatch wrappers may replace the public signal slot. */ +interface ToolCancellationState { + readonly callerSignal: AbortSignal | undefined + readonly abortedAtEntry: boolean +} + +/** One dispatch-scoped fused signal plus listener cleanup after the body settles. */ +interface FusedToolSignal { + readonly signal: AbortSignal | undefined + dispose(): void +} + /** * Tool registry and execution pipeline. Scoped registrations shadow globals; * one visibility resolver feeds presentation, lookup, and dispatch. @@ -452,6 +479,8 @@ export class ToolRegistry extends Service { /** Context deferred by a running tool body, keyed by its scheduler-owned execution. */ private deferredContexts = new WeakMap() + /** Original caller cancellation, kept outside the wrapper-mutable execution object. */ + private cancellationStates = new WeakMap() private global = new Map() private scoped = new Map>() /** Compiled restriction filters, per scope (see {@link restrict}). */ @@ -774,7 +803,10 @@ export class ToolRegistry extends Service { * Execute through pre-policy, guards, around-dispatch, post-policy, and final * notification. Tool and listener failures resolve as materialized error * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is - * the same lossless, frozen snapshot final observers receive. + * the same lossless, frozen snapshot final observers receive. Cancellation + * arriving after entry skips a not-yet-started body or replaces a successful + * dispatch 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. @@ -827,6 +859,10 @@ export class ToolRegistry extends Service { } const execution: ToolRunContext = { ...base, arguments: deepFreeze(detached) } this.deferredContexts.set(execution, deferredContexts) + this.cancellationStates.set(execution, { + callerSignal: signal, + abortedAtEntry: signal?.aborted === true, + }) return { kind: 'ready', exec: execution } } catch (error: unknown) { const execution: ToolRunContext = { ...base, arguments: undefined } @@ -858,6 +894,9 @@ export class ToolRegistry extends Service { () => Promise.resolve({ 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 denialReason = decision.kind === 'allow' ? this.guardReason(exec) : decision.reason @@ -873,7 +912,60 @@ export class ToolRegistry extends Service { } return await next({ kind: 'dispatch', exec }) } catch (error: unknown) { - return next({ kind: 'final-result', exec, result: toolErrorResult(error) }) + return this.callerCancelledAfterEntry(exec) + ? await next({ kind: 'post-result', exec, result: toolAbortedResult() }) + : 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 { + 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 + } + + /** + * Dispatch the registered body with the original caller signal fused back + * into any around-wrapper replacement. Cancellation never abandons the body: + * a started promise reaches quiescence before its outcome becomes `ABORTED`. + */ + private async dispatchToolBody(exec: ToolRunContext): Promise { + const state = this.cancellationStates.get(exec) + /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ + if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') + const wrapperSignal = exec.signal + const fused = fuseToolSignals(state.callerSignal, wrapperSignal) + const signal = fused.signal + const abortedBeforeBody = isAborted(signal) + + if (!state.abortedAtEntry && abortedBeforeBody) { + fused.dispose() + return toolAbortedResult() + } + if (signal === undefined) delete exec.signal + else exec.signal = signal + try { + const tool = this.get(exec.name, exec.agent) + if (!tool) throw new ToolNotFoundError(exec.name) + const returned = await tool.execute(exec.arguments, exec) + const content = Array.isArray(returned) ? returned : returned.content + const meta = Array.isArray(returned) ? undefined : returned.meta + const result: ToolExecutionResult = { + content, + isError: false, + ...meta !== undefined ? { meta } : {}, + } + return !abortedBeforeBody && isAborted(signal) + ? toolAbortedResult(result) + : result + } catch (error: unknown) { + return toolErrorResult(error) + } finally { + fused.dispose() + if (wrapperSignal === undefined) delete exec.signal + else exec.signal = wrapperSignal } } @@ -889,18 +981,7 @@ export class ToolRegistry extends Service { const carrier = scopeTarget(this, exec.agent) const result = await this.ctx.waterfall( carrier, 'tools/execute', exec, - async (): Promise => { - try { - const tool = this.get(exec.name, exec.agent) - if (!tool) throw new ToolNotFoundError(exec.name) - const returned = await tool.execute(exec.arguments, exec) - const content = Array.isArray(returned) ? returned : returned.content - const meta = Array.isArray(returned) ? undefined : returned.meta - return { content, isError: false, ...meta !== undefined ? { meta } : {} } - } catch (error: unknown) { - return toolErrorResult(error) - } - }, + () => this.dispatchToolBody(exec), ) const deferredContexts = this.deferredContexts.get(exec) /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */ @@ -914,7 +995,12 @@ export class ToolRegistry extends Service { ...result.additionalContexts ?? [], ], } - return { kind: 'post-result', result: resultWithDeferredContexts } + return { + kind: 'post-result', + result: this.callerCancelledAfterEntry(exec) && !resultWithDeferredContexts.isError + ? toolAbortedResult(resultWithDeferredContexts) + : resultWithDeferredContexts, + } } catch (error: unknown) { return { kind: 'final-result', result: toolErrorResult(error) } } @@ -1069,4 +1155,56 @@ 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 +} + +/** + * 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() {} } + + const controller = new AbortController() + let listening = false + const dispose = (): void => { + if (!listening) return + listening = false + caller.removeEventListener('abort', abortFromCaller) + wrapper.removeEventListener('abort', abortFromWrapper) + } + const abortFrom = (source: AbortSignal): void => { + const reason: unknown = source.reason + controller.abort(reason) + dispose() + } + const abortFromCaller = (): void => { abortFrom(caller) } + const abortFromWrapper = (): void => { abortFrom(wrapper) } + + if (wrapper.aborted) abortFromWrapper() + else if (caller.aborted) abortFromCaller() + else { + listening = true + caller.addEventListener('abort', abortFromCaller, { once: true }) + wrapper.addEventListener('abort', abortFromWrapper, { once: true }) + } + return { signal: controller.signal, dispose } +} + +/** Canonical result when cancellation prevents dispatch or supersedes a successful outcome. */ +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' }, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + } +} + export default ToolRegistry diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index bbc19d5d75..64b58b336f 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -857,7 +857,7 @@ describe('the run_code dispatch bridge', () => { expect(calls).toEqual([]) }) - it('rejects a binding invoked after the run is over without dispatching it', async () => { + it('reports cancellation after rejecting a late binding without dispatching it', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const calls = registerEcho(ctx) const controller = new AbortController() @@ -868,8 +868,9 @@ describe('the run_code dispatch bridge', () => { return { logs: [], value: message } } const result = await runCode(ctx, 'program', { signal: controller.signal }) - expect(result.isError).toBe(false) - expect((result.content[0] as { text: string }).text).toContain('not dispatched') + expect(result.isError).toBe(true) + expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' }) + expect((result.content[0] as { text: string }).text).toBe('Error: tool call aborted') expect(calls).toEqual([]) }) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 3de73c3326..98f1f3650b 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -499,6 +499,302 @@ describe('ToolRegistry', () => { expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post']) }) + it('skips dispatch when caller cancellation arrives while pre-execute awaits', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'must-not-run', + async execute() { dispatched += 1; return [] }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/pre-execute', async (_exec, next) => { + entered.resolve(undefined) + await release.promise + return await next() + }) + + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-in-pre'), name: 'must-not-run', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('cancelled in policy') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: tool call aborted' }], + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + }) + expect(dispatched).toBe(0) + }) + + it('materializes ABORTED when an async pre-execute gate throws after cancellation', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'must-not-run', + async execute() { dispatched += 1; return [] }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/pre-execute', async () => { + entered.resolve(undefined) + await release.promise + throw new Error('gate interrupted') + }) + + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-pre-error'), name: 'must-not-run', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('cancelled in policy') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + }) + expect(dispatched).toBe(0) + }) + + it('rechecks caller cancellation after an async around-dispatch wrapper delegates', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'must-not-run', + async execute() { dispatched += 1; return [] }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const replacement = new AbortController() + ctx.on('tools/execute', async (exec, next) => { + const upstream = exec.signal + exec.signal = replacement.signal + try { + entered.resolve(undefined) + await release.promise + return await next() + } finally { + if (upstream === undefined) delete exec.signal + else exec.signal = upstream + } + }) + + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-in-around'), name: 'must-not-run', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('cancelled in wrapper') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + }) + expect(dispatched).toBe(0) + }) + + it('skips dispatch when an around wrapper supplies an already-aborted signal', async () => { + const ctx = await setup() + let dispatched = 0 + ctx.tools.register({ + ...echoTool, + name: 'must-not-run', + async execute() { dispatched += 1; return [] }, + }) + const replacement = AbortSignal.abort('wrapper cancelled') + ctx.on('tools/execute', async (exec, next) => { + const upstream = exec.signal + exec.signal = replacement + try { + return await next() + } finally { + if (upstream === undefined) delete exec.signal + else exec.signal = upstream + } + }) + + const controller = new AbortController() + const result = await ctx.tools.execute({ + callId: CallId('cancelled-wrapper'), name: 'must-not-run', arguments: {}, signal: controller.signal, + }) + + expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' }) + expect(dispatched).toBe(0) + }) + + it('replaces a late wrapper success with ABORTED and preserves deferred contexts', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'completed-before-wrapper', + async execute(_args, exec) { + exec.deferContext({ + content: [{ type: 'text', text: 'completed child work' }], + source: { kind: 'plugin', plugin: 'child' }, + }) + return [{ type: 'text', text: 'body complete' }] + }, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/execute', async (_exec, next) => { + const result = await next() + entered.resolve(undefined) + await release.promise + return result + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-after-body'), name: 'completed-before-wrapper', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('cancelled while wrapper settled') + release.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + content: [{ type: 'text', text: 'Error: tool call aborted' }], + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + additionalContexts: [{ source: { kind: 'plugin', plugin: 'child' } }], + }) + }) + + it('fuses caller cancellation back into a wrapper replacement for the running body', async () => { + const ctx = await setup() + const entered = Promise.withResolvers() + const replacement = new AbortController() + let bodySignal: AbortSignal | undefined + ctx.tools.register({ + ...echoTool, + name: 'cooperative', + execute(_args, exec) { + bodySignal = exec.signal + entered.resolve(undefined) + if (exec.signal?.aborted) return Promise.resolve([]) + return new Promise((resolve) => { + exec.signal?.addEventListener('abort', () => { resolve([]) }, { once: true }) + }) + }, + }) + ctx.on('tools/execute', async (exec, next) => { + const upstream = exec.signal + exec.signal = replacement.signal + try { + return await next() + } finally { + if (upstream === undefined) delete exec.signal + else exec.signal = upstream + } + }) + + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('cancelled-body'), name: 'cooperative', arguments: {}, signal: controller.signal, + }) + await entered.promise + expect(bodySignal).not.toBe(controller.signal) + expect(bodySignal).not.toBe(replacement.signal) + controller.abort('cancel running body') + + await expect(pending).resolves.toMatchObject({ + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + }) + expect(bodySignal?.aborted).toBe(true) + expect(replacement.signal.aborted).toBe(false) + }) + + it('restores a removed caller signal for 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 [] }, + }) + ctx.on('tools/execute', async (exec, next) => { + const upstream = exec.signal + delete exec.signal + try { + return await next() + } finally { + if (upstream !== undefined) exec.signal = upstream + } + }) + const controller = new AbortController() + + await ctx.tools.execute({ + callId: CallId('restored-signal'), name: 'signal-probe', arguments: {}, signal: controller.signal, + }) + + expect(bodySignal).toBe(controller.signal) + }) + + it('waits for an uncooperative started body before returning ABORTED', async () => { + const ctx = await setup() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.tools.register({ + ...echoTool, + name: 'uncooperative', + execute(_args, exec) { + exec.deferContext({ + content: [{ type: 'text', text: 'nested outcome' }], + source: { kind: 'plugin', plugin: 'nested' }, + }) + entered.resolve(undefined) + return release.promise + }, + }) + const controller = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('drain-body'), name: 'uncooperative', arguments: {}, signal: controller.signal, + }) + await entered.promise + controller.abort('must still drain') + + const state = await Promise.race([ + pending.then(() => 'settled' as const), + Promise.resolve('pending' as const), + ]) + expect(state).toBe('pending') + release.resolve([]) + await expect(pending).resolves.toMatchObject({ + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + additionalContexts: [{ source: { kind: 'plugin', plugin: 'nested' } }], + }) + }) + + it('lets an already-aborted entry signal reach the body for domain-specific cleanup', async () => { + const ctx = await setup() + let dispatched = 0 + 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') + }, + }) + + const result = await ctx.tools.execute({ + callId: CallId('pre-aborted'), name: 'domain-abort', arguments: {}, signal: AbortSignal.abort(), + }) + + expect(dispatched).toBe(1) + expect(result.error).toEqual({ name: 'HarnessError', code: 'DOMAIN_ABORTED' }) + }) + it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => { const ctx = await setup() ctx.tools.register(echoTool) @@ -560,7 +856,7 @@ describe('ToolRegistry', () => { expect(result.content[0]).toMatchObject({ text: 'Error: exploded' }) }) - it('a tools/execute listener can replace exec.signal for the dispatched tool (deadline pattern)', async () => { + it('re-fuses the caller signal with an around-dispatch replacement for the body', async () => { const ctx = await setup() let seenSignal: AbortSignal | undefined ctx.tools.register({ @@ -583,7 +879,9 @@ describe('ToolRegistry', () => { }) await ctx.tools.execute({ callId: CallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream }) - expect(seenSignal).toBe(replacement) // dispatch saw the wrapper's replacement, not the upstream + expect(seenSignal).toBeDefined() + expect(seenSignal).not.toBe(upstream) + expect(seenSignal).not.toBe(replacement) }) it('a tools/execute listener can short-circuit dispatch by returning a result without next()', async () => { diff --git a/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts b/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts index bd06ed6e16..db41d6e1dc 100644 --- a/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts +++ b/packages/timeout/timeout-policy/tests/timeout-policy.spec.ts @@ -126,7 +126,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { expect(result.content[0]).toMatchObject({ text: 'Error: tool call timed out after 100ms' }) }) - it('does NOT replace when the caller aborts first (upstream cancel, not our timeout)', async () => { + it('preserves registry ABORTED when the caller aborts first (upstream cancel, not our timeout)', async () => { const ctx = await setup() ctx.tools.register(cooperativeTool) const upstream = new AbortController() @@ -134,8 +134,42 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { upstream.abort('user cancelled') await vi.advanceTimersByTimeAsync(0) const result = await pending - expect(result.isError).toBe(false) - expect(result.content[0]).toMatchObject({ text: 'stopped cooperatively' }) + expect(result.isError).toBe(true) + expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' }) + expect(result.content[0]).toMatchObject({ text: 'Error: tool call aborted' }) + }) + + it('preserves TOOL_TIMEOUT when the deadline wins before a later caller abort', async () => { + const ctx = await setup() + const sawAbort = Promise.withResolvers() + const releaseCleanup = Promise.withResolvers() + ctx.tools.register(defineTool({ + name: 'slow-cleanup', description: 'settles after abort cleanup', parameters: {}, timeoutMs: 100, + async execute(_args, exec) { + if (!exec.signal?.aborted) { + await new Promise((resolve) => { + exec.signal?.addEventListener('abort', () => { resolve(undefined) }, { once: true }) + }) + } + sawAbort.resolve(undefined) + await releaseCleanup.promise + return [{ type: 'text' as const, text: 'cleanup complete' }] + }, + })) + const upstream = new AbortController() + const pending = ctx.tools.execute({ + callId: CallId('timeout-first'), name: 'slow-cleanup', arguments: {}, signal: upstream.signal, + }) + + await vi.advanceTimersByTimeAsync(100) + await sawAbort.promise + upstream.abort('too late to replace timeout') + releaseCleanup.resolve(undefined) + + await expect(pending).resolves.toMatchObject({ + isError: true, + error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, + }) }) }) diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index fcfa426a81..372e2f761d 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -741,7 +741,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#L116) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L120) ### tools/execute @@ -751,7 +751,9 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai /** * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns * a normalized result; wrappers may change only `exec.signal`, while call - * identity remains immutable. + * identity remains immutable. The registry re-fuses the original caller + * signal before the body, so replacement cannot detach caller cancellation; + * wrappers must still restore their signal and reach quiescence. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). * @mode waterfall @@ -759,11 +761,11 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise ``` -Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. +Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. The registry re-fuses the original caller signal before the body, so replacement cannot detach caller cancellation; wrappers must still restore their signal and reach quiescence. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. - `exec` — the allowed call about to dispatch (name, parsed arguments, caller agent, signal). -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L89) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L93) ### tools/post-execute @@ -786,7 +788,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts - `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#L98) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L102) ### tools/pre-execute @@ -795,7 +797,9 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts ```ts website-api /** * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing - * approval support turns `ask` into denial. + * approval support turns `ask` into denial. Async gates must observe + * `exec.signal`; the registry rechecks cancellation after they settle but + * never abandons their promise. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall @@ -803,11 +807,11 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise ``` -Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. +Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Async gates must observe `exec.signal`; the registry rechecks cancellation after they settle but never abandons their promise. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. - `exec` — the pending call (name, parsed arguments, caller agent). -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L80) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L82) ### tools/result @@ -829,7 +833,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#L106) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L110) ## workflow/* diff --git a/website/zh-CN/api/harness/tools.md b/website/zh-CN/api/harness/tools.md index eb416e8b75..be22b84380 100644 --- a/website/zh-CN/api/harness/tools.md +++ b/website/zh-CN/api/harness/tools.md @@ -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#L438) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L465) ### 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#L538) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L567) ### 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#L578) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L607) ### 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#L629) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L658) ### 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#L731) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L760) ### 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#L741) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L770) ### 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#L762) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L791) ### ctx.tools.execute(exec) @@ -145,7 +145,10 @@ Classify a pending call through the caller's visible tool definition. Only an ex * Execute through pre-policy, guards, around-dispatch, post-policy, and final * notification. Tool and listener failures resolve as materialized error * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is - * the same lossless, frozen snapshot final observers receive. + * the same lossless, frozen snapshot final observers receive. Cancellation + * arriving after entry skips a not-yet-started body or replaces a successful + * dispatch 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. @@ -153,10 +156,10 @@ Classify a pending call through the caller's visible tool definition. Only an ex async execute(exec: ToolExecutionInput): Promise ``` -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. +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 skips a not-yet-started body or replaces a successful dispatch 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#L782) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L814)