fix(tools): complete cancellation boundary

This commit is contained in:
Tianyi Cui
2026-07-19 21:13:41 +08:00
parent 5cc18ea7db
commit 54aef60685
17 changed files with 241 additions and 43 deletions

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, and around-dispatch waits, so a body cannot start late; if the body has started, the registry preserves the caller signal through wrapper replacement, awaits settlement, and replaces a successful dispatch outcome with structured `ABORTED`. A tool-owned structured error still wins. The registry never races away from a live same-process promise: every async tool must observe or forward `exec.signal` and settle only after owned work stops. A signal already aborted on entry still reaches the body for domain-specific cleanup; the agent-loop scheduler prevents model-driven calls from entering in that state. A timeout wrapper may replace the intermediate `ABORTED` with its owned `TOOL_TIMEOUT` when its deadline won. See the [quiescent-disposal rule](../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it) and [timeout ownership decision](../../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md).
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.
### Live events

View File

@@ -93,7 +93,9 @@ declare module 'cordis' {
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, 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.
* accepts it unchanged; thrown tools still reach this seam as errors. Async
* listeners must observe `exec.signal`; after they settle, caller
* cancellation replaces only a successful accepted outcome with `ABORTED`.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
* @param exec - the call that just ran (name, parsed arguments, caller agent).
* @param result - the dispatch outcome a listener may accept, replace, or block.
@@ -804,9 +806,10 @@ export class ToolRegistry extends Service {
* notification. Tool and listener failures resolve as materialized error
* results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
* the same lossless, frozen snapshot final observers receive. Cancellation
* arriving after entry skips a not-yet-started body or replaces a successful
* dispatch outcome with `ABORTED`; already-started work is still drained and
* may retain a tool-owned structured error.
* 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.
* @param exec - the typed same-process call input. The registry assigns its
* correlation token before policy begins.
* @returns the materialized final result.
@@ -1015,7 +1018,13 @@ export class ToolRegistry extends Service {
*/
private async finalizeScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): Promise<ToolExecutionResult> {
try {
return this.finishScheduledExecution(exec, await this.postExecute(exec, result))
const postResult = await this.postExecute(exec, result)
return this.finishScheduledExecution(
exec,
this.callerCancelledAfterEntry(exec) && !postResult.isError
? toolAbortedResult(postResult)
: postResult,
)
} catch (error: unknown) {
return this.finishScheduledExecution(exec, toolErrorResult(error))
}

View File

@@ -667,6 +667,52 @@ describe('ToolRegistry', () => {
})
})
it('replaces a late post-execute success with ABORTED and preserves contexts', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'completed-before-post',
async execute(_args, exec) {
exec.deferContext({
content: [{ type: 'text', text: 'completed child work' }],
source: { kind: 'plugin', plugin: 'child' },
})
return [{ type: 'text', text: 'body complete' }]
},
})
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.on('tools/post-execute', async (_exec, _result, next) => {
const decision = await next()
entered.resolve(undefined)
await release.promise
return {
...decision,
additionalContexts: [{
content: [{ type: 'text', text: 'post context' }],
source: { kind: 'plugin', plugin: 'post' },
}],
}
})
const controller = new AbortController()
const pending = ctx.tools.execute({
callId: CallId('cancelled-in-post'), name: 'completed-before-post', arguments: {}, signal: controller.signal,
})
await entered.promise
controller.abort('cancelled while post policy waits')
release.resolve(undefined)
await expect(pending).resolves.toMatchObject({
content: [{ type: 'text', text: 'Error: tool call aborted' }],
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
additionalContexts: [
{ source: { kind: 'plugin', plugin: 'child' } },
{ source: { kind: 'plugin', plugin: 'post' } },
],
})
})
it('fuses caller cancellation back into a wrapper replacement for the running body', async () => {
const ctx = await setup()
const entered = Promise.withResolvers<undefined>()