feat(tools): require cancellation signal on every invocation

This commit is contained in:
Tianyi Cui
2026-07-19 23:38:54 +08:00
parent a99750f341
commit e8b95c8754
77 changed files with 1129 additions and 446 deletions

View File

@@ -5,7 +5,6 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md
- **Plugin export shape:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
- **Optional services use `ctx.get(name)`.** Reserve `ctx.<name>` for declared injections; the property proxy is topology-sensitive, while strict `ctx.get` reads the global service store ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md).
- **Typed same-process service and plugin calls are contracts, not serialization boundaries.** Prefer readonly borrowed values; materialize or defensively validate only at parser/config, queued, model/tool JSON, durable/file, worker, process, or wire boundaries.
- **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence.
- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).

View File

@@ -366,7 +366,7 @@ export function apply(ctx: Context, config: Config = {}): void {
toolName: 'bash',
callId: exec.callId,
reason: `escalate sandbox to ${mode}: ${justification}`,
...exec.signal ? { signal: exec.signal } : {},
signal: exec.signal,
})
switch (outcome) {
case 'allowed-once': return mode as SandboxMode
@@ -437,8 +437,6 @@ export function apply(ctx: Context, config: Config = {}): void {
if (tasks === undefined) {
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
}
// Reject pre-start cancellation; returned tasks use their own lifecycle.
if (exec.signal?.aborted) throw new Error('command aborted')
// Task preflight finishes before the starter can spawn a process.
const id = tasks.start({
kind: 'bash',
@@ -457,7 +455,7 @@ export function apply(ctx: Context, config: Config = {}): void {
}
const result = await ctx.bash.run(ctx.bash.resolve({
...request,
...exec.signal ? { signal: exec.signal } : {},
signal: exec.signal,
}))
if (result.aborted) throw new Error('command aborted')
return [{ type: 'text', text: renderResult(result, escalationModes) }]

View File

@@ -7,10 +7,13 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { BashEnvRegistry } from '@deepseek-ai/dsh-tool-bash'
const testToolSignal = new AbortController().signal
afterEach(() => vi.unstubAllEnvs())
function execution(sessionId?: string): ToolExecution {
return {
signal: testToolSignal,
token: Symbol('bash-env-test') as ToolExecution['token'],
callId: CallId('bash-env-call'),
name: 'bash',

View File

@@ -7,7 +7,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -21,6 +21,8 @@ import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { processOutcome } from '../src/background.ts'
import { renderProcessRead, renderResult } from '../src/render.ts'
const testToolSignal = new AbortController().signal
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
/** Foreground-only harness: no task runtime (backgrounding fails loud here). */
@@ -67,7 +69,7 @@ function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: un
}
let callCounter = 0
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
}
function text(result: { content: { type: string; text?: string }[] }): string {
@@ -451,7 +453,7 @@ describe('background execution through the task runtime', () => {
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
})
it('a pre-aborted call refuses to start: isError, no process spawned', async () => {
it('a pre-aborted call is skipped before the process starts', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -470,7 +472,8 @@ describe('background execution through the task runtime', () => {
signal: controller.signal,
})
expect(result.isError).toBe(true)
expect(text(result)).toContain('command aborted')
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(text(result)).toBe('Error: tool call aborted before dispatch')
expect((ctx.bash as CountingStartExecutor).starts).toBe(0)
})
@@ -730,7 +733,7 @@ describe('session-cwd routing (per-session workdir)', () => {
it('falls back to the executor default when the agent has no session cwd', async () => {
const ctx = await setup()
// No exec.agent at all → executor uses its config/process.cwd() default.
const result = await ctx.tools.execute({ callId: CallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } })
expect(result.isError).toBe(false)
expect(text(result).trim().length).toBeGreaterThan(0)
})
@@ -1012,6 +1015,7 @@ describe('the model-facing bash tool builds its request from named args only (no
const path = ctx.sessionPersistence.locate(agent.session.header)?.path
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('session-env-fg'),
name: 'bash',
arguments: { command: 'true', description: 'run command' },
@@ -1032,6 +1036,7 @@ describe('the model-facing bash tool builds its request from named args only (no
const path = ctx.sessionPersistence.locate(agent.session.header)?.path
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('session-env-bg'),
name: 'bash',
arguments: {
@@ -1058,6 +1063,7 @@ describe('the model-facing bash tool builds its request from named args only (no
const ambient = process.env.DSH_SESSION_ID
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('session-env-id-only'),
name: 'bash',
arguments: { command: 'true', description: 'run command' },
@@ -1079,6 +1085,7 @@ describe('the model-facing bash tool builds its request from named args only (no
for (const [callId, agent] of [['parent', parent], ['child', child]] as const) {
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`session-env-${callId}`),
name: 'bash',
arguments: { command: 'true', description: 'run command' },
@@ -1109,6 +1116,7 @@ describe('the model-facing bash tool builds its request from named args only (no
// This preserves the request shape; it is not a security boundary because shell syntax can
// already set environment variables or feed stdin.
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('no-forward-1'),
name: 'bash',
arguments: {
@@ -1130,6 +1138,7 @@ describe('the model-facing bash tool builds its request from named args only (no
it('a background bash call likewise carries no trusted-only fields', async () => {
const { ctx, bash } = await setupRecording()
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('no-forward-2'),
name: 'bash',
arguments: {

View File

@@ -501,7 +501,7 @@ export async function dynamicInstructionContext(
{
touchedPath,
includeBaselineScopes: baselineInstructionStates.has(agent.session),
...exec.signal === undefined ? {} : { signal: exec.signal },
signal: exec.signal,
},
)
}

View File

@@ -40,6 +40,8 @@ import {
} from '../src/state.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
const testToolSignal = new AbortController().signal
async function tempRepo(): Promise<string> {
return mkdtemp(join(tmpdir(), 'dsh-workspace-context-'))
}
@@ -797,6 +799,7 @@ describe('workspace context request injection', () => {
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({
signal: testToolSignal,
callId: CallId('no-fs-post-execute'),
name: 'read',
arguments: { file_path: 'pkg/file.txt' },
@@ -832,6 +835,7 @@ describe('workspace context request injection', () => {
const agent = stubAgent(root)
const exec = stubToolExecution({
signal: testToolSignal,
callId: CallId('read-blocked-post-execute'),
name: 'read',
arguments: { file_path: 'pkg/file.txt' },
@@ -977,6 +981,7 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
await write(join(root, 'AGENTS.md'), 'new root rule with more detail')
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
})
@@ -1005,6 +1010,7 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
await rm(join(root, 'AGENTS.md'))
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
})
@@ -1030,6 +1036,7 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-with-shared-global-root'), name: 'read', arguments: { file_path: 'file.txt' }, agent,
})
@@ -1108,6 +1115,25 @@ describe('workspace context request injection', () => {
}
})
it('keeps the direct provider API usable without an operation signal', async () => {
const root = '/virtual/no-signal-repo'
const home = '/virtual/no-signal-home'
const ctx = new Context()
try {
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'optional capability signal' })
const rendered = await loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 }, fs)
expect(rendered?.text).toContain('optional capability signal')
expect(fs.signals).toEqual([])
} finally {
await ctx.fiber.dispose()
}
})
it('rejects a provider-sized instruction file before reading content', async () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
@@ -1687,6 +1713,7 @@ describe('dynamic nested workspace context injection', () => {
const agent = stubAgent(root)
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-nested'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -1747,6 +1774,7 @@ describe('dynamic nested workspace context injection', () => {
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-configured-nested-candidate'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -1775,12 +1803,14 @@ describe('dynamic nested workspace context injection', () => {
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-nested-1'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
agent,
})
const second = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-nested-2'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -1813,10 +1843,12 @@ describe('dynamic nested workspace context injection', () => {
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-before-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContexts(agent, first)
const second = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-with-version-fast-path'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
@@ -1848,14 +1880,17 @@ describe('dynamic nested workspace context injection', () => {
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-before-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContexts(agent, first)
fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-2') })
const afterVersionChange = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-same-digest-version-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
const afterRefresh = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-version-cache-refresh'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
@@ -1886,9 +1921,11 @@ describe('dynamic nested workspace context injection', () => {
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-from-first-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root),
})
const second = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-from-second-session'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: stubAgent(root),
})
@@ -1914,11 +1951,13 @@ describe('dynamic nested workspace context injection', () => {
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-before-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContexts(agent, first)
await write(join(root, 'pkg/AGENTS.md'), 'new package rule with more detail')
const changed = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
@@ -1954,15 +1993,18 @@ describe('dynamic nested workspace context injection', () => {
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-before-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContexts(agent, first)
await rm(join(root, 'pkg/AGENTS.md'))
const changed = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContexts(agent, changed)
const unchanged = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-logged-fallback'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
@@ -1993,11 +2035,13 @@ describe('dynamic nested workspace context injection', () => {
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-before-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContexts(agent, first)
await rm(join(root, 'pkg/AGENTS.md'))
const removed = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-remove'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
@@ -2031,17 +2075,20 @@ describe('dynamic nested workspace context injection', () => {
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-before-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContexts(agent, first)
await rm(join(root, 'pkg/AGENTS.md'))
const removed = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-to-create-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContexts(agent, removed)
await write(join(root, 'pkg/AGENTS.md'), 'restored package rule')
const restored = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-tombstone'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
@@ -2073,11 +2120,13 @@ describe('dynamic nested workspace context injection', () => {
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-before-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
appendAdditionalContexts(agent, first)
fs.throwOnStat.add(join(root, 'pkg/AGENTS.md'))
const duringFailure = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-during-provider-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
})
@@ -2101,6 +2150,7 @@ describe('dynamic nested workspace context injection', () => {
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-before-resume'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -2113,6 +2163,7 @@ describe('dynamic nested workspace context injection', () => {
}
const afterResume = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-resume'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -2138,6 +2189,7 @@ describe('dynamic nested workspace context injection', () => {
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const original = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-before-offline-change'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent: original,
})
appendAdditionalContexts(original, first)
@@ -2168,6 +2220,7 @@ describe('dynamic nested workspace context injection', () => {
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-before-compact'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -2175,6 +2228,7 @@ describe('dynamic nested workspace context injection', () => {
})
const contextSeq = appendAdditionalContexts(agent, first)!
const visibleBeforeCompact = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-while-visible'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -2190,6 +2244,7 @@ describe('dynamic nested workspace context injection', () => {
})
const afterCompact = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-compact'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -2219,6 +2274,7 @@ describe('dynamic nested workspace context injection', () => {
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-package'),
name: 'read',
arguments: { file_path: 'pkg/file.txt' },
@@ -2227,6 +2283,7 @@ describe('dynamic nested workspace context injection', () => {
appendAdditionalContexts(agent, first)
const second = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-subtree'),
name: 'read',
arguments: { file_path: 'pkg/sub/file.txt' },
@@ -2254,6 +2311,7 @@ describe('dynamic nested workspace context injection', () => {
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 })
const agent = stubAgent(root)
const first = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-subtree-omitting-parent'),
name: 'read',
arguments: { file_path: 'pkg/sub/file.txt' },
@@ -2262,6 +2320,7 @@ describe('dynamic nested workspace context injection', () => {
appendAdditionalContexts(agent, first)
const second = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-parent-after-omit'),
name: 'read',
arguments: { file_path: 'pkg/other.txt' },
@@ -2323,6 +2382,7 @@ describe('dynamic nested workspace context injection', () => {
}, { surfaceOp: 'append' })
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-spoofed-state'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -2349,12 +2409,14 @@ describe('dynamic nested workspace context injection', () => {
const agent = stubAgent(root)
const rootResult = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-root-file'),
name: 'read',
arguments: { file_path: 'root.txt' },
agent,
})
const absoluteResult = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-absolute-nested-file'),
name: 'read',
arguments: { file_path: join(root, 'pkg/deep/file.txt') },
@@ -2388,11 +2450,13 @@ describe('dynamic nested workspace context injection', () => {
}
const failedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({
signal: testToolSignal,
callId: CallId('provider-stat-failure'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
}), result, async () => ({ kind: 'accept' as const }))
fs.throwOnStat.clear()
fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'directory' })
const mismatchedStat = await ctx.waterfall('tools/post-execute', stubToolExecution({
signal: testToolSignal,
callId: CallId('provider-stat-mismatch'), name: 'read', arguments: { file_path: 'pkg/file.txt' }, agent,
}), result, async () => ({ kind: 'accept' as const }))
@@ -2418,6 +2482,7 @@ describe('dynamic nested workspace context injection', () => {
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-with-unreadable-nested-instruction'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -2452,6 +2517,7 @@ describe('dynamic nested workspace context injection', () => {
}))
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-with-downstream'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -2496,6 +2562,7 @@ describe('dynamic nested workspace context injection', () => {
}))
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-blocked-downstream'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -2536,6 +2603,7 @@ describe('dynamic nested workspace context injection', () => {
const agent = stubAgent(root)
const blocked = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('outer-block-first'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -2543,6 +2611,7 @@ describe('dynamic nested workspace context injection', () => {
})
shouldBlock = false
const accepted = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('outer-block-retry'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -2583,7 +2652,7 @@ describe('dynamic nested workspace context injection', () => {
arguments: { file_path: 'pkg/deep/file.txt' },
...exec.agent === undefined ? {} : { agent: exec.agent },
parent: exec.token,
...exec.signal === undefined ? {} : { signal: exec.signal },
signal: exec.signal,
})
for (const context of nested.additionalContexts ?? []) exec.deferContext(context)
return nested.content
@@ -2600,10 +2669,12 @@ describe('dynamic nested workspace context injection', () => {
const agent = stubAgent(root)
const blocked = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('composite-first'), name: 'composite-read', arguments: {}, agent,
})
shouldBlock = false
const accepted = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('composite-retry'), name: 'composite-read', arguments: {}, agent,
})
@@ -2627,19 +2698,23 @@ describe('dynamic nested workspace context injection', () => {
const plainResult = { callId: CallId('plain'), content: [], isError: false }
ctx.emit('tools/result', stubToolExecution({
signal: testToolSignal,
callId: CallId('agentless-child'), name: 'read', arguments: {}, parent,
}), plainResult)
ctx.emit('tools/result', stubToolExecution({
signal: testToolSignal,
callId: CallId('contextless-child'), name: 'read', arguments: {}, agent, parent,
}), { ...plainResult, additionalContexts: [{ content: [], source: { kind: 'plugin', plugin: 'workspace-context' } }] })
ctx.emit('tools/result', stubToolExecution({
signal: testToolSignal,
callId: CallId('first-child'), name: 'read', arguments: {}, agent, parent,
}), { ...plainResult, additionalContexts: [workspaceChangeContext('first', 'one')] })
ctx.emit('tools/result', stubToolExecution({
signal: testToolSignal,
callId: CallId('second-child'), name: 'read', arguments: {}, agent, parent,
}), { ...plainResult, additionalContexts: [workspaceChangeContext('second', 'two')] })
ctx.emit('tools/result', {
...stubToolExecution({ callId: CallId('agentless-parent'), name: 'composite', arguments: {} }),
...stubToolExecution({ signal: testToolSignal, callId: CallId('agentless-parent'), name: 'composite', arguments: {} }),
token: parent,
}, plainResult)
@@ -2675,6 +2750,7 @@ describe('dynamic nested workspace context injection', () => {
for (const item of cases) {
const decision = await ctx.waterfall('tools/post-execute', stubToolExecution({
signal: testToolSignal,
callId: CallId(`manual-${item.name}-${cases.indexOf(item)}`),
name: item.name,
arguments: item.arguments,
@@ -2699,6 +2775,7 @@ describe('dynamic nested workspace context injection', () => {
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 0 })
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-with-disabled-budget'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },
@@ -2723,6 +2800,7 @@ describe('dynamic nested workspace context injection', () => {
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-missing'),
name: 'read',
arguments: { file_path: 'pkg/missing.txt' },
@@ -2749,6 +2827,7 @@ describe('dynamic nested workspace context injection', () => {
await fiber.dispose()
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('read-after-dispose'),
name: 'read',
arguments: { file_path: 'pkg/deep/file.txt' },

View File

@@ -552,7 +552,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>',
jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body or replaces a successful pipeline outcome with\n * `ABORTED`; already-started work is still drained and may retain a\n * tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */',
jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */',
},
],
},
@@ -833,7 +833,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
{
name: 'tools/execute',
mode: 'waterfall',
signature: '\'tools/execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>',
signature: '\'tools/execute\'(this: Scoped<ToolRegistry>, exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>',
jsDoc: '/**\n * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns\n * a normalized result; wrappers may change only `exec.signal`, while call\n * identity remains immutable. The registry re-fuses the original caller\n * signal before the body, so replacement cannot detach caller cancellation;\n * wrappers must still restore their signal and reach quiescence.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).\n * @mode waterfall\n */',
summary: 'Around-dispatch waterfall for timeout, retry, or metrics.',
},
@@ -841,7 +841,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'tools/post-execute',
mode: 'waterfall',
signature: '\'tools/post-execute\'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>',
jsDoc: '/**\n * Accept, replace, enrich, or block a normalized dispatch result. `next()`\n * accepts it unchanged; thrown tools still reach this seam as errors. Async\n * listeners must observe `exec.signal`; after they settle, caller\n * cancellation replaces only a successful accepted outcome with `ABORTED`.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the call that just ran (name, parsed arguments, caller agent).\n * @param result - the dispatch outcome a listener may accept, replace, or block.\n * @mode waterfall\n */',
jsDoc: '/**\n * Accept, replace, enrich, or block a normalized dispatch result. `next()`\n * accepts it unchanged; thrown tools still reach this seam as errors. Async\n * listeners must observe `exec.signal`; after they settle, caller\n * cancellation replaces only a successful accepted outcome with the code\n * selected by whether the tool body was invoked.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the call that just ran (name, parsed arguments, caller agent).\n * @param result - the dispatch outcome a listener may accept, replace, or block.\n * @mode waterfall\n */',
summary: 'Accept, replace, enrich, or block a normalized dispatch result.',
},
{
@@ -1514,7 +1514,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolExecutionInput',
declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n}',
declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n}',
},
{
name: 'ToolExecutionMode',

View File

@@ -6,6 +6,8 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { ToolDefinition, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import * as tool from '../src/index.ts'
const testToolSignal = new AbortController().signal
/**
* Shared spec helpers: a real `SystemPrompt` + `ToolRegistry` + timer +
* tool-cordis tree (only the model is absent — the code strings below stand in
@@ -27,7 +29,7 @@ let callCounter = 0
/** Execute a registered tool through the real registry pipeline. */
export function call(ctx: Context, name: string, args: unknown): Promise<ToolExecutionResult> {
return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args })
return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args })
}
/** Concatenated text blocks of one tool result. */

View File

@@ -54,7 +54,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery observes a closed failed step, and a retry rebuilds the request from the durable log in a new numbered step. Cancellation clears pending work and aborts the current step without leaking to the next prompt; undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush.
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery observes a closed failed step, and a retry rebuilds the request from the durable log in a new numbered step. Cancellation clears pending work and aborts the current step without leaking to the next prompt; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Terminal continuation stops remain authoritative through turn close and durability flush.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path.
@@ -102,7 +102,7 @@ Ordinary history growth is append-only and preserves reusable entries. A surface
#### What the model sees
If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has the error result text `Error: tool call skipped because the step was aborted before execution`.
If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has error code `ABORTED_BEFORE_DISPATCH` and result text `Error: tool call aborted before dispatch`.
#### Token effect

View File

@@ -13,7 +13,7 @@ import type { Context } from 'cordis'
import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
import type { HookContext } from '@deepseek-ai/dsh-agent'
import type { Session } from '@deepseek-ai/dsh-session'
import { TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
/** One tool call after argument parsing, ready to schedule. */
interface PlannedCall {
@@ -217,9 +217,9 @@ async function runGroup(
function appendSkippedToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): void {
const callSeq = appendToolCall(session, turn, step, block)
appendToolResult(session, turn, step, block, {
content: [{ type: 'text', text: 'Error: tool call skipped because the step was aborted before execution' }],
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
}, callSeq)
}

View File

@@ -9,6 +9,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
const testToolSignal = new AbortController().signal
interface Harness {
ctx: Context
agentsFiber: Fiber
@@ -239,6 +241,7 @@ describe('AgentLoop initiator scope', () => {
}))
const direct = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('direct'),
name: 'agentless-probe',
arguments: {},

View File

@@ -12,7 +12,7 @@ import { Context } from 'cordis'
import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -181,7 +181,7 @@ describe('Agent.cancel()', () => {
expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({
callId: 'c1',
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
send(agent, 'continue safely')

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineTool, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
@@ -259,7 +259,10 @@ describe('abort during tool execution ends the turn', () => {
case 'assistant/message': order.push('assistant/message'); break
case 'tool/call': order.push(`tool/call:${event.data.callId}`); break
case 'tool/result': {
const outcome = event.data.error?.code === 'ABORTED' ? 'aborted' : 'completed'
const outcome = event.data.error?.code === TOOL_ABORTED
|| event.data.error?.code === TOOL_ABORTED_BEFORE_DISPATCH
? 'aborted'
: 'completed'
order.push(`tool/result:${event.data.callId}:${outcome}`)
break
}
@@ -308,12 +311,12 @@ describe('abort during tool execution ends the turn', () => {
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: tool call aborted' }],
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED },
})
expect(results[1]!.data).toMatchObject({
callId: CallId('c2'),
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
})

View File

@@ -9,7 +9,7 @@ import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import LlmService from '@deepseek-ai/dsh-llm'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -476,8 +476,8 @@ describe('tool-call scheduler: abort handling', () => {
isError: e.data.isError,
error: e.data.error,
}))).toEqual([
{ callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } },
{ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } },
{ callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
{ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
])
})
@@ -508,8 +508,8 @@ describe('tool-call scheduler: abort handling', () => {
isError: e.data.isError,
error: e.data.error,
}))).toEqual([
{ callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } },
{ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } },
{ callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
{ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
])
})
@@ -541,8 +541,8 @@ describe('tool-call scheduler: abort handling', () => {
.toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')])
expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => e.data))
.toEqual([
expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }),
expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }),
expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }),
expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } }),
])
const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message')
expect(settled.map(e => e.type))
@@ -584,6 +584,6 @@ describe('tool-call scheduler: abort handling', () => {
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2'), CallId('c3')])
expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data)
.toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } })
.toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } })
})
})

View File

@@ -29,7 +29,7 @@ tools:
### Cancellation
Cancellation is cooperative and quiescent. A cancellation that arrives after registry entry is rechecked after pre-policy, approval, around-dispatch, and post-result policy waits, so a body cannot start late and cancellation that wins before final result materialization supersedes a successful pipeline outcome; if the body has started, the registry preserves the caller signal through wrapper replacement and awaits settlement. A tool-owned structured error still wins. The registry never races away from a live same-process promise: every async tool must observe or forward `exec.signal` and settle only after owned work stops. A signal already aborted on entry still reaches the body for domain-specific cleanup; the agent-loop scheduler prevents model-driven calls from entering in that state. A timeout wrapper may replace the intermediate `ABORTED` with its owned `TOOL_TIMEOUT` when its deadline won. The [tool-cancellation RFC](../../../docs/rfc/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md) owns the service boundary and its hard-termination limit.
Cancellation is cooperative and quiescent. Every typed invocation supplies a caller-owned `AbortSignal`; tool bodies receive it as required readonly `exec.signal`, while only `tools/execute` wrappers may temporarily replace the required signal. The registry preserves caller cancellation through replacement and never races away from a started same-process promise. Cancellation before body invocation is `ABORTED_BEFORE_DISPATCH`; cancellation after invocation can replace only a successful outcome with `ABORTED`. A denial, wrapper failure, tool failure, post-policy failure, or timeout-owned `TOOL_TIMEOUT` remains more specific. A pre-aborted entry materializes and freezes arguments, then skips every policy and dispatch phase and publishes one result. Every async tool must observe or forward the signal and settle only after owned work stops. The [tool-cancellation RFC](../../../docs/rfc/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md) owns the full contract and hard-termination limit.
### Live events
@@ -38,9 +38,9 @@ The live registry pipeline has three transformable waterfalls followed by the ob
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec)`, whose async work must cooperatively stop through `exec.signal`, plus optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, signal, agent?, parent? }`; `signal` is required and readonly, callers may pass an enclosing execution's opaque token as `parent`, and callers never choose the new execution's own token.
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore; the registry separately retains and re-fuses the original caller signal. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source, envelope, and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.

