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

@@ -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 },