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

@@ -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: {