View File

@@ -160,9 +160,8 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
// (its executor kills on this signal) instead of orphaned, and
// queued-unstarted dispatches are abandoned.
const runController = new AbortController()
const onOuterAbort = (): void => { runController.abort(exec.signal?.reason) }
if (exec.signal?.aborted) onOuterAbort()
exec.signal?.addEventListener('abort', onOuterAbort, { once: true })
const onOuterAbort = (): void => { runController.abort(exec.signal.reason) }
exec.signal.addEventListener('abort', onOuterAbort, { once: true })
let dispatches = 0
// The per-run serialization queue: every binding call chains onto the tail, so even
@@ -273,7 +272,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
meta,
}
} finally {
exec.signal?.removeEventListener('abort', onOuterAbort)
exec.signal.removeEventListener('abort', onOuterAbort)
}
},
// ACP execute cards use the program as their visible title.

View File

@@ -90,12 +90,13 @@ declare module 'cordis' {
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
* @mode waterfall
*/
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
/**
* Accept, replace, enrich, or block a normalized dispatch result. `next()`
* accepts it unchanged; thrown tools still reach this seam as errors. Async
* listeners must observe `exec.signal`; after they settle, caller
* cancellation replaces only a successful accepted outcome with `ABORTED`.
* cancellation replaces only a successful accepted outcome with the code
* selected by whether the tool body was invoked.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
* @param exec - the call that just ran (name, parsed arguments, caller agent).
* @param result - the dispatch outcome a listener may accept, replace, or block.
@@ -218,7 +219,8 @@ export interface ToolExecutionInput {
* the outer `run_code` outcome without receiving its live mutable execution.
*/
readonly parent?: ToolExecutionToken
signal?: AbortSignal
/** Required caller-owned cancellation for this invocation. */
readonly signal: AbortSignal
}
/**
@@ -232,17 +234,25 @@ export type ToolExecutionMode =
/**
* One pending tool call inside the registry pipeline. Parsed arguments cross
* one lossless-JSON materialization boundary before policy and are deep-frozen;
* call identity and the registry-assigned {@link token} are readonly. An
* around-dispatch wrapper may set, replace, or remove `signal`; immediately
* before the body, the registry re-fuses the original caller signal so a
* wrapper cannot detach caller cancellation. The registry freezes the complete
* object before `tools/result` observers run.
* call identity, the caller signal, and the registry-assigned {@link token} are
* readonly. The registry freezes the complete object before `tools/result`
* observers run.
*/
export interface ToolExecution extends ToolExecutionInput {
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
readonly token: ToolExecutionToken
}
/**
* Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper
* may replace the signal for its delegated lifetime, but it cannot remove it.
* The registry fuses every replacement with the captured caller signal.
*/
export interface ToolDispatchExecution extends Omit<ToolExecution, 'signal'> {
/** Cancellation signal visible to the next wrapper or tool body. */
signal: AbortSignal
}
/**
* Runtime context handed to a tool implementation after the registry has
* accepted a {@link ToolExecution}. A composite tool uses
@@ -258,6 +268,9 @@ export interface ToolRunContext extends ToolExecution {
deferContext(context: HookContext): void
}
/** Registry-owned live execution object; public pipeline views stay readonly. */
type MutableToolRunContext = Omit<ToolRunContext, 'signal'> & { signal: AbortSignal }
/**
* Scheduler-only result after ordered pre-execute and guards. A `post-result`
* still receives post-execute; a `final-result` bypasses it.
@@ -299,6 +312,13 @@ export interface ToolRegistryScheduler {
* @internal
*/
export const TOOL_REGISTRY_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler')
/** Canonical error code for cancellation after a tool body was invoked. */
export const TOOL_ABORTED = 'ABORTED'
/** Canonical error code for cancellation before a tool body was invoked. */
export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH'
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
export interface ToolErrorInfo {
name: string
@@ -448,15 +468,21 @@ interface ToolGuardRegistration {
guard: ToolGuard
}
/** Caller cancellation captured before around-dispatch wrappers may replace the public signal slot. */
/** Approval decision plus whether the approval channel reported cancellation. */
interface ToolAskResolution {
readonly decision: Extract<PreToolDecision, { kind: 'allow' | 'deny' }>
readonly approvalCancelled: boolean
}
/** Caller cancellation and dispatch state kept outside the around-wrapper view. */
interface ToolCancellationState {
readonly callerSignal: AbortSignal | undefined
readonly abortedAtEntry: boolean
readonly callerSignal: AbortSignal
bodyInvoked: boolean
}
/** One dispatch-scoped fused signal plus listener cleanup after the body settles. */
interface FusedToolSignal {
readonly signal: AbortSignal | undefined
readonly signal: AbortSignal
dispose(): void
}
@@ -807,9 +833,9 @@ export class ToolRegistry extends Service {
* results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
* the same lossless, frozen snapshot final observers receive. Cancellation
* arriving after entry and before final result materialization skips a
* not-yet-started body or replaces a successful pipeline outcome with
* `ABORTED`; already-started work is still drained and may retain a
* tool-owned structured error.
* not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a
* successful started outcome with `ABORTED`; already-started work is still
* drained and may retain a tool-owned structured error.
* @param exec - the typed same-process call input. The registry assigns its
* correlation token before policy begins.
* @returns the materialized final result.
@@ -836,7 +862,7 @@ export class ToolRegistry extends Service {
}
}
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: ToolRunContext } {
private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } {
const deferredContexts: HookContext[] = []
const token = createExecutionToken()
const callId = exec.callId
@@ -848,9 +874,9 @@ export class ToolRegistry extends Service {
token,
callId,
name,
signal,
...agent !== undefined ? { agent } : {},
...parent !== undefined ? { parent } : {},
...signal !== undefined ? { signal } : {},
deferContext(context: HookContext): void {
deferredContexts.push(context)
},
@@ -860,15 +886,15 @@ export class ToolRegistry extends Service {
if (detached === undefined) {
throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
}
const execution: ToolRunContext = { ...base, arguments: deepFreeze(detached) }
const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) }
this.deferredContexts.set(execution, deferredContexts)
this.cancellationStates.set(execution, {
callerSignal: signal,
abortedAtEntry: signal?.aborted === true,
bodyInvoked: false,
})
return { kind: 'ready', exec: execution }
} catch (error: unknown) {
const execution: ToolRunContext = { ...base, arguments: undefined }
const execution: MutableToolRunContext = { ...base, arguments: undefined }
return { kind: 'final-result', exec: execution, result: toolErrorResult(error) }
}
}
@@ -890,15 +916,21 @@ export class ToolRegistry extends Service {
const created = this.createExecution(input)
if (created.kind !== 'ready') return next(created)
const exec = created.exec
if (this.callerCancelled(exec)) {
return next({ kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() })
}
try {
const carrier = scopeTarget(this, exec.agent)
const gate = await this.ctx.waterfall(
carrier, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)
const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
if (this.callerCancelledAfterEntry(exec)) {
return await next({ kind: 'post-result', exec, result: toolAbortedResult() })
const askResolution: ToolAskResolution = gate.kind === 'ask'
? await this.serviceAsk(exec, gate)
: { decision: gate, approvalCancelled: false }
const { decision } = askResolution
if (this.callerCancelled(exec) && askResolution.approvalCancelled) {
return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
}
const denialReason = decision.kind === 'allow'
? this.guardReason(exec)
@@ -913,20 +945,31 @@ export class ToolRegistry extends Service {
},
})
}
if (this.callerCancelled(exec)) {
return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() })
}
return await next({ kind: 'dispatch', exec })
} catch (error: unknown) {
return this.callerCancelledAfterEntry(exec)
? await next({ kind: 'post-result', exec, result: toolAbortedResult() })
: next({ kind: 'final-result', exec, result: toolErrorResult(error) })
return next({ kind: 'final-result', exec, result: toolErrorResult(error) })
}
}
/** Whether the original live caller signal aborted after this execution entered the registry. */
private callerCancelledAfterEntry(exec: ToolRunContext): boolean {
/** Whether the original caller signal is currently aborted. */
private callerCancelled(exec: ToolRunContext): boolean {
const state = this.cancellationStates.get(exec)
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
return !state.abortedAtEntry && state.callerSignal?.aborted === true
return state.callerSignal.aborted
}
/** Canonical cancellation outcome selected by whether the tool body started. */
private cancellationResult(exec: ToolRunContext, prior?: ToolExecutionResult): ToolExecutionResult {
const state = this.cancellationStates.get(exec)
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
return state.bodyInvoked
? toolAbortedResult(prior)
: toolAbortedBeforeDispatchResult(prior)
}
/**
@@ -934,24 +977,23 @@ export class ToolRegistry extends Service {
* into any around-wrapper replacement. Cancellation never abandons the body:
* a started promise reaches quiescence before its outcome becomes `ABORTED`.
*/
private async dispatchToolBody(exec: ToolRunContext): Promise<ToolExecutionResult> {
private async dispatchToolBody(exec: MutableToolRunContext): Promise<ToolExecutionResult> {
const state = this.cancellationStates.get(exec)
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
const wrapperSignal = exec.signal
const fused = fuseToolSignals(state.callerSignal, wrapperSignal)
const signal = fused.signal
const abortedBeforeBody = isAborted(signal)
if (!state.abortedAtEntry && abortedBeforeBody) {
if (isAborted(signal)) {
fused.dispose()
return toolAbortedResult()
return toolAbortedBeforeDispatchResult()
}
if (signal === undefined) delete exec.signal
else exec.signal = signal
exec.signal = signal
try {
const tool = this.get(exec.name, exec.agent)
if (!tool) throw new ToolNotFoundError(exec.name)
state.bodyInvoked = true
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
@@ -960,15 +1002,14 @@ export class ToolRegistry extends Service {
isError: false,
...meta !== undefined ? { meta } : {},
}
return !abortedBeforeBody && isAborted(signal)
return isAborted(signal)
? toolAbortedResult(result)
: result
} catch (error: unknown) {
return toolErrorResult(error)
} finally {
fused.dispose()
if (wrapperSignal === undefined) delete exec.signal
else exec.signal = wrapperSignal
exec.signal = wrapperSignal
}
}
@@ -981,10 +1022,11 @@ export class ToolRegistry extends Service {
*/
private async dispatchScheduledExecution(exec: ToolRunContext): Promise<ScheduledToolDispatch> {
try {
const mutableExec = exec as MutableToolRunContext
const carrier = scopeTarget(this, exec.agent)
const result = await this.ctx.waterfall(
carrier, 'tools/execute', exec,
() => this.dispatchToolBody(exec),
carrier, 'tools/execute', mutableExec,
() => this.dispatchToolBody(mutableExec),
)
const deferredContexts = this.deferredContexts.get(exec)
/* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
@@ -1000,8 +1042,8 @@ export class ToolRegistry extends Service {
}
return {
kind: 'post-result',
result: this.callerCancelledAfterEntry(exec) && !resultWithDeferredContexts.isError
? toolAbortedResult(resultWithDeferredContexts)
result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError
? this.cancellationResult(exec, resultWithDeferredContexts)
: resultWithDeferredContexts,
}
} catch (error: unknown) {
@@ -1021,8 +1063,8 @@ export class ToolRegistry extends Service {
const postResult = await this.postExecute(exec, result)
return this.finishScheduledExecution(
exec,
this.callerCancelledAfterEntry(exec) && !postResult.isError
? toolAbortedResult(postResult)
this.callerCancelled(exec) && !postResult.isError
? this.cancellationResult(exec, postResult)
: postResult,
)
} catch (error: unknown) {
@@ -1050,8 +1092,8 @@ export class ToolRegistry extends Service {
/** Notify observers without exposing a mutation or error channel into the outcome. */
private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void {
// Freeze the remaining mutable signal slot before observers receive the
// shared WeakMap-keyable execution object.
// Freeze the registry's live object before observers receive its readonly
// WeakMap-keyable view.
Object.freeze(exec)
const callbacks = this.ctx.events.dispatch('emit', [
scopeTarget(this, exec.agent), 'tools/result', exec, result,
@@ -1079,26 +1121,41 @@ export class ToolRegistry extends Service {
private async serviceAsk(
exec: ToolExecution,
ask: Extract<PreToolDecision, { kind: 'ask' }>,
): Promise<Extract<PreToolDecision, { kind: 'allow' | 'deny' }>> {
): Promise<ToolAskResolution> {
const approval = this.ctx.get('approval')
if (approval === undefined) {
return { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` }
return {
decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` },
approvalCancelled: false,
}
}
if (exec.agent === undefined) {
return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` }
return {
decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` },
approvalCancelled: false,
}
}
const outcome = await approval.request({
agent: exec.agent,
toolName: exec.name,
callId: exec.callId,
...ask.reason !== undefined ? { reason: ask.reason } : {},
...exec.signal !== undefined ? { signal: exec.signal } : {},
signal: exec.signal,
})
switch (outcome) {
case 'allowed-once': return { kind: 'allow' }
case 'rejected': return { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }
case 'cancelled': return { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` }
case 'unavailable': return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` }
case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false }
case 'rejected': return {
decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` },
approvalCancelled: false,
}
case 'cancelled': return {
decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` },
approvalCancelled: true,
}
case 'unavailable': return {
decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` },
approvalCancelled: false,
}
default: return assertNever(outcome, 'ApprovalOutcome')
}
}
@@ -1165,19 +1222,16 @@ function toolErrorResult(error: unknown): ToolExecutionResult {
}
/** Read live abort state across an await without treating it as synchronously immutable. */
function isAborted(signal: AbortSignal | undefined): boolean {
return signal?.aborted === true
function isAborted(signal: AbortSignal): boolean {
return signal.aborted
}
/**
* Fuse caller and wrapper cancellation without nesting `AbortSignal.any`.
* Keeping the relay dispatch-scoped also removes listeners when work settles.
*/
function fuseToolSignals(caller: AbortSignal | undefined, wrapper: AbortSignal | undefined): FusedToolSignal {
if (caller === undefined || caller === wrapper) {
return { signal: wrapper ?? caller, dispose() {} }
}
if (wrapper === undefined) return { signal: caller, dispose() {} }
function fuseToolSignals(caller: AbortSignal, wrapper: AbortSignal): FusedToolSignal {
if (caller === wrapper) return { signal: caller, dispose() {} }
const controller = new AbortController()
let listening = false
@@ -1205,13 +1259,24 @@ function fuseToolSignals(caller: AbortSignal | undefined, wrapper: AbortSignal |
return { signal: controller.signal, dispose }
}
/** Canonical result when cancellation prevents dispatch or supersedes a successful outcome. */
/** Canonical result when cancellation supersedes success after body invocation. */
function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult {
const additionalContexts = prior?.additionalContexts ?? []
return {
content: [{ type: 'text', text: 'Error: tool call aborted' }],
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED },
...additionalContexts.length > 0 ? { additionalContexts } : {},
}
}
/** Canonical result when cancellation prevents tool body invocation. */
function toolAbortedBeforeDispatchResult(prior?: ToolExecutionResult): ToolExecutionResult {
const additionalContexts = prior?.additionalContexts ?? []
return {
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
...additionalContexts.length > 0 ? { additionalContexts } : {},
}
}

View File

@@ -6,12 +6,14 @@ import type { Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineTool } from '@deepseek-ai/dsh-tools'
import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventMap } from '@deepseek-ai/dsh-session'
const testToolSignal = new AbortController().signal
/**
* Code Mode unit tier (per the RFC's plan): provider contribution per mode,
* misconfiguration rejections, the run_code dispatch bridge (serialization,
@@ -95,6 +97,7 @@ function fakeAgent(options: { cwd?: string } = { cwd: '/workspace' }): { agent:
/** Dispatch run_code through the registry pipeline, as the loop would. */
async function runCode(ctx: Context, code: string, extras: { agent?: Agent; signal?: AbortSignal } = {}): Promise<ToolExecutionResult> {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId('call-1'),
name: RUN_CODE_NAME,
arguments: { code },
@@ -357,8 +360,7 @@ describe('the run_code dispatch bridge', () => {
const previous = exec.signal
exec.signal = new AbortController().signal
const result = await next()
if (previous === undefined) delete exec.signal
else exec.signal = previous
exec.signal = previous
return result
})
ctx.on('tools/result', (exec) => {
@@ -577,7 +579,7 @@ describe('the run_code dispatch bridge', () => {
seen.push(args.id)
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 500)
exec.signal?.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true })
exec.signal.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true })
})
return [{ type: 'text' as const, text: args.id }]
},
@@ -613,7 +615,7 @@ describe('the run_code dispatch bridge', () => {
started()
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 500)
exec.signal?.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true })
exec.signal.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true })
})
return [{ type: 'text' as const, text: args.id }]
},
@@ -841,7 +843,7 @@ describe('the run_code dispatch bridge', () => {
expect((result.content[0] as { text: string }).text).toBe('{ n: 42 }')
})
it('reports a pre-aborted outer signal as the run failure without dispatching anything', async () => {
it('short-circuits a pre-aborted outer signal before the code runtime', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const calls = registerEcho(ctx)
runtime.behavior = (request) => {
@@ -853,7 +855,12 @@ describe('the run_code dispatch bridge', () => {
controller.abort('too-late')
const result = await runCode(ctx, 'program', { signal: controller.signal })
expect(result.isError).toBe(true)
expect((result.content[0] as { text: string }).text).toContain('code run failed (abort)')
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(runtime.lastRequest).toBeUndefined()
expect(calls).toEqual([])
})

View File

@@ -11,6 +11,8 @@ import ToolRegistry, {
type ToolExecutionMode,
} from '@deepseek-ai/dsh-tools'
const testToolSignal = new AbortController().signal
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -19,7 +21,7 @@ async function setup() {
}
function exec(name: string, args: unknown): ToolExecutionInput {
return { callId: CallId('c1'), name, arguments: args }
return { signal: testToolSignal, callId: CallId('c1'), name, arguments: args }
}
describe('ToolRegistry.executionMode', () => {

View File

@@ -0,0 +1,100 @@
import { describe, expectTypeOf, it } from 'vitest'
import type { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {
ToolDispatchExecution,
ToolExecution,
ToolExecutionInput,
ToolRunContext,
} from '@deepseek-ai/dsh-tools'
function inputAndExecutionContracts(
input: ToolExecutionInput,
execution: ToolExecution,
run: ToolRunContext,
): void {
// @ts-expect-error -- every typed invocation must supply a caller-owned signal.
const missingSignal: ToolExecutionInput = { callId: CallId('missing'), name: 'probe', arguments: {} }
void missingSignal
// @ts-expect-error -- caller input is readonly after construction.
input.signal = new AbortController().signal
// @ts-expect-error -- required readonly properties cannot be deleted.
delete input.signal
// @ts-expect-error -- required signals cannot become undefined.
input.signal = undefined
// @ts-expect-error -- pipeline observers receive a readonly execution view.
execution.signal = new AbortController().signal
// @ts-expect-error -- pipeline observers cannot remove the required signal.
delete execution.signal
// @ts-expect-error -- tool bodies receive a readonly run context.
run.signal = new AbortController().signal
// @ts-expect-error -- tool bodies cannot remove the required signal.
delete run.signal
// @ts-expect-error -- tool bodies cannot replace the required signal with undefined.
run.signal = undefined
}
void inputAndExecutionContracts
function observerContracts(ctx: Context): void {
ctx.on('tools/pre-execute', (exec, next) => {
// @ts-expect-error -- pre-policy sees a readonly signal.
exec.signal = new AbortController().signal
// @ts-expect-error -- pre-policy cannot remove the required signal.
delete exec.signal
// @ts-expect-error -- pre-policy cannot replace the required signal with undefined.
exec.signal = undefined
return next()
})
ctx.on('tools/post-execute', (exec, _result, next) => {
// @ts-expect-error -- post-policy sees a readonly signal.
exec.signal = new AbortController().signal
// @ts-expect-error -- post-policy sees a readonly signal.
delete exec.signal
// @ts-expect-error -- post-policy cannot replace the required signal with undefined.
exec.signal = undefined
return next()
})
ctx.on('tools/result', (exec) => {
// @ts-expect-error -- result observers see a readonly signal.
exec.signal = new AbortController().signal
// @ts-expect-error -- result observers cannot remove the required signal.
delete exec.signal
// @ts-expect-error -- result observers see a readonly signal.
exec.signal = undefined
})
ctx.on('tools/execute', (exec, next) => {
exec.signal = new AbortController().signal
// @ts-expect-error -- around-dispatch may replace but not remove the signal.
delete exec.signal
// @ts-expect-error -- around-dispatch cannot replace the required signal with undefined.
exec.signal = undefined
return next()
})
}
void observerContracts
const inferredTool = defineTool({
name: 'signal-inference',
description: 'Pins contextual signal inference.',
parameters: {},
async execute(_args, exec) {
expectTypeOf(exec.signal).toEqualTypeOf<AbortSignal>()
// @ts-expect-error -- defineTool contextually exposes a readonly signal.
exec.signal = new AbortController().signal
return []
},
})
void inferredTool
describe('tool execution signal types', () => {
it('requires an exact AbortSignal at every readonly tool view', () => {
expectTypeOf<ToolExecutionInput['signal']>().toEqualTypeOf<AbortSignal>()
expectTypeOf<ToolExecution['signal']>().toEqualTypeOf<AbortSignal>()
expectTypeOf<ToolRunContext['signal']>().toEqualTypeOf<AbortSignal>()
expectTypeOf<ToolDispatchExecution['signal']>().toEqualTypeOf<AbortSignal>()
expectTypeOf<typeof inferredTool.execute>().toBeFunction()
})
})

View File

@@ -12,6 +12,8 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
const testToolSignal = new AbortController().signal
/** Mount the registry (with its systemPrompt dependency) on a fresh context. */
async function mount(): Promise<Context> {
const ctx = new Context()
@@ -43,6 +45,7 @@ function tool(name: string, reply = `ran:${name}`): ToolDefinition {
async function run(ctx: Context, name: string, agent?: Agent): Promise<string> {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('c1'),
name,
arguments: {},
@@ -305,6 +308,7 @@ describe('scoped execution dispatch', () => {
expect(await run(ctx, 'danger', key)).toBe('Error: danger denied')
const callerArguments = { source: true }
const safeResult = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('safe-call'),
name: 'safe',
arguments: callerArguments,
@@ -348,7 +352,7 @@ describe('scoped execution dispatch', () => {
if (exec.name === 'parent') parent = exec.token
return next()
})
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
await ctx.tools.execute({ signal: testToolSignal, callId: CallId('parent'), name: 'parent', arguments: {} })
stopCapture()
policyCalls = 0
const signal = new AbortController().signal
@@ -372,6 +376,7 @@ describe('scoped execution dispatch', () => {
signal,
})
const subjectlessResult = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('non-cloneable-subjectless'),
name: 't',
arguments: { invalid: () => undefined },
@@ -414,6 +419,7 @@ describe('scoped execution dispatch', () => {
callId: CallId('stateful-parent'),
name: 't',
arguments: {},
signal: testToolSignal,
get parent(): ToolExecutionToken | undefined {
parentReads += 1
return parentReads === 1 ? undefined : forged
@@ -438,7 +444,7 @@ describe('scoped execution dispatch', () => {
if (exec.name === 'parent') parent = exec.token
return next()
})
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
await ctx.tools.execute({ signal: testToolSignal, callId: CallId('parent'), name: 'parent', arguments: {} })
stopCapture()
const acceptedSignal = new AbortController().signal
const driftSignal = new AbortController().signal
@@ -485,6 +491,7 @@ describe('scoped execution dispatch', () => {
const input = {
callId: CallId('throwing-arguments'),
name: 't',
signal: testToolSignal,
get arguments(): unknown {
argumentReads += 1
throw new Error('getter exploded')
@@ -525,6 +532,7 @@ describe('scoped execution dispatch', () => {
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('bad-arguments'), name: 't', arguments: argumentsValue,
})
@@ -545,6 +553,7 @@ describe('scoped execution dispatch', () => {
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue,
})
@@ -584,7 +593,7 @@ describe('scoped execution dispatch', () => {
})
ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) })
const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('final'), name: 't', arguments: {}, agent: key })
expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] })
expect(seen).toEqual([true, true])
expect(dispatchModes).toEqual(['emit'])

View File

@@ -6,10 +6,13 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult,
type ToolDispatchExecution, type ToolExecutionResult,
} from '@deepseek-ai/dsh-tools'
const testToolSignal = new AbortController().signal
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -79,7 +82,7 @@ describe('ToolRegistry', () => {
it('executes a tool and returns its content', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
})
@@ -92,7 +95,7 @@ describe('ToolRegistry', () => {
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] } }
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'meta-tool', arguments: {} })
expect(result).toEqual({
content: [{ type: 'text', text: 'ok' }],
isError: false,
@@ -109,7 +112,7 @@ describe('ToolRegistry', () => {
return { content: [{ type: 'text', text: 'ok' }] }
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
expect('meta' in result).toBe(false)
})
@@ -127,6 +130,7 @@ describe('ToolRegistry', () => {
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('bad-meta'), name: 'bad-meta', arguments: {},
})
expect(result.isError).toBe(true)
@@ -144,13 +148,13 @@ describe('ToolRegistry', () => {
},
})
const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'nope', arguments: {} })
const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'nope', arguments: {} })
expect(unknown.isError).toBe(true)
expect(unknown.content[0]).toMatchObject({ text: 'Error: unknown tool "nope"' })
// An unknown tool is a routable failure class, same as a tool-thrown one.
expect(unknown.error).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' })
const thrown = await ctx.tools.execute({ callId: CallId('c2'), name: 'boom', arguments: {} })
const thrown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'boom', arguments: {} })
expect(thrown.isError).toBe(true)
expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' })
})
@@ -170,6 +174,7 @@ describe('ToolRegistry', () => {
})
await expect(ctx.tools.execute({
signal: testToolSignal,
callId: CallId('hostile'), name: 'hostile-throw', arguments: {},
})).resolves.toMatchObject({
isError: true,
@@ -195,7 +200,7 @@ describe('ToolRegistry', () => {
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
})
@@ -207,7 +212,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> =>
({ kind: 'ask', reason: 'needs approval' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: needs approval' })
})
@@ -218,7 +223,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' })
})
@@ -269,7 +274,7 @@ describe('ToolRegistry', () => {
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: the user rejected tool "echo"' })
})
@@ -279,16 +284,51 @@ describe('ToolRegistry', () => {
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('cancelled'))
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: approval for tool "echo" was cancelled' })
})
it('returns ABORTED_BEFORE_DISPATCH when caller cancellation overtakes approval', async () => {
const ctx = await approvalSetup()
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<ApprovalOutcome>()
let dispatched = 0
ctx.tools.register({
...echoTool,
name: 'approval-probe',
async execute() { dispatched += 1; return [] },
})
ctx.on('approval/request', () => {
entered.resolve(undefined)
return release.promise
})
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const controller = new AbortController()
const pending = ctx.tools.execute({
callId: CallId('approval-cancelled'),
name: 'approval-probe',
arguments: {},
agent: fakeAgent(),
signal: controller.signal,
})
await entered.promise
controller.abort('caller cancelled approval')
release.resolve('allowed-once')
await expect(pending).resolves.toMatchObject({
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(dispatched).toBe(0)
})
it('denies with the no-channel reason when the seam is mounted but nobody answers', async () => {
const ctx = await approvalSetup()
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but no approval channel is available' })
})
@@ -302,7 +342,7 @@ describe('ToolRegistry', () => {
})
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} })
expect(asked).toBe(false)
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but the call has no agent to route it through' })
@@ -317,7 +357,7 @@ describe('ToolRegistry', () => {
ctx.provide('approval', { request: () => Promise.resolve('yolo') } as unknown as ApprovalService)
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
expect(result.isError).toBe(true)
const text = result.content[0]?.type === 'text' ? result.content[0].text : ''
expect(text).toContain('unreachable')
@@ -331,7 +371,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({ kind: 'accept', content: [{ type: 'text', text: 'rewritten' }] }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(false)
expect(result.content[0]).toMatchObject({ text: 'rewritten' })
})
@@ -343,7 +383,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({ kind: 'block', feedback: [{ type: 'text', text: 'output rejected: try again' }] }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' })
})
@@ -359,7 +399,7 @@ describe('ToolRegistry', () => {
additionalContexts: [{ content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }],
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'rejected' })
expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } }])
@@ -372,7 +412,7 @@ describe('ToolRegistry', () => {
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }] }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } }])
})
@@ -409,7 +449,7 @@ describe('ToolRegistry', () => {
}
})
const result = await ctx.tools.execute({ callId: CallId('composite'), name: 'composite', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('composite'), name: 'composite', arguments: {} })
expect(result.additionalContexts?.map(context => context.source)).toEqual([
{ kind: 'plugin', plugin: 'nested-1' },
@@ -433,7 +473,7 @@ describe('ToolRegistry', () => {
},
}))
const failed = await ctx.tools.execute({ callId: CallId('failed'), name: 'failing-composite', arguments: {} })
const failed = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('failed'), name: 'failing-composite', arguments: {} })
expect(failed.isError).toBe(true)
expect(failed.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'nested' }])
@@ -442,7 +482,7 @@ describe('ToolRegistry', () => {
feedback: [{ type: 'text', text: 'blocked' }],
additionalContexts: [{ content: [{ type: 'text', text: 'block-only' }], source: { kind: 'plugin', plugin: 'blocker' } }],
}))
const blocked = await ctx.tools.execute({ callId: CallId('blocked'), name: 'failing-composite', arguments: {} })
const blocked = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('blocked'), name: 'failing-composite', arguments: {} })
expect(blocked.isError).toBe(true)
expect(blocked.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'plugin', plugin: 'blocker' }])
})
@@ -465,7 +505,7 @@ describe('ToolRegistry', () => {
return decision
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } })
expect(result.isError).toBe(false)
// pre runs fully (gate) before dispatch, then post runs over the result.
expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after'])
@@ -485,7 +525,7 @@ describe('ToolRegistry', () => {
}))
ctx.on('tools/pre-execute', async (_exec, next) => { order.push('pre'); return next() })
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
order.push('execute:before')
const result = await next()
order.push('execute:after')
@@ -493,7 +533,7 @@ describe('ToolRegistry', () => {
})
ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
// The around seam wraps dispatch; pre gates before it, post runs over its result.
expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
@@ -524,14 +564,45 @@ describe('ToolRegistry', () => {
release.resolve(undefined)
await expect(pending).resolves.toMatchObject({
content: [{ type: 'text', text: 'Error: tool call aborted' }],
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(dispatched).toBe(0)
})
it('materializes ABORTED when an async pre-execute gate throws after cancellation', async () => {
it('preserves a pre-execute denial that settles after cancellation', async () => {
const ctx = await setup()
let dispatched = 0
ctx.tools.register({
...echoTool,
name: 'denied-after-cancel',
async execute() { dispatched += 1; return [] },
})
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.on('tools/pre-execute', async () => {
entered.resolve(undefined)
await release.promise
return { kind: 'deny', reason: 'policy denied the call' }
})
const controller = new AbortController()
const pending = ctx.tools.execute({
callId: CallId('denied-after-cancel'), name: 'denied-after-cancel', arguments: {}, signal: controller.signal,
})
await entered.promise
controller.abort('cancelled while policy decided')
release.resolve(undefined)
await expect(pending).resolves.toEqual({
content: [{ type: 'text', text: 'Error: policy denied the call' }],
isError: true,
})
expect(dispatched).toBe(0)
})
it('preserves an async pre-execute failure that settles after cancellation', async () => {
const ctx = await setup()
let dispatched = 0
ctx.tools.register({
@@ -555,9 +626,9 @@ describe('ToolRegistry', () => {
controller.abort('cancelled in policy')
release.resolve(undefined)
await expect(pending).resolves.toMatchObject({
await expect(pending).resolves.toEqual({
content: [{ type: 'text', text: 'Error: gate interrupted' }],
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
})
expect(dispatched).toBe(0)
})
@@ -581,8 +652,7 @@ describe('ToolRegistry', () => {
await release.promise
return await next()
} finally {
if (upstream === undefined) delete exec.signal
else exec.signal = upstream
exec.signal = upstream
}
})
@@ -596,7 +666,7 @@ describe('ToolRegistry', () => {
await expect(pending).resolves.toMatchObject({
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(dispatched).toBe(0)
})
@@ -616,8 +686,7 @@ describe('ToolRegistry', () => {
try {
return await next()
} finally {
if (upstream === undefined) delete exec.signal
else exec.signal = upstream
exec.signal = upstream
}
})
@@ -626,7 +695,50 @@ describe('ToolRegistry', () => {
callId: CallId('cancelled-wrapper'), name: 'must-not-run', arguments: {}, signal: controller.signal,
})
expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' })
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(dispatched).toBe(0)
})
it('uses ABORTED_BEFORE_DISPATCH when cancellation overtakes a wrapper short-circuit', async () => {
const ctx = await setup()
let dispatched = 0
ctx.tools.register({
...echoTool,
name: 'short-circuited',
async execute() { dispatched += 1; return [] },
})
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.on('tools/execute', async () => {
entered.resolve(undefined)
await release.promise
return {
content: [{ type: 'text', text: 'wrapper success' }],
isError: false,
additionalContexts: [{
content: [{ type: 'text', text: 'wrapper context' }],
source: { kind: 'plugin', plugin: 'wrapper' },
}],
}
})
const controller = new AbortController()
const pending = ctx.tools.execute({
callId: CallId('cancelled-short-circuit'),
name: 'short-circuited',
arguments: {},
signal: controller.signal,
})
await entered.promise
controller.abort('cancelled while wrapper waited')
release.resolve(undefined)
await expect(pending).resolves.toMatchObject({
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
additionalContexts: [{ source: { kind: 'plugin', plugin: 'wrapper' } }],
})
expect(dispatched).toBe(0)
})
@@ -662,7 +774,7 @@ describe('ToolRegistry', () => {
await expect(pending).resolves.toMatchObject({
content: [{ type: 'text', text: 'Error: tool call aborted' }],
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED },
additionalContexts: [{ source: { kind: 'plugin', plugin: 'child' } }],
})
})
@@ -713,6 +825,94 @@ describe('ToolRegistry', () => {
})
})
it('preserves an around-dispatch failure that settles after cancellation', async () => {
const ctx = await setup()
let dispatched = 0
ctx.tools.register({
...echoTool,
name: 'wrapper-failure',
async execute() { dispatched += 1; return [] },
})
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.on('tools/execute', async () => {
entered.resolve(undefined)
await release.promise
throw new HarnessError('wrapper failed', 'WRAPPER_FAILURE')
})
const controller = new AbortController()
const pending = ctx.tools.execute({
callId: CallId('wrapper-failure'), name: 'wrapper-failure', arguments: {}, signal: controller.signal,
})
await entered.promise
controller.abort('cancelled while wrapper failed')
release.resolve(undefined)
await expect(pending).resolves.toMatchObject({
content: [{ type: 'text', text: 'Error: wrapper failed' }],
isError: true,
error: { name: 'HarnessError', code: 'WRAPPER_FAILURE' },
})
expect(dispatched).toBe(0)
})
it('preserves a tool-owned failure after the body observes cancellation', async () => {
const ctx = await setup()
const entered = Promise.withResolvers<undefined>()
ctx.tools.register({
...echoTool,
name: 'tool-failure',
execute(_args, exec) {
entered.resolve(undefined)
return new Promise<never[]>((_resolve, reject) => {
exec.signal.addEventListener('abort', () => {
reject(new HarnessError('tool failed', 'TOOL_FAILURE'))
}, { once: true })
})
},
})
const controller = new AbortController()
const pending = ctx.tools.execute({
callId: CallId('tool-failure'), name: 'tool-failure', arguments: {}, signal: controller.signal,
})
await entered.promise
controller.abort('cancelled running body')
await expect(pending).resolves.toMatchObject({
content: [{ type: 'text', text: 'Error: tool failed' }],
isError: true,
error: { name: 'HarnessError', code: 'TOOL_FAILURE' },
})
})
it('preserves a post-policy failure that settles after cancellation', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.on('tools/post-execute', async () => {
entered.resolve(undefined)
await release.promise
throw new HarnessError('post-policy failed', 'POST_FAILURE')
})
const controller = new AbortController()
const pending = ctx.tools.execute({
callId: CallId('post-failure'), name: 'echo', arguments: {}, signal: controller.signal,
})
await entered.promise
controller.abort('cancelled while post-policy failed')
release.resolve(undefined)
await expect(pending).resolves.toMatchObject({
content: [{ type: 'text', text: 'Error: post-policy failed' }],
isError: true,
error: { name: 'HarnessError', code: 'POST_FAILURE' },
})
})
it('fuses caller cancellation back into a wrapper replacement for the running body', async () => {
const ctx = await setup()
const entered = Promise.withResolvers<undefined>()
@@ -724,9 +924,9 @@ describe('ToolRegistry', () => {
execute(_args, exec) {
bodySignal = exec.signal
entered.resolve(undefined)
if (exec.signal?.aborted) return Promise.resolve([])
if (exec.signal.aborted) return Promise.resolve([])
return new Promise((resolve) => {
exec.signal?.addEventListener('abort', () => { resolve([]) }, { once: true })
exec.signal.addEventListener('abort', () => { resolve([]) }, { once: true })
})
},
})
@@ -736,8 +936,7 @@ describe('ToolRegistry', () => {
try {
return await next()
} finally {
if (upstream === undefined) delete exec.signal
else exec.signal = upstream
exec.signal = upstream
}
})
@@ -758,30 +957,29 @@ describe('ToolRegistry', () => {
expect(replacement.signal.aborted).toBe(false)
})
it('restores a removed caller signal for dispatch', async () => {
it('restores the required caller signal after around dispatch', async () => {
const ctx = await setup()
let bodySignal: AbortSignal | undefined
ctx.tools.register({
...echoTool,
name: 'signal-probe',
async execute(_args, exec) { bodySignal = exec.signal; return [] },
})
let postSignal: AbortSignal | undefined
ctx.on('tools/execute', async (exec, next) => {
const upstream = exec.signal
delete exec.signal
exec.signal = new AbortController().signal
try {
return await next()
} finally {
if (upstream !== undefined) exec.signal = upstream
exec.signal = upstream
}
})
ctx.on('tools/post-execute', async (exec, _result, next) => {
postSignal = exec.signal
return next()
})
const controller = new AbortController()
await ctx.tools.execute({
callId: CallId('restored-signal'), name: 'signal-probe', arguments: {}, signal: controller.signal,
callId: CallId('restored-signal'), name: 'echo', arguments: {}, signal: controller.signal,
})
expect(bodySignal).toBe(controller.signal)
expect(postSignal).toBe(controller.signal)
})
it('waits for an uncooperative started body before returning ABORTED', async () => {
@@ -820,25 +1018,75 @@ describe('ToolRegistry', () => {
})
})
it('lets an already-aborted entry signal reach the body for domain-specific cleanup', async () => {
it('materializes a pre-aborted call and publishes one result without entering pipeline phases', async () => {
const ctx = await setup()
let dispatched = 0
const phases = { pre: 0, around: 0, body: 0, post: 0, result: 0 }
const callerArguments = { nested: { value: 1 } }
const callerSignal = AbortSignal.abort('already cancelled')
let argumentReads = 0
let observedArguments: unknown
let observedExecution: object | undefined
let observedToken: symbol | undefined
let observedSignal: AbortSignal | undefined
let observedResult: ToolExecutionResult | undefined
ctx.tools.register({
...echoTool,
name: 'domain-abort',
async execute(_args, exec) {
dispatched += 1
expect(exec.signal?.aborted).toBe(true)
throw new HarnessError('domain cleanup completed', 'DOMAIN_ABORTED')
},
async execute() { phases.body += 1; return [] },
})
ctx.on('tools/pre-execute', async (_exec, next) => { phases.pre += 1; return next() })
ctx.on('tools/execute', async (_exec, next) => { phases.around += 1; return next() })
ctx.on('tools/post-execute', async (_exec, _result, next) => { phases.post += 1; return next() })
ctx.on('tools/result', (exec, result) => {
phases.result += 1
observedExecution = exec
observedArguments = exec.arguments
observedToken = exec.token
observedSignal = exec.signal
observedResult = result
})
const result = await ctx.tools.execute({
callId: CallId('pre-aborted'), name: 'domain-abort', arguments: {}, signal: AbortSignal.abort(),
callId: CallId('pre-aborted'),
name: 'domain-abort',
get arguments() { argumentReads += 1; return callerArguments },
signal: callerSignal,
})
expect(dispatched).toBe(1)
expect(result.error).toEqual({ name: 'HarnessError', code: 'DOMAIN_ABORTED' })
expect(argumentReads).toBe(1)
expect(phases).toEqual({ pre: 0, around: 0, body: 0, post: 0, result: 1 })
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
expect(observedResult).toBe(result)
expect(Object.isFrozen(observedExecution)).toBe(true)
expect(typeof observedToken).toBe('symbol')
expect(observedSignal).toBe(callerSignal)
expect(Object.isFrozen(result)).toBe(true)
expect(observedArguments).not.toBe(callerArguments)
expect(Object.isFrozen(observedArguments)).toBe(true)
expect(Object.isFrozen((observedArguments as { nested: object }).nested)).toBe(true)
})
it('lets argument materialization failure win over a pre-aborted signal', async () => {
const ctx = await setup()
let observed = 0
ctx.on('tools/result', () => { observed += 1 })
const result = await ctx.tools.execute({
callId: CallId('invalid-pre-aborted'),
name: 'missing',
arguments: { invalid: () => undefined },
signal: AbortSignal.abort('already cancelled'),
})
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: tool execution arguments must be losslessly JSON-serializable' }],
isError: true,
})
expect(observed).toBe(1)
})
it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => {
@@ -847,12 +1095,12 @@ describe('ToolRegistry', () => {
let entered = false
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'deny', reason: 'nope' }))
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
entered = true
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: nope' })
expect(entered).toBe(false) // a denied call never enters the around-dispatch seam
@@ -867,7 +1115,7 @@ describe('ToolRegistry', () => {
})
let seen: { isError: boolean; error?: unknown } | undefined
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
const result = await next()
// The base next() IS dispatch-with-normalization: the wrapper sees the
// normalized isError result, never a raw throw from the tool body.
@@ -875,7 +1123,7 @@ describe('ToolRegistry', () => {
return result
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'boom', arguments: {} })
expect(seen).toEqual({ isError: true, error: { name: 'HarnessError', code: 'BOOM' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
@@ -890,13 +1138,13 @@ describe('ToolRegistry', () => {
})
let postSaw: boolean | undefined
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => next())
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => next())
ctx.on('tools/post-execute', async (_exec, result, next) => {
postSaw = result.isError
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'boom', arguments: {} })
expect(postSaw).toBe(true) // the normalized isError still flows through post-execute
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: exploded' })
@@ -916,7 +1164,7 @@ describe('ToolRegistry', () => {
const upstream = new AbortController().signal
const replacement = new AbortController().signal
ctx.on('tools/execute', async (exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
ctx.on('tools/execute', async (exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
expect(exec.signal).toBe(upstream)
// Cordis next() ignores passed arguments, so a wrapper mutates exec in
// place (the documented "mutate the shared object, then delegate" idiom).
@@ -939,10 +1187,10 @@ describe('ToolRegistry', () => {
async execute() { dispatched = true; return [] },
})
ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
ctx.on('tools/execute', async (_exec: ToolDispatchExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
({ content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'never-runs', arguments: {} })
expect(dispatched).toBe(false) // returning without next() skips core dispatch
expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
})
@@ -960,6 +1208,7 @@ describe('ToolRegistry', () => {
}))
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('around-context'), name: 'echo', arguments: {},
})
expect(result.additionalContexts).toEqual([{
@@ -973,7 +1222,7 @@ describe('ToolRegistry', () => {
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => { throw new Error('wrapper broke') })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: wrapper broke' }],
isError: true,
@@ -987,7 +1236,7 @@ describe('ToolRegistry', () => {
throw new Error('permission hook broke')
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: permission hook broke' }],
@@ -1002,7 +1251,7 @@ describe('ToolRegistry', () => {
throw new Error('post hook broke')
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
content: [{ type: 'text', text: 'Error: post hook broke' }],
@@ -1017,7 +1266,7 @@ describe('ToolRegistry', () => {
throw new HarnessError('denied', 'DENIED')
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toMatchObject({
isError: true,
@@ -1204,6 +1453,7 @@ describe('defineTool / schema DSL', () => {
}])
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('c1'),
name: 'typed-echo',
arguments: { text: 'hello', uppercase: true },
@@ -1258,6 +1508,7 @@ describe('defineTool / schema DSL', () => {
// Execution round-trip
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('c1'),
name: 'roundtrip',
arguments: { req: 'hello' },
@@ -1290,6 +1541,7 @@ describe('defineTool / schema DSL', () => {
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('c1'),
name: 'raw-tool',
arguments: { path: '/tmp' },
@@ -1463,7 +1715,7 @@ describe('schema DSL optional and nested contracts', () => {
throw { message: 'denied by object' }
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-thrower', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'object-thrower', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: denied by object' })
})
@@ -1478,7 +1730,7 @@ describe('schema DSL optional and nested contracts', () => {
throw 'kaboom'
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'string-thrower', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'string-thrower', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
})
@@ -1493,7 +1745,7 @@ describe('schema DSL optional and nested contracts', () => {
throw { code: 500 }
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'object-no-message', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'object-no-message', arguments: {} })
expect(result.isError).toBe(true)
const firstContent = result.content[0]!
expect(firstContent.type).toBe('text')
@@ -1630,7 +1882,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
},
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'reader', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({
text: 'Error: invalid arguments: missing required property "path"',
@@ -1647,7 +1899,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
return [{ type: 'text', text: `read ${args.path}` }]
},
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false })
})
@@ -1670,7 +1922,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
return [{ type: 'text', text: args.path }]
},
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'reader', arguments: {} })
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'ToolArgsError', code: 'INVALID_ARGS' })
})
@@ -1685,7 +1937,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
throw new HarnessError('disk full', 'ENOSPC')
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'coded', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'coded', arguments: {} })
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'HarnessError', code: 'ENOSPC' })
expect(result.content[0]).toMatchObject({ text: 'Error: disk full' })
@@ -1700,7 +1952,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
throw new Error('just a message')
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'plain', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'plain', arguments: {} })
expect(result.isError).toBe(true)
expect(result.error).toBeUndefined()
expect(result.content[0]).toMatchObject({ text: 'Error: just a message' })
@@ -1719,7 +1971,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
})
// Missing the "required" path — but raw tools validate their own input, so
// this reaches execute rather than being rejected by the harness.
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'raw', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'raw', arguments: {} })
expect(result.isError).toBe(false)
})

View File

@@ -13,6 +13,8 @@ import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-a
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
const testToolSignal = new AbortController().signal
declare module '@deepseek-ai/dsh-tasks' {
interface TaskKindMap {
probe: 'probe'
@@ -264,6 +266,7 @@ describe('dsh-agent-spine-demo bundle', () => {
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['shared-skill'])
const execution: ToolExecution = {
signal: testToolSignal,
token: Symbol('agent-core-dsh-home-test') as ToolExecution['token'],
callId: CallId('agent-core-dsh-home'),
name: 'bash',
@@ -335,11 +338,12 @@ describe('dsh-agent-spine-demo bundle', () => {
})
const wait = vi.spyOn(ctx.tasks, 'wait')
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('task-config-forwarding'),
name: 'task_output',
arguments: { task_id: id, wait: true },
})
expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined)
expect(wait).toHaveBeenCalledWith(id, 7, undefined, testToolSignal)
await ctx.fiber.dispose()
})

View File

@@ -10,6 +10,8 @@ import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { afterEach, describe, expect, it, vi } from 'vitest'
import * as cliDemo from '../src/index.ts'
const testToolSignal = new AbortController().signal
const contexts: Context[] = []
async function skillConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<cliDemo.Config['skills']>> {
@@ -122,6 +124,7 @@ describe('dsh-cli-demo app composition', () => {
expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3)
const execution: ToolExecution = {
signal: testToolSignal,
token: Symbol('cli-demo-dsh-home-test') as ToolExecution['token'],
callId: CallId('cli-demo-dsh-home'),
name: 'bash',
@@ -139,11 +142,12 @@ describe('dsh-cli-demo app composition', () => {
})
const wait = vi.spyOn(ctx.tasks, 'wait')
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('cli-demo-task-config'),
name: 'task_output',
arguments: { task_id: id, wait: true },
})
expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined)
expect(wait).toHaveBeenCalledWith(id, 7, undefined, testToolSignal)
})
it('accepts false to keep task services without model-facing task controls', async () => {

View File

@@ -162,7 +162,7 @@ export async function runRipgrep(
command,
stdoutMaxBytes: rawOutputMaxBytes,
...cwd !== undefined ? { workdir: cwd } : {},
...exec.signal ? { signal: exec.signal } : {},
signal: exec.signal,
})
let result: BashRunResult
try {

View File

@@ -16,10 +16,12 @@ import { join } from 'node:path'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
const testToolSignal = new AbortController().signal
const hasRg = spawnSync('rg', ['--version'], { encoding: 'utf8' }).status === 0
let dir: string
@@ -28,6 +30,7 @@ let ctx: Context
let callCounter = 0
function call(name: string, args: unknown, agentObj?: object) {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`it-${++callCounter}`),
name,
arguments: args,
@@ -165,8 +168,8 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
})
})
describe('bash-start infrastructure failures stay in the SEARCH_* taxonomy', () => {
it('a pre-aborted exec.signal (real executor rejects before spawn) is SEARCH_ABORTED', async () => {
describe('pre-dispatch cancellation and bash-start failures', () => {
it('a pre-aborted registry call is ABORTED_BEFORE_DISPATCH', async () => {
const controller = new AbortController()
controller.abort()
const result = await ctx.tools.execute({
@@ -176,7 +179,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
signal: controller.signal,
})
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
expect(result.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
})
it('an unusable session cwd (spawn failure) is SEARCH_FAILED', async () => {

View File

@@ -14,7 +14,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
@@ -31,6 +31,8 @@ import {
toWorkdirRelative,
} from '@deepseek-ai/dsh-tool-fs-search'
const testToolSignal = new AbortController().signal
/** A successful run result over the given stdout; overrides script the failure shapes. */
function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunResult {
return {
@@ -55,6 +57,7 @@ class FakeBash extends BashExecutor {
requests: BashExecRequest[] = []
specs: BashExecSpec[] = []
startCalls = 0
forwardSignal = true
handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
override resolve(request: BashExecRequest): BashExecSpec {
@@ -64,7 +67,7 @@ class FakeBash extends BashExecutor {
workdir: request.workdir ?? '/work',
timeoutMs: request.timeoutMs ?? 60_000,
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
...this.forwardSignal ? { signal: request.signal } : {},
sandboxMode: request.sandboxMode,
}
}
@@ -117,6 +120,7 @@ const agent = (cwd?: string) => ({ session: { header: { id: 'session-1', ...cwd
let callCounter = 0
function call(ctx: Context, name: string, args: unknown, options: { agent?: object; signal?: AbortSignal } = {}) {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`call-${++callCounter}`),
name,
arguments: args,
@@ -248,16 +252,13 @@ describe('workdir derivation and signal forwarding', () => {
expect(bash.requests[1]).not.toHaveProperty('workdir')
})
it('forwards exec.signal into the bash spec (the abort reaches the backend)', async () => {
it('forwards exec.signal into the bash spec', async () => {
const { ctx, bash } = await setup()
const controller = new AbortController()
controller.abort()
bash.handler = spec => runResult('', { aborted: spec.signal?.aborted === true })
bash.handler = () => runResult('')
const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal })
expect(bash.specs[0]?.signal).toBe(controller.signal)
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
expect(text(result)).toContain('aborted')
expect(result.isError).toBe(false)
})
it('reports the bash executor timeout as SEARCH_ABORTED with the budget', async () => {
@@ -269,20 +270,46 @@ describe('workdir derivation and signal forwarding', () => {
expect(text(result)).toContain('timed out after 1234ms')
})
it('translates a run() rejection under a pre-aborted signal into SEARCH_ABORTED', async () => {
// The seam contract: run() REJECTS for a pre-aborted signal (it never
// spawns). The plain rejection must not escape the SEARCH_* taxonomy.
it('skips a pre-aborted registry call before run()', async () => {
const { ctx, bash } = await setup()
const controller = new AbortController()
controller.abort()
bash.handler = () => { throw new Error('aborted before spawn') }
const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(bash.specs).toHaveLength(0)
})
it('translates a run() rejection after the forwarded signal aborts', async () => {
const { ctx, bash } = await setup()
const controller = new AbortController()
bash.handler = () => {
controller.abort('cancel search')
throw new Error('executor stopped on abort')
}
const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
expect(text(result)).toContain('aborted before completion')
})
it('translates an aborted executor result after dispatch starts', async () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('', { aborted: true, exitCode: null })
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'SearchError', code: 'SEARCH_ABORTED' })
expect(text(result)).toContain('aborted before completion')
})
it('translates a run() rejection without an abort (unusable workdir) into SEARCH_FAILED', async () => {
const { ctx, bash } = await setup()
bash.forwardSignal = false
bash.handler = () => { throw new Error('spawn bash ENOENT') }
const result = await call(ctx, 'glob', { pattern: '*' })
expect(result.isError).toBe(true)

View File

@@ -28,6 +28,6 @@ export function sessionResolveOptions(exec: ToolExecution): { cwd?: string; sign
const cwd = sessionCwd(exec)
return {
...cwd !== undefined ? { cwd } : {},
...exec.signal !== undefined ? { signal: exec.signal } : {},
signal: exec.signal,
}
}

View File

@@ -12,11 +12,13 @@ import { join } from 'node:path'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
const testToolSignal = new AbortController().signal
let dir: string
let ctx: Context
let fiber: Awaited<ReturnType<Context['plugin']>>
@@ -26,6 +28,7 @@ const session = { header: {} }
let callCounter = 0
function call(name: string, args: unknown) {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`call-${++callCounter}`),
name,
arguments: args,
@@ -299,6 +302,7 @@ describe('per-session cwd', () => {
const callIn = (sessionObj: object, name: string, args: unknown) =>
ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`call-${++callCounter}`),
name,
arguments: args,
@@ -344,26 +348,25 @@ describe('signal, concurrency, and the fs/observed contract', () => {
const callSig = (signal: AbortSignal, name: string, args: unknown) =>
ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never, signal })
const callOwned = (name: string, args: unknown) =>
ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never })
ctx.tools.execute({ signal: testToolSignal, callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never })
it('a pre-aborted signal makes read/write/edit return isError FS_ABORTED', async () => {
it('a pre-aborted registry call skips read/write/edit with ABORTED_BEFORE_DISPATCH', async () => {
await writeFile(join(dir, 'a.txt'), 'hello')
const read = await callSig(AbortSignal.abort(), 'read', { file_path: 'a.txt' })
expect(read.isError).toBe(true)
expect(read.error).toMatchObject({ code: 'FS_ABORTED' })
expect(read.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
const write = await callSig(AbortSignal.abort(), 'write', { file_path: 'new.txt', content: 'x' })
expect(write.isError).toBe(true)
expect(write.error).toMatchObject({ code: 'FS_ABORTED' })
expect(write.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
await expect(readFile(join(dir, 'new.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
// Read first (un-aborted, SAME session owner) so the edit clears the
// observation gate; then the aborted edit fails on the signal, not on
// FS_NOT_OBSERVED.
// observation gate; then the registry skips the aborted edit before its body.
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
const edit = await callSig(AbortSignal.abort(), 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' })
expect(edit.isError).toBe(true)
expect(edit.error).toMatchObject({ code: 'FS_ABORTED' })
expect(edit.error).toMatchObject({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello') // unchanged
})

View File

@@ -25,6 +25,8 @@ import { STREAM_MIN_SIZE } from '../src/read.ts'
import { formatReadOutput } from '../src/read-render.ts'
import type { FileReadOutcome } from '../src/read-render.ts'
const testToolSignal = new AbortController().signal
/** An in-memory fake provider; a test can arm a rejection on any primitive. */
class FakeFs extends FileSystem {
files = new Map<string, string>()
@@ -91,6 +93,7 @@ async function setup() {
let callCounter = 0
function call(ctx: Context, name: string, args: unknown, agent?: object) {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`call-${++callCounter}`),
name,
arguments: args,
@@ -110,11 +113,11 @@ describe('registration', () => {
it('declares read parallel-safe while write/edit remain exclusive', async () => {
const { ctx } = await setup()
expect(ctx.tools.executionMode({ callId: CallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } }))
expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } }))
.toEqual({ kind: 'parallel' })
expect(ctx.tools.executionMode({ callId: CallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } }))
expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } }))
.toEqual({ kind: 'exclusive' })
expect(ctx.tools.executionMode({ callId: CallId('edit-exclusive'), name: 'edit', arguments: { file_path: 'a.txt', old_string: 'x', new_string: 'y' } }))
expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('edit-exclusive'), name: 'edit', arguments: { file_path: 'a.txt', old_string: 'x', new_string: 'y' } }))
.toEqual({ kind: 'exclusive' })
})

View File

@@ -10,6 +10,8 @@ import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard'
import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
const testToolSignal = new AbortController().signal
/**
* Behavior suite for the repeat-tool-call guard: chain semantics (identical /
* different-tracked / untracked-transparent / per-agent / resets), threshold
@@ -284,7 +286,7 @@ describe('chain semantics', () => {
it('ignores direct executes with no agent (they neither crash nor advance any chain)', async () => {
const ctx = await harness({ thresholds: [2] })
const direct = await ctx.tools.execute({ callId: CallId('d1'), name: 'probe', arguments: { q: 1 } })
const direct = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('d1'), name: 'probe', arguments: { q: 1 } })
expect(direct.isError).toBe(false)
ctx.llm.registerAdapter(['mock'], new MockAdapter([

View File

@@ -231,7 +231,7 @@ export function apply(ctx: Context, config: Config): void {
// --- PreToolUse → PreToolDecision. Matcher subject is the tool name. ---
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
const turn = lastTurn(exec.agent)
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} }
return next()
@@ -240,7 +240,7 @@ export function apply(ctx: Context, config: Config): void {
// --- PostToolUse → PostToolDecision. Matcher subject is the tool name. ---
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
const turn = lastTurn(exec.agent)
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
const context = contextFrom(merged)
if (merged.decision === 'deny') {
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} }

View File

@@ -14,6 +14,8 @@ import { SubagentRunId } from '@deepseek-ai/dsh-subagent'
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
const testToolSignal = new AbortController().signal
/** Targeted branch coverage for the CC bridge: option arms, warn paths, no-agent
* fallbacks, contextFrom-empty, and the detached-listener catch handlers. */
@@ -145,7 +147,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
// Call execute() directly with NO agent — the bridge's no-agent/no-turn path.
const { CallId } = await import('@deepseek-ai/dsh-llm')
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} })
expect(ran).toBe(false)
expect(result.isError).toBe(true)
})

View File

@@ -203,7 +203,7 @@ export function apply(ctx: Context, config: Config): void {
// PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored).
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
const turn = lastTurn(exec.agent)
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
/* jscpd:ignore-end */
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
return next()
@@ -213,7 +213,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
const turn = lastTurn(exec.agent)
/* jscpd:ignore-start */
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
const context = contextFrom(merged)
if (merged.decision === 'deny') {
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} }

View File

@@ -13,6 +13,8 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
const testToolSignal = new AbortController().signal
const dirs: string[] = []
afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) })
function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hx-cov-')); dirs.push(d); return d }
@@ -455,7 +457,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
let ran = false
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
const { CallId } = await import('@deepseek-ai/dsh-llm')
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } })
expect(ran).toBe(false) // denied
expect(result.isError).toBe(true)
})
@@ -466,7 +468,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([]))
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const { CallId } = await import('@deepseek-ai/dsh-llm')
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'Bash', arguments: { command: 'x' } })
expect(result.isError).toBeFalsy()
expect(result.additionalContexts?.[0]?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true)
})

View File

@@ -165,7 +165,7 @@ function createExecutor(
{ name: rawName, arguments: argsObj },
undefined,
{
...exec.signal ? { signal: exec.signal } : {},
signal: exec.signal,
timeout: opts.toolCallTimeoutMs,
},
)

View File

@@ -26,6 +26,8 @@ import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
import type { Config } from '@deepseek-ai/dsh-mcp-client'
const testToolSignal = new AbortController().signal
const fixtureServerPath = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
// Resolve package-local .bin for pnpm-hoisted MCP server binaries.
@@ -119,6 +121,7 @@ describe('fixture server — controlled scenarios', () => {
it('executes the dotted tool via its normalized public name', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: publicToolName('fixture', 'admin.reset'), arguments: {},
})
expect(result.isError).toBe(false)
@@ -127,6 +130,7 @@ describe('fixture server — controlled scenarios', () => {
it('executes add(2, 3) → "5"', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__fixture__add', arguments: { a: 2, b: 3 },
})
expect(result.isError).toBe(false)
@@ -135,6 +139,7 @@ describe('fixture server — controlled scenarios', () => {
it('executes greet("World") → "Hello, World!"', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__fixture__greet', arguments: { name: 'World' },
})
expect(result.isError).toBe(false)
@@ -143,6 +148,7 @@ describe('fixture server — controlled scenarios', () => {
it('executes fail() → isError result', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__fixture__fail', arguments: {},
})
expect(result.isError).toBe(true)
@@ -151,6 +157,7 @@ describe('fixture server — controlled scenarios', () => {
it('executes image() → image placeholder', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__fixture__image', arguments: {},
})
expect(result.isError).toBe(false)
@@ -241,6 +248,7 @@ describe('server-everything — official test server', () => {
it('executes echo({ message: "hello" }) → "Echo: hello"', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__everything__echo', arguments: { message: 'hello' },
})
expect(result.isError).toBe(false)
@@ -249,6 +257,7 @@ describe('server-everything — official test server', () => {
it('executes get-sum({ a: 3, b: 7 }) → contains "10"', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__everything__get-sum', arguments: { a: 3, b: 7 },
})
expect(result.isError).toBe(false)
@@ -257,6 +266,7 @@ describe('server-everything — official test server', () => {
it('executes get-tiny-image → image placeholder', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__everything__get-tiny-image', arguments: {},
})
expect(result.isError).toBe(false)
@@ -306,6 +316,7 @@ describe('server-filesystem — real filesystem operations', () => {
// Write via MCP tool
const writeResult = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content },
})
expect(writeResult.isError).toBe(false)
@@ -316,6 +327,7 @@ describe('server-filesystem — real filesystem operations', () => {
// Read back via MCP tool
const readResult = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__filesystem__read_file', arguments: { path: filePath },
})
expect(readResult.isError).toBe(false)
@@ -327,6 +339,7 @@ describe('server-filesystem — real filesystem operations', () => {
await writeFile(join(tempDir, 'listed.txt'), 'listed')
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__filesystem__list_directory', arguments: { path: tempDir },
})
expect(result.isError).toBe(false)
@@ -418,6 +431,7 @@ describe('streamable-http — in-process MCP server', () => {
it('executes ping() → "pong" over HTTP', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__web__ping', arguments: {},
})
expect(result.isError).toBe(false)
@@ -426,6 +440,7 @@ describe('streamable-http — in-process MCP server', () => {
it('executes shout({ message }) with args over HTTP', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__web__shout', arguments: { message: 'quiet' },
})
expect(result.isError).toBe(false)

View File

@@ -7,6 +7,8 @@ import { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/
import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts'
import type { Config } from '@deepseek-ai/dsh-mcp-client'
const testToolSignal = new AbortController().signal
// ---- Mock MCP Client ----
interface MockTool {
@@ -122,7 +124,7 @@ describe('syncTools', () => {
expect(ctx.tools.get('search')).toBeDefined()
expect(ctx.tools.get('mcp__srv__search')).toBeDefined()
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'search', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'search', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: 'native' })
})
@@ -217,7 +219,7 @@ describe('tool execution', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } })
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'hello world' }])
@@ -237,7 +239,7 @@ describe('tool execution', () => {
await syncTools(client as never, ctx, defaultOpts, new Map())
const publicName = publicToolName('srv', 'admin.reset')
const result = await ctx.tools.execute({ callId: CallId('c1'), name: publicName, arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: publicName, arguments: {} })
expect(result.isError).toBe(false)
expect(client.callTool).toHaveBeenCalledWith(
@@ -254,7 +256,7 @@ describe('tool execution', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__multi', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__multi', arguments: {} })
expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }])
})
@@ -266,7 +268,7 @@ describe('tool execution', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' })
})
@@ -278,7 +280,7 @@ describe('tool execution', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__fail', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__fail', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' })
@@ -308,7 +310,7 @@ describe('tool execution', () => {
client.callTool.mockResolvedValue({ toolResult: { key: 'value' } })
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__legacy', arguments: {} })
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' })
@@ -329,7 +331,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[audio: audio/mp3, content discarded]' })
})
@@ -341,7 +343,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
})
@@ -353,7 +355,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
})
@@ -365,7 +367,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported content type: video]' })
})
@@ -377,7 +379,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[image: unknown, content discarded]' })
})
@@ -389,7 +391,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[audio: unknown, content discarded]' })
})
@@ -401,7 +403,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' })
})
@@ -413,7 +415,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' })
})
@@ -426,7 +428,7 @@ describe('tool execution edge cases', () => {
client.callTool.mockResolvedValue({})
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' })
})
@@ -438,7 +440,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toEqual({ type: 'text', text: 'Error: [image: image/png, content discarded]' })
@@ -575,7 +577,7 @@ describe('tool execution — non-object args fallback', () => {
await syncTools(client as never, ctx, defaultOpts, new Map())
// Simulate model emitting `null` as tool arguments (malformed).
await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null })
await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null })
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'coerce', arguments: {} },
@@ -591,7 +593,7 @@ describe('tool execution — non-object args fallback', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce2', arguments: 'bad' })
await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__coerce2', arguments: 'bad' })
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'coerce2', arguments: {} },

View File

@@ -12,6 +12,8 @@ import SkillService from '@deepseek-ai/dsh-skill'
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
const testToolSignal = new AbortController().signal
async function tempDir(name: string): Promise<string> {
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
}
@@ -219,6 +221,7 @@ describe('dsh-tool-skill', () => {
const ctx = await setup(home)
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('c1'),
name: 'skill',
arguments: { name: 'project-skill' },
@@ -271,9 +274,9 @@ describe('dsh-tool-skill', () => {
content: 'Provider instructions.',
})
const opaque = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } })
const url = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'url-skill' } })
const provider = await ctx.tools.execute({ callId: CallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } })
const opaque = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } })
const url = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c3'), name: 'skill', arguments: { name: 'url-skill' } })
const provider = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } })
if (opaque.content[0]?.type !== 'text' || url.content[0]?.type !== 'text' || provider.content[0]?.type !== 'text') {
throw new Error('expected text tool results')
@@ -295,7 +298,7 @@ describe('dsh-tool-skill', () => {
content: 'Rogue instructions.',
})
const result = await ctx.tools.execute({ callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } })
expect(result.isError).toBe(true)
const block = result.content[0]
@@ -309,9 +312,9 @@ describe('dsh-tool-skill', () => {
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: Hidden skill\ndisableModelInvocation: true\n---\n\nHidden instructions.\n')
const ctx = await setup(home)
const unknown = await ctx.tools.execute({ callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } })
const invalid = await ctx.tools.execute({ callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } })
const disabled = await ctx.tools.execute({ callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } })
const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'skill', arguments: { name: 'missing' } })
const invalid = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } })
const disabled = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } })
expect(unknown.isError).toBe(true)
expect(invalid.isError).toBe(true)

View File

@@ -21,6 +21,8 @@ import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
const testToolSignal = new AbortController().signal
/** A stub spill backend recording its saves; `fail` exercises the best-effort fallback. */
class StubStore extends SpillStore {
saves: SaveTextSpill[] = []
@@ -51,7 +53,7 @@ function textTool(name: string, text: string) {
function exec(name: string, session = 's1'): ToolExecution {
// Only agent.session.header.id is read by the policy; a structural stub suffices.
const agent = { session: { header: { id: SessionId(session) } } }
return { callId: CallId(`call-${name}`), name, arguments: {}, agent } as unknown as ToolExecution
return { callId: CallId(`call-${name}`), name, arguments: {}, agent, signal: testToolSignal } as unknown as ToolExecution
}
/**
@@ -208,7 +210,7 @@ describe('best-effort fallback', () => {
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
const result = await ctx.tools.execute({ callId: CallId('c'), name: 'big', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c'), name: 'big', arguments: {} })
expect(textOf(result.content)).toBe('x'.repeat(1000))
expect(spill?.saves).toHaveLength(0)
expect(warn).toHaveBeenCalled()

View File

@@ -16,6 +16,8 @@ import {
STRUCTURED_OUTPUT_TOOL,
} from '../src/structured.ts'
const testToolSignal = new AbortController().signal
type Script = ConstructorParameters<typeof MockAdapter>[0]
interface CodeRunRequestLike {
@@ -640,6 +642,7 @@ describe('in-process structured output', () => {
it('a structured_output call from an agent WITHOUT a structured run is UNKNOWN_TOOL (the tool does not exist for it)', async () => {
const { ctx, parent } = await setup([])
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: 'x' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 1 },
@@ -652,6 +655,7 @@ describe('in-process structured output', () => {
it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => {
const { ctx } = await setup([])
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: 'x' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 1 },
@@ -684,6 +688,7 @@ describe('in-process structured output', () => {
// …and a LATER invalid call (its own body staged nothing) must not
// resurrect c1's discarded value: drive the pipeline directly.
const invalid = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c2' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 'not-a-number' },
@@ -692,6 +697,7 @@ describe('in-process structured output', () => {
expect(invalid.isError).toBe(true)
// A fresh valid call still captures ITS OWN value.
const valid = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c3' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 9 },
@@ -722,6 +728,7 @@ describe('in-process structured output', () => {
// (invalid args throw before the stage): the discarded value must not ride
// its acceptance.
const reused = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c1' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 'not-a-number' },
@@ -730,6 +737,7 @@ describe('in-process structured output', () => {
expect(reused.isError).toBe(true)
// Nothing was ever committed: a fresh valid call is still required.
const valid = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c1' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 5 },
@@ -764,6 +772,7 @@ describe('in-process structured output', () => {
return undefined as never
}, { prepend: true })
const denied = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c1' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 2 },
@@ -774,6 +783,7 @@ describe('in-process structured output', () => {
// The discarded value was never promoted: a fresh valid call is required
// (and succeeds, proving the runtime is not wedged).
const valid = await ctx.tools.execute({
signal: testToolSignal,
callId: 'c1' as never,
name: STRUCTURED_OUTPUT_TOOL,
arguments: { answer: 5 },

View File

@@ -270,9 +270,6 @@ export function apply(ctx: Context, config: Config): void {
if (tasks === undefined) {
throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
}
// Reject cancellation before spawning; after return, the task-owned
// signal covers both pending startup and the ready child.
if (exec.signal?.aborted) throw new Error('subagent delegation aborted')
// Task preflight finishes before the starter can spawn a child.
const id = tasks.start({
kind: 'subagent',
@@ -300,7 +297,7 @@ export function apply(ctx: Context, config: Config): void {
config,
args.prompt,
parent,
exec.signal ?? new AbortController().signal,
exec.signal,
)
const run: SubagentRun = await ctx.subagents.start(config.provider, request)

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import { type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SubagentService from '@deepseek-ai/dsh-subagent'
@@ -14,6 +14,8 @@ import * as tool from '../src/index.ts'
import { runOutcome, settleRun } from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
const testToolSignal = new AbortController().signal
/**
* Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real
* `ToolRegistry` + `SubagentService`, with a package-local scripted child
@@ -44,6 +46,7 @@ function callSubagent(ctx: Context, args: unknown, over: { agent?: Agent | undef
// exactOptionalPropertyTypes the key is omitted rather than set to undefined.
const agent = 'agent' in over ? over.agent : fakeAgent()
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`call-${++callCounter}`),
name: 'subagent',
arguments: args,
@@ -99,11 +102,13 @@ describe('dsh-tool-subagent', () => {
it('keeps foreground and background calls exclusive', async () => {
const ctx = await setup({ provider: 'mock' })
expect(ctx.tools.executionMode({
signal: testToolSignal,
callId: CallId('subagent-foreground'),
name: 'subagent',
arguments: { description: 'do work', prompt: 'Reply OK' },
})).toEqual({ kind: 'exclusive' })
expect(ctx.tools.executionMode({
signal: testToolSignal,
callId: CallId('subagent-background'),
name: 'subagent',
arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true },
@@ -138,8 +143,8 @@ describe('dsh-tool-subagent', () => {
const names = ctx.tools.schemas().map(s => s.name).filter(n => n.startsWith('subagent')).sort()
expect(names).toEqual(['subagent', 'subagent_acp'])
const viaSpawn = await ctx.tools.execute({ callId: CallId('c-spawn'), name: 'subagent', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() })
const viaAcp = await ctx.tools.execute({ callId: CallId('c-acp'), name: 'subagent_acp', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() })
const viaSpawn = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c-spawn'), name: 'subagent', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() })
const viaAcp = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c-acp'), name: 'subagent_acp', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() })
expect(text(viaSpawn)).toBe('from spawn')
expect(text(viaAcp)).toBe('from acp')
})
@@ -417,7 +422,7 @@ describe('dsh-tool-subagent', () => {
expect(result.isError).toBe(true)
})
it('passes an already-aborted signal so provider startup rejects', async () => {
it('skips provider startup for an already-aborted signal', async () => {
const sawAborted = vi.fn()
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -437,8 +442,9 @@ describe('dsh-tool-subagent', () => {
const controller = new AbortController()
controller.abort() // already aborted BEFORE the tool runs
const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal })
expect(sawAborted).toHaveBeenCalledTimes(1)
expect(sawAborted).not.toHaveBeenCalled()
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
})
it('tools depend on the service: no `subagent` tool without ctx.subagents', async () => {
@@ -639,6 +645,7 @@ describe('dsh-tool-subagent background mode', () => {
expect(text(start)).toBe('started background subagent task subagent-1')
const collected = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('collect-1'),
name: 'task_output',
arguments: { task_id: 'subagent-1', wait: true },
@@ -648,6 +655,7 @@ describe('dsh-tool-subagent background mode', () => {
// Final-output reads are idempotent (not consumed).
const again = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('collect-2'),
name: 'task_output',
arguments: { task_id: 'subagent-1' },
@@ -663,14 +671,15 @@ describe('dsh-tool-subagent background mode', () => {
expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks')
})
it('refuses to start when the tool signal is already aborted', async () => {
it('skips background startup when the tool signal is already aborted', async () => {
const ctx = await backgroundSetup({ provider: 'mock' })
const parent = ownerAgent(ctx, 'sess-parent')
const controller = new AbortController()
controller.abort()
const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent, signal: controller.signal })
expect(result.isError).toBe(true)
expect(text(result)).toContain('subagent delegation aborted')
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(text(result)).toBe('Error: tool call aborted before dispatch')
})
it('settles an asynchronous provider-start failure as a failed task', async () => {
@@ -685,6 +694,7 @@ describe('dsh-tool-subagent background mode', () => {
tool.apply(ctx, { provider: 'broken-start', toolName: 'subagent_broken' })
const started = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('broken-start'),
name: 'subagent_broken',
arguments: { description: 'broken', prompt: 'p', run_in_background: true },
@@ -692,6 +702,7 @@ describe('dsh-tool-subagent background mode', () => {
})
expect(text(started)).toBe('started background subagent task subagent-1')
const output = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('broken-output'),
name: 'task_output',
arguments: { task_id: 'subagent-1', wait: true },
@@ -714,18 +725,21 @@ describe('dsh-tool-subagent background mode', () => {
tool.apply(ctx, { provider: 'pending-start', toolName: 'subagent_pending' })
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('pending-start'),
name: 'subagent_pending',
arguments: { description: 'pending', prompt: 'p', run_in_background: true },
agent: parent,
})
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('pending-kill'),
name: 'task_kill',
arguments: { task_id: 'subagent-1', reason: 'no longer needed' },
agent: parent,
})
const output = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('pending-output'),
name: 'task_output',
arguments: { task_id: 'subagent-1', wait: true },
@@ -763,19 +777,19 @@ describe('dsh-tool-subagent background mode', () => {
// Direct apply preserves omitted agentOptions instead of applying schema defaults.
tool.apply(ctx, { provider: 'hanging', toolName: 'subagent_hang' })
const startOne = await ctx.tools.execute({ callId: CallId('h1'), name: 'subagent_hang', arguments: { description: 'one', prompt: 'p', run_in_background: true }, agent: parent })
const startTwo = await ctx.tools.execute({ callId: CallId('h2'), name: 'subagent_hang', arguments: { description: 'two', prompt: 'p', run_in_background: true }, agent: parent })
const startOne = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('h1'), name: 'subagent_hang', arguments: { description: 'one', prompt: 'p', run_in_background: true }, agent: parent })
const startTwo = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('h2'), name: 'subagent_hang', arguments: { description: 'two', prompt: 'p', run_in_background: true }, agent: parent })
expect(text(startOne)).toBe('started background subagent task subagent-1')
expect(text(startTwo)).toBe('started background subagent task subagent-2')
const withReason = await ctx.tools.execute({ callId: CallId('k1'), name: 'task_kill', arguments: { task_id: 'subagent-1', reason: 'superseded' }, agent: parent })
const withoutReason = await ctx.tools.execute({ callId: CallId('k2'), name: 'task_kill', arguments: { task_id: 'subagent-2' }, agent: parent })
const withReason = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('k1'), name: 'task_kill', arguments: { task_id: 'subagent-1', reason: 'superseded' }, agent: parent })
const withoutReason = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('k2'), name: 'task_kill', arguments: { task_id: 'subagent-2' }, agent: parent })
expect(text(withReason)).toBe('requested cancellation of task subagent-1')
expect(text(withoutReason)).toBe('requested cancellation of task subagent-2')
expect(cancels).toEqual(['superseded', 'background subagent task killed'])
// The aborted children settle as killed tasks.
const killed = await ctx.tools.execute({ callId: CallId('w1'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, agent: parent })
const killed = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('w1'), name: 'task_output', arguments: { task_id: 'subagent-1', wait: true }, agent: parent })
expect(text(killed)).toBe('(no new output)\n[status: killed]')
})
@@ -868,6 +882,7 @@ describe('background preflight failure (no orphaned child, by construction)', ()
tool.apply(ctx, { provider: 'probe', toolName: 'subagent_probe' })
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('probe-1'),
name: 'subagent_probe',
arguments: { description: 'd', prompt: 'p', run_in_background: true },

View File

@@ -11,6 +11,8 @@ import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
const testToolSignal = new AbortController().signal
const agentRegistryDisposers = new WeakMap<Agent, () => void>()
async function setup(config: ToolTasks.Config = {}) {
@@ -62,7 +64,7 @@ function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
let callCounter = 0
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
}
function text(result: { content: { type: string; text?: string }[] }): string {

View File

@@ -54,8 +54,7 @@ export function apply(ctx: Context): void {
using d = deadline(exec.signal, timeoutMs, TOOL_TIMEOUT)
// Swap the derived deadline onto exec for dispatch, then restore the
// caller's own signal so post-execute listeners never see this plugin's
// (possibly already-aborted) timeout signal. `undefined` is not assignable to
// the optional `signal` under exactOptionalPropertyTypes, so branch on it.
// (possibly already-aborted) timeout signal.
const upstream = exec.signal
exec.signal = d.signal
try {
@@ -69,8 +68,7 @@ export function apply(ctx: Context): void {
}
return result
} finally {
if (upstream === undefined) delete exec.signal
else exec.signal = upstream
exec.signal = upstream
}
})
}

View File

@@ -11,10 +11,12 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineTool, TOOL_ABORTED, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
import { TOOL_TIMEOUT } from '@deepseek-ai/dsh-timeout-policy'
const testToolSignal = new AbortController().signal
/** Mount the registry + the zero-config timeout-policy enforcer. */
async function setup() {
const ctx = new Context()
@@ -29,8 +31,8 @@ const cooperativeTool = defineTool({
name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100,
execute(_args, exec): Promise<{ type: 'text'; text: string }[]> {
const done = [{ type: 'text' as const, text: 'stopped cooperatively' }]
if (exec.signal?.aborted) return Promise.resolve(done)
return new Promise((resolve) => { exec.signal?.addEventListener('abort', () => { resolve(done) }) })
if (exec.signal.aborted) return Promise.resolve(done)
return new Promise((resolve) => { exec.signal.addEventListener('abort', () => { resolve(done) }) })
},
})
@@ -38,8 +40,8 @@ const cooperativeTool = defineTool({
const abortThrowingTool = defineTool({
name: 'aborter', description: 'throws WEB_ABORTED when aborted', parameters: {}, timeoutMs: 100,
execute(_args, exec): Promise<never> {
if (exec.signal?.aborted) return Promise.reject(new HarnessError('web fetch aborted', 'WEB_ABORTED'))
return new Promise((_resolve, reject) => { exec.signal?.addEventListener('abort', () => { reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) }) })
if (exec.signal.aborted) return Promise.reject(new HarnessError('web fetch aborted', 'WEB_ABORTED'))
return new Promise((_resolve, reject) => { exec.signal.addEventListener('abort', () => { reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) }) })
},
})
@@ -59,7 +61,7 @@ describe('timeout-policy delegation (unconfigured / fast)', () => {
const ctx = await setup()
ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'fast', arguments: {} })
expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
})
@@ -86,16 +88,6 @@ describe('timeout-policy signal restoration', () => {
await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {}, signal: upstream })
expect(postSignal).toBe(upstream)
})
it('deletes exec.signal again when the caller passed none', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
let hadSignal: boolean | undefined
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { hadSignal = 'signal' in exec && exec.signal !== undefined; return next() })
await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} })
expect(hadSignal).toBe(false)
})
})
describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
@@ -105,7 +97,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
it('replaces a cooperative tool result with TOOL_TIMEOUT when its own deadline fires', async () => {
const ctx = await setup()
ctx.tools.register(cooperativeTool)
const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {} })
const pending = ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'slow', arguments: {} })
await vi.advanceTimersByTimeAsync(150)
const result = await pending
expect(result).toEqual({
@@ -118,7 +110,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
it('replaces a provider-owned abort ERROR result with TOOL_TIMEOUT when the signal was ours', async () => {
const ctx = await setup()
ctx.tools.register(abortThrowingTool)
const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'aborter', arguments: {} })
const pending = ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'aborter', arguments: {} })
await vi.advanceTimersByTimeAsync(150)
const result = await pending
expect(result.isError).toBe(true)
@@ -128,14 +120,26 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
it('preserves registry ABORTED when the caller aborts first (upstream cancel, not our timeout)', async () => {
const ctx = await setup()
ctx.tools.register(cooperativeTool)
const entered = Promise.withResolvers<undefined>()
ctx.tools.register(defineTool({
name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100,
execute(_args, exec) {
entered.resolve(undefined)
const done = [{ type: 'text' as const, text: 'stopped cooperatively' }]
if (exec.signal.aborted) return Promise.resolve(done)
return new Promise((resolve) => {
exec.signal.addEventListener('abort', () => { resolve(done) }, { once: true })
})
},
}))
const upstream = new AbortController()
const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {}, signal: upstream.signal })
await entered.promise
upstream.abort('user cancelled')
await vi.advanceTimersByTimeAsync(0)
const result = await pending
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' })
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED })
expect(result.content[0]).toMatchObject({ text: 'Error: tool call aborted' })
})
@@ -146,9 +150,9 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
ctx.tools.register(defineTool({
name: 'slow-cleanup', description: 'settles after abort cleanup', parameters: {}, timeoutMs: 100,
async execute(_args, exec) {
if (!exec.signal?.aborted) {
if (!exec.signal.aborted) {
await new Promise<undefined>((resolve) => {
exec.signal?.addEventListener('abort', () => { resolve(undefined) }, { once: true })
exec.signal.addEventListener('abort', () => { resolve(undefined) }, { once: true })
})
}
sawAbort.resolve(undefined)
@@ -217,7 +221,7 @@ describe('dsh-timeout-policy real-load-path guard', () => {
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters<Context['plugin']>[0]
const fiber = await ctx.plugin(unwrapped)
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput)
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput)
expect(result.isError).toBe(false)
await fiber.dispose()
})

View File

@@ -10,6 +10,8 @@ import { type Agent } from '@deepseek-ai/dsh-agent'
import * as tool from '../src/index.ts'
const testToolSignal = new AbortController().signal
/**
* Drives the REAL plugin body: mounts `dsh-tool-todo` on a real `ToolRegistry`
* and invokes the registered `todo_write` tool through `ctx.tools.execute`,
@@ -36,6 +38,7 @@ let callCounter = 0
function callTodo(ctx: Context, args: unknown, over: { agent?: Agent | undefined } = {}) {
const agent = 'agent' in over ? over.agent : agentWithSession()
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId(`call-${++callCounter}`),
name: 'todo_write',
arguments: args,

View File

@@ -63,7 +63,7 @@ export function apply(ctx: Context): void {
...question.multi_select !== undefined ? { multiSelect: question.multi_select } : {},
})),
...exec.agent !== undefined ? { agent: exec.agent } : {},
...exec.signal !== undefined ? { signal: exec.signal } : {},
signal: exec.signal,
})
return [{ type: 'text', text: JSON.stringify(result) }]
},

View File

@@ -7,6 +7,8 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
const testToolSignal = new AbortController().signal
interface OptionSchemaShape {
properties: {
questions: {
@@ -75,6 +77,7 @@ describe('ask_user_question tool', () => {
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('ask-1'),
name: 'ask_user_question',
arguments: {
@@ -110,6 +113,7 @@ describe('ask_user_question tool', () => {
})
await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('ask-recommended'),
name: 'ask_user_question',
arguments: {
@@ -144,6 +148,7 @@ describe('ask_user_question tool', () => {
})
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('ask-multi'),
name: 'ask_user_question',
arguments: {
@@ -198,6 +203,7 @@ describe('ask_user_question tool', () => {
const agent = { id: 'main' } as unknown as Agent
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('ask-3'),
name: 'ask_user_question',
arguments: { questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }] },
@@ -212,6 +218,7 @@ describe('ask_user_question tool', () => {
const ctx = await setup()
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('ask-no-provider'),
name: 'ask_user_question',
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
@@ -227,6 +234,7 @@ describe('ask_user_question tool', () => {
const ctx = await setup()
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('ask-empty'),
name: 'ask_user_question',
arguments: { questions: [] },

View File

@@ -19,6 +19,8 @@ import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
const testToolSignal = new AbortController().signal
type Handler = (req: IncomingMessage, res: ServerResponse) => void
let server: Server
@@ -56,7 +58,7 @@ afterEach(async () => {
let counter = 0
type ToolResult = { isError: boolean; content: { type: string; text?: string }[]; error?: { code: string } }
function call(name: string, args: unknown): Promise<ToolResult> {
return ctx.tools.execute({ callId: CallId(`call-${++counter}`), name, arguments: args })
return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++counter}`), name, arguments: args })
}
describe('web_fetch integration over the real backend', () => {
@@ -147,7 +149,7 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc
})
it('returns a structured TOOL_TIMEOUT (not the provider WEB_FETCH_TIMEOUT) when the tool-call budget wins', async () => {
const out = await tctx.tools.execute({ callId: CallId('slow-1'), name: 'web_fetch', arguments: { url: slowBase } })
const out = await tctx.tools.execute({ signal: testToolSignal, callId: CallId('slow-1'), name: 'web_fetch', arguments: { url: slowBase } })
expect(out.isError).toBe(true)
// The outer tool-call deadline won: TOOL_TIMEOUT, owned by dsh-timeout-policy,
// NOT the provider's own WEB_FETCH_TIMEOUT (its 30s backstop never fired).

View File

@@ -19,6 +19,8 @@ import { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
const testToolSignal = new AbortController().signal
import WebService from '@deepseek-ai/dsh-web'
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
import LocalSpillStore from '@deepseek-ai/dsh-spill-local'
@@ -63,7 +65,7 @@ afterEach(async () => {
/** A web_fetch call carrying a session owner (so the policy can scope the spill). */
function fetchCall(): Promise<{ isError: boolean; content: { type: string; text?: string }[] }> {
const agent = { session: { header: { id: SessionId('web-sess') } } }
const exec = { callId: CallId('call-1'), name: 'web_fetch', arguments: { url: base }, agent } as unknown as ToolExecution
const exec = { callId: CallId('call-1'), name: 'web_fetch', arguments: { url: base }, agent, signal: testToolSignal } as unknown as ToolExecution
return ctx.tools.execute(exec)
}

View File

@@ -18,6 +18,8 @@ import {
WEB_SEARCH_MAX_RESULTS,
} from '@deepseek-ai/dsh-tool-web'
const testToolSignal = new AbortController().signal
const available = true
function searchProvider(result: WebSearchResult, isAvailable = available): WebSearchProvider {
@@ -39,7 +41,7 @@ async function mountTools(opts: {
if (opts.fetchProvider) ctx.web.registerFetchProvider(opts.fetchProvider)
const fiber = await ctx.plugin(ToolWeb, opts.config ?? {})
let counter = 0
const call = (name: string, args: unknown) => ctx.tools.execute({ callId: CallId(`call-${++counter}`), name, arguments: args }) as never
const call = (name: string, args: unknown) => ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++counter}`), name, arguments: args }) as never
return { ctx, fiber, call }
}
@@ -166,9 +168,9 @@ describe('tool-web registration', () => {
const names = ctx.tools.schemas().map(s => s.name)
expect(names).toContain('web_search')
expect(names).toContain('web_fetch')
expect(ctx.tools.executionMode({ callId: CallId('search-safe'), name: 'web_search', arguments: { query: 'q' } }))
expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('search-safe'), name: 'web_search', arguments: { query: 'q' } }))
.toEqual({ kind: 'parallel' })
expect(ctx.tools.executionMode({ callId: CallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } }))
expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } }))
.toEqual({ kind: 'parallel' })
await fiber.dispose()
expect(ctx.tools.schemas().map(s => s.name)).not.toContain('web_search')
@@ -274,7 +276,7 @@ describe('tool-web execution through the real registry', () => {
await fiber.dispose()
})
it('executes web_fetch with no caller signal (forwards undefined to the seam)', async () => {
it('forwards the required caller signal to web_fetch', async () => {
const seen: { signal?: AbortSignal | undefined; passedSignal?: boolean } = {}
const fetchProvider = {
id: 'stub-fetch',
@@ -286,11 +288,10 @@ describe('tool-web execution through the real registry', () => {
},
}
const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
// No signal on the execution: the tool passes `undefined`.
const out = await ctx.tools.execute({ callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } })
const out = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } })
expect(out.isError).toBe(false)
expect(seen.passedSignal).toBe(false)
expect(seen.signal).toBeUndefined()
expect(seen.passedSignal).toBe(true)
expect(seen.signal).toBe(testToolSignal)
await fiber.dispose()
})

View File

@@ -174,17 +174,14 @@ export function apply(ctx: Context, config: Config): void {
meta: args.meta,
...args.args !== undefined ? { args: args.args } : {},
parent,
...exec.signal ? { signal: exec.signal } : {},
signal: exec.signal,
})
// Bridge the tool's abort signal to the run: if the parent step is aborted while the
// script is in flight, cancel the whole run. The signal also enters the engine directly, but
// this local bridge preserves the tool contract even if an implementation ignores it.
const onAbort = (): void => { run.cancel('parent step aborted') }
exec.signal?.addEventListener('abort', onAbort, { once: true })
// `addEventListener` does NOT fire for a signal already aborted before
// this line — cancel explicitly in that case.
if (exec.signal?.aborted) run.cancel('parent step aborted')
exec.signal.addEventListener('abort', onAbort, { once: true })
try {
const result = await run.result
@@ -196,7 +193,7 @@ export function apply(ctx: Context, config: Config): void {
}
return [{ type: 'text', text: renderResult(run, result, maxResultChars) }]
} finally {
exec.signal?.removeEventListener('abort', onAbort)
exec.signal.removeEventListener('abort', onAbort)
// Always reach run quiescence — never leak a live script or children.
await run.dispose()
}

View File

@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
@@ -13,6 +13,8 @@ import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
import * as toolWorkflow from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
const testToolSignal = new AbortController().signal
/** A controllable engine standing in behind ctx.workflows (the tool's only seam). */
class StubEngine extends WorkflowService {
requests: WorkflowStartRequest[] = []
@@ -60,6 +62,7 @@ const META = { name: 'audit', description: 'd' }
function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise<ToolExecutionResult> {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId('call-1'),
name: 'workflow',
arguments: args,
@@ -154,14 +157,16 @@ describe('dsh-tool-workflow', () => {
expect(result.error?.code).toBe('INVALID_ARGS')
})
it('cancels the run when exec.signal is ALREADY aborted at call time', async () => {
it('skips workflow startup when exec.signal is already aborted', async () => {
const { ctx, engine, parent } = await setup()
const controller = new AbortController()
controller.abort()
const result = await execute(ctx, { script: SCRIPT, meta: META }, { agent: parent, signal: controller.signal })
expect(result.isError).toBe(true)
expect(engine.cancels).toContain('parent step aborted')
expect(engine.disposed).toBe(1)
expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH })
expect(engine.requests).toHaveLength(0)
expect(engine.cancels).toHaveLength(0)
expect(engine.disposed).toBe(0)
})
it('truncates an oversized rendered value with a notice (maxResultChars)', async () => {