fix(tools): enforce cooperative cancellation
This commit is contained in:
@@ -473,7 +473,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
expect(events(agent).filter(e => e.type === 'tool/result')).toEqual([])
|
||||
})
|
||||
|
||||
it('stops starting siblings when abort fires during ordered pre-execute', async () => {
|
||||
it('skips dispatch and stops starting siblings when abort fires during ordered pre-execute', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
|
||||
textResponse('should never be requested'),
|
||||
@@ -490,16 +490,14 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
})
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 1)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
expect(gated.started).toEqual(['1'])
|
||||
gated.release('1')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(gated.started).toEqual([])
|
||||
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
|
||||
.toEqual([CallId('c1')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
|
||||
.toEqual([CallId('c1')])
|
||||
const results = events(agent).filter(e => e.type === 'tool/result')
|
||||
expect(results.map(e => e.data.callId)).toEqual([CallId('c1')])
|
||||
expect(results[0]?.data.error).toEqual({ name: 'AbortError', code: 'ABORTED' })
|
||||
})
|
||||
|
||||
it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => {
|
||||
|
||||
@@ -20,24 +20,28 @@ tools:
|
||||
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
|
||||
- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body; around wrappers may replace only `signal`.
|
||||
- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body. Around wrappers may replace only `signal`; the registry re-fuses the original caller signal immediately before the body.
|
||||
- `ctx.tools.executionMode(exec)` returns `parallel` only when the visible definition's `isConcurrencySafe(exec.arguments)` classifier returns exactly `true`; unknown, hidden, undeclared, invalid, or throwing classifications are exclusive.
|
||||
|
||||
### Injected services
|
||||
|
||||
`SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`. The approval seam is consumed opportunistically instead (`ctx.get('approval')`, no static inject): a deployment without it keeps the ask→deny degrade, and the registry stays active either way.
|
||||
|
||||
### Cancellation
|
||||
|
||||
Cancellation is cooperative and quiescent. A cancellation that arrives after registry entry is rechecked after pre-policy, approval, and around-dispatch waits, so a body cannot start late; if the body has started, the registry preserves the caller signal through wrapper replacement, awaits settlement, and replaces a successful dispatch outcome with structured `ABORTED`. A tool-owned structured error still wins. The registry never races away from a live same-process promise: every async tool must observe or forward `exec.signal` and settle only after owned work stops. A signal already aborted on entry still reaches the body for domain-specific cleanup; the agent-loop scheduler prevents model-driven calls from entering in that state. A timeout wrapper may replace the intermediate `ABORTED` with its owned `TOOL_TIMEOUT` when its deadline won. See the [quiescent-disposal rule](../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it) and [timeout ownership decision](../../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md).
|
||||
|
||||
### Live events
|
||||
|
||||
The live registry pipeline has three transformable waterfalls followed by the observe-only `tools/result` boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards.
|
||||
|
||||
### Key types
|
||||
|
||||
- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification.
|
||||
- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, whose async work must cooperatively stop through `exec.signal`, plus optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification.
|
||||
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
|
||||
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
|
||||
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
|
||||
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately.
|
||||
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore; the registry separately retains and re-fuses the original caller signal. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
|
||||
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately.
|
||||
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source, envelope, and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
|
||||
- `PostToolDecision` — `{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
|
||||
@@ -74,7 +78,7 @@ ctx.tools.register(defineTool({
|
||||
},
|
||||
async execute(args, exec) {
|
||||
// args is typed: { path: string; offset?: number; limit?: number }
|
||||
const text = await readFile(args.path, 'utf8')
|
||||
const text = await readFile(args.path, { encoding: 'utf8', signal: exec.signal })
|
||||
return [{ type: 'text', text }]
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -72,7 +72,9 @@ declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Allow, deny, or ask before dispatch. `next()` delegates to allow; missing
|
||||
* approval support turns `ask` into denial.
|
||||
* approval support turns `ask` into denial. Async gates must observe
|
||||
* `exec.signal`; the registry rechecks cancellation after they settle but
|
||||
* never abandons their promise.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
|
||||
* @param exec - the pending call (name, parsed arguments, caller agent).
|
||||
* @mode waterfall
|
||||
@@ -81,7 +83,9 @@ declare module 'cordis' {
|
||||
/**
|
||||
* Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns
|
||||
* a normalized result; wrappers may change only `exec.signal`, while call
|
||||
* identity remains immutable.
|
||||
* identity remains immutable. The registry re-fuses the original caller
|
||||
* signal before the body, so replacement cannot detach caller cancellation;
|
||||
* wrappers must still restore their signal and reach quiescence.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
|
||||
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
|
||||
* @mode waterfall
|
||||
@@ -122,6 +126,15 @@ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta
|
||||
|
||||
/** A registered tool: its schema plus the execution function. */
|
||||
export interface ToolDefinition extends ToolSchema {
|
||||
/**
|
||||
* Run one accepted call. Async work must observe or forward `exec.signal` and
|
||||
* settle only after its owned work reaches quiescence. The registry preserves
|
||||
* caller cancellation through around-dispatch signal replacement and does
|
||||
* not abandon this promise, but it cannot hard-kill same-process code.
|
||||
* @param args - losslessly snapshotted, frozen model arguments.
|
||||
* @param exec - execution identity, cancellation signal, and context deferral.
|
||||
* @returns model-facing content plus optional private presentation metadata.
|
||||
*/
|
||||
execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn>
|
||||
/**
|
||||
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
|
||||
@@ -218,8 +231,10 @@ export type ToolExecutionMode =
|
||||
* One pending tool call inside the registry pipeline. Parsed arguments cross
|
||||
* one lossless-JSON materialization boundary before policy and are deep-frozen;
|
||||
* call identity and the registry-assigned {@link token} are readonly. An
|
||||
* around-dispatch wrapper may set, replace, or remove `signal`. The registry
|
||||
* freezes the complete object before `tools/result` observers run.
|
||||
* around-dispatch wrapper may set, replace, or remove `signal`; immediately
|
||||
* before the body, the registry re-fuses the original caller signal so a
|
||||
* wrapper cannot detach caller cancellation. The registry freezes the complete
|
||||
* object before `tools/result` observers run.
|
||||
*/
|
||||
export interface ToolExecution extends ToolExecutionInput {
|
||||
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
|
||||
@@ -431,6 +446,18 @@ interface ToolGuardRegistration {
|
||||
guard: ToolGuard
|
||||
}
|
||||
|
||||
/** Caller cancellation captured before around-dispatch wrappers may replace the public signal slot. */
|
||||
interface ToolCancellationState {
|
||||
readonly callerSignal: AbortSignal | undefined
|
||||
readonly abortedAtEntry: boolean
|
||||
}
|
||||
|
||||
/** One dispatch-scoped fused signal plus listener cleanup after the body settles. */
|
||||
interface FusedToolSignal {
|
||||
readonly signal: AbortSignal | undefined
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool registry and execution pipeline. Scoped registrations shadow globals;
|
||||
* one visibility resolver feeds presentation, lookup, and dispatch.
|
||||
@@ -452,6 +479,8 @@ export class ToolRegistry extends Service {
|
||||
|
||||
/** Context deferred by a running tool body, keyed by its scheduler-owned execution. */
|
||||
private deferredContexts = new WeakMap<ToolRunContext, HookContext[]>()
|
||||
/** Original caller cancellation, kept outside the wrapper-mutable execution object. */
|
||||
private cancellationStates = new WeakMap<ToolRunContext, ToolCancellationState>()
|
||||
private global = new Map<string, ToolDefinition>()
|
||||
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
|
||||
/** Compiled restriction filters, per scope (see {@link restrict}). */
|
||||
@@ -774,7 +803,10 @@ export class ToolRegistry extends Service {
|
||||
* Execute through pre-policy, guards, around-dispatch, post-policy, and final
|
||||
* notification. Tool and listener failures resolve as materialized error
|
||||
* results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is
|
||||
* the same lossless, frozen snapshot final observers receive.
|
||||
* the same lossless, frozen snapshot final observers receive. Cancellation
|
||||
* arriving after entry skips a not-yet-started body or replaces a successful
|
||||
* dispatch outcome with `ABORTED`; already-started work is still drained and
|
||||
* may retain a tool-owned structured error.
|
||||
* @param exec - the typed same-process call input. The registry assigns its
|
||||
* correlation token before policy begins.
|
||||
* @returns the materialized final result.
|
||||
@@ -827,6 +859,10 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
const execution: ToolRunContext = { ...base, arguments: deepFreeze(detached) }
|
||||
this.deferredContexts.set(execution, deferredContexts)
|
||||
this.cancellationStates.set(execution, {
|
||||
callerSignal: signal,
|
||||
abortedAtEntry: signal?.aborted === true,
|
||||
})
|
||||
return { kind: 'ready', exec: execution }
|
||||
} catch (error: unknown) {
|
||||
const execution: ToolRunContext = { ...base, arguments: undefined }
|
||||
@@ -858,6 +894,9 @@ export class ToolRegistry extends Service {
|
||||
() => Promise.resolve<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 denialReason = decision.kind === 'allow'
|
||||
? this.guardReason(exec)
|
||||
: decision.reason
|
||||
@@ -873,7 +912,60 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
return await next({ kind: 'dispatch', exec })
|
||||
} catch (error: unknown) {
|
||||
return next({ kind: 'final-result', exec, result: toolErrorResult(error) })
|
||||
return this.callerCancelledAfterEntry(exec)
|
||||
? await next({ kind: 'post-result', exec, result: toolAbortedResult() })
|
||||
: next({ kind: 'final-result', exec, result: toolErrorResult(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the original live caller signal aborted after this execution entered the registry. */
|
||||
private callerCancelledAfterEntry(exec: ToolRunContext): boolean {
|
||||
const state = this.cancellationStates.get(exec)
|
||||
/* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */
|
||||
if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state')
|
||||
return !state.abortedAtEntry && state.callerSignal?.aborted === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the registered body with the original caller signal fused back
|
||||
* into any around-wrapper replacement. Cancellation never abandons the body:
|
||||
* a started promise reaches quiescence before its outcome becomes `ABORTED`.
|
||||
*/
|
||||
private async dispatchToolBody(exec: ToolRunContext): Promise<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) {
|
||||
fused.dispose()
|
||||
return toolAbortedResult()
|
||||
}
|
||||
if (signal === undefined) delete exec.signal
|
||||
else exec.signal = signal
|
||||
try {
|
||||
const tool = this.get(exec.name, exec.agent)
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
const returned = await tool.execute(exec.arguments, exec)
|
||||
const content = Array.isArray(returned) ? returned : returned.content
|
||||
const meta = Array.isArray(returned) ? undefined : returned.meta
|
||||
const result: ToolExecutionResult = {
|
||||
content,
|
||||
isError: false,
|
||||
...meta !== undefined ? { meta } : {},
|
||||
}
|
||||
return !abortedBeforeBody && isAborted(signal)
|
||||
? toolAbortedResult(result)
|
||||
: result
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(error)
|
||||
} finally {
|
||||
fused.dispose()
|
||||
if (wrapperSignal === undefined) delete exec.signal
|
||||
else exec.signal = wrapperSignal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,18 +981,7 @@ export class ToolRegistry extends Service {
|
||||
const carrier = scopeTarget(this, exec.agent)
|
||||
const result = await this.ctx.waterfall(
|
||||
carrier, 'tools/execute', exec,
|
||||
async (): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
const tool = this.get(exec.name, exec.agent)
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
const returned = await tool.execute(exec.arguments, exec)
|
||||
const content = Array.isArray(returned) ? returned : returned.content
|
||||
const meta = Array.isArray(returned) ? undefined : returned.meta
|
||||
return { content, isError: false, ...meta !== undefined ? { meta } : {} }
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(error)
|
||||
}
|
||||
},
|
||||
() => this.dispatchToolBody(exec),
|
||||
)
|
||||
const deferredContexts = this.deferredContexts.get(exec)
|
||||
/* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */
|
||||
@@ -914,7 +995,12 @@ export class ToolRegistry extends Service {
|
||||
...result.additionalContexts ?? [],
|
||||
],
|
||||
}
|
||||
return { kind: 'post-result', result: resultWithDeferredContexts }
|
||||
return {
|
||||
kind: 'post-result',
|
||||
result: this.callerCancelledAfterEntry(exec) && !resultWithDeferredContexts.isError
|
||||
? toolAbortedResult(resultWithDeferredContexts)
|
||||
: resultWithDeferredContexts,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
return { kind: 'final-result', result: toolErrorResult(error) }
|
||||
}
|
||||
@@ -1069,4 +1155,56 @@ function toolErrorResult(error: unknown): ToolExecutionResult {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read live abort state across an await without treating it as synchronously immutable. */
|
||||
function isAborted(signal: AbortSignal | undefined): boolean {
|
||||
return signal?.aborted === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuse caller and wrapper cancellation without nesting `AbortSignal.any`.
|
||||
* Keeping the relay dispatch-scoped also removes listeners when work settles.
|
||||
*/
|
||||
function fuseToolSignals(caller: AbortSignal | undefined, wrapper: AbortSignal | undefined): FusedToolSignal {
|
||||
if (caller === undefined || caller === wrapper) {
|
||||
return { signal: wrapper ?? caller, dispose() {} }
|
||||
}
|
||||
if (wrapper === undefined) return { signal: caller, dispose() {} }
|
||||
|
||||
const controller = new AbortController()
|
||||
let listening = false
|
||||
const dispose = (): void => {
|
||||
if (!listening) return
|
||||
listening = false
|
||||
caller.removeEventListener('abort', abortFromCaller)
|
||||
wrapper.removeEventListener('abort', abortFromWrapper)
|
||||
}
|
||||
const abortFrom = (source: AbortSignal): void => {
|
||||
const reason: unknown = source.reason
|
||||
controller.abort(reason)
|
||||
dispose()
|
||||
}
|
||||
const abortFromCaller = (): void => { abortFrom(caller) }
|
||||
const abortFromWrapper = (): void => { abortFrom(wrapper) }
|
||||
|
||||
if (wrapper.aborted) abortFromWrapper()
|
||||
else if (caller.aborted) abortFromCaller()
|
||||
else {
|
||||
listening = true
|
||||
caller.addEventListener('abort', abortFromCaller, { once: true })
|
||||
wrapper.addEventListener('abort', abortFromWrapper, { once: true })
|
||||
}
|
||||
return { signal: controller.signal, dispose }
|
||||
}
|
||||
|
||||
/** Canonical result when cancellation prevents dispatch or supersedes a successful outcome. */
|
||||
function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult {
|
||||
const additionalContexts = prior?.additionalContexts ?? []
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
...additionalContexts.length > 0 ? { additionalContexts } : {},
|
||||
}
|
||||
}
|
||||
|
||||
export default ToolRegistry
|
||||
|
||||
@@ -857,7 +857,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a binding invoked after the run is over without dispatching it', async () => {
|
||||
it('reports cancellation after rejecting a late binding without dispatching it', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const calls = registerEcho(ctx)
|
||||
const controller = new AbortController()
|
||||
@@ -868,8 +868,9 @@ describe('the run_code dispatch bridge', () => {
|
||||
return { logs: [], value: message }
|
||||
}
|
||||
const result = await runCode(ctx, 'program', { signal: controller.signal })
|
||||
expect(result.isError).toBe(false)
|
||||
expect((result.content[0] as { text: string }).text).toContain('not dispatched')
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' })
|
||||
expect((result.content[0] as { text: string }).text).toBe('Error: tool call aborted')
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -499,6 +499,302 @@ describe('ToolRegistry', () => {
|
||||
expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
|
||||
})
|
||||
|
||||
it('skips dispatch when caller cancellation arrives while pre-execute awaits', async () => {
|
||||
const ctx = await setup()
|
||||
let dispatched = 0
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'must-not-run',
|
||||
async execute() { dispatched += 1; return [] },
|
||||
})
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.on('tools/pre-execute', async (_exec, next) => {
|
||||
entered.resolve(undefined)
|
||||
await release.promise
|
||||
return await next()
|
||||
})
|
||||
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.tools.execute({
|
||||
callId: CallId('cancelled-in-pre'), name: 'must-not-run', arguments: {}, signal: controller.signal,
|
||||
})
|
||||
await entered.promise
|
||||
controller.abort('cancelled in policy')
|
||||
release.resolve(undefined)
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
})
|
||||
expect(dispatched).toBe(0)
|
||||
})
|
||||
|
||||
it('materializes ABORTED when an async pre-execute gate throws after cancellation', async () => {
|
||||
const ctx = await setup()
|
||||
let dispatched = 0
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'must-not-run',
|
||||
async execute() { dispatched += 1; return [] },
|
||||
})
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.on('tools/pre-execute', async () => {
|
||||
entered.resolve(undefined)
|
||||
await release.promise
|
||||
throw new Error('gate interrupted')
|
||||
})
|
||||
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.tools.execute({
|
||||
callId: CallId('cancelled-pre-error'), name: 'must-not-run', arguments: {}, signal: controller.signal,
|
||||
})
|
||||
await entered.promise
|
||||
controller.abort('cancelled in policy')
|
||||
release.resolve(undefined)
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
})
|
||||
expect(dispatched).toBe(0)
|
||||
})
|
||||
|
||||
it('rechecks caller cancellation after an async around-dispatch wrapper delegates', async () => {
|
||||
const ctx = await setup()
|
||||
let dispatched = 0
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'must-not-run',
|
||||
async execute() { dispatched += 1; return [] },
|
||||
})
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const replacement = new AbortController()
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
const upstream = exec.signal
|
||||
exec.signal = replacement.signal
|
||||
try {
|
||||
entered.resolve(undefined)
|
||||
await release.promise
|
||||
return await next()
|
||||
} finally {
|
||||
if (upstream === undefined) delete exec.signal
|
||||
else exec.signal = upstream
|
||||
}
|
||||
})
|
||||
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.tools.execute({
|
||||
callId: CallId('cancelled-in-around'), name: 'must-not-run', arguments: {}, signal: controller.signal,
|
||||
})
|
||||
await entered.promise
|
||||
controller.abort('cancelled in wrapper')
|
||||
release.resolve(undefined)
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
})
|
||||
expect(dispatched).toBe(0)
|
||||
})
|
||||
|
||||
it('skips dispatch when an around wrapper supplies an already-aborted signal', async () => {
|
||||
const ctx = await setup()
|
||||
let dispatched = 0
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'must-not-run',
|
||||
async execute() { dispatched += 1; return [] },
|
||||
})
|
||||
const replacement = AbortSignal.abort('wrapper cancelled')
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
const upstream = exec.signal
|
||||
exec.signal = replacement
|
||||
try {
|
||||
return await next()
|
||||
} finally {
|
||||
if (upstream === undefined) delete exec.signal
|
||||
else exec.signal = upstream
|
||||
}
|
||||
})
|
||||
|
||||
const controller = new AbortController()
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('cancelled-wrapper'), name: 'must-not-run', arguments: {}, signal: controller.signal,
|
||||
})
|
||||
|
||||
expect(result.error).toEqual({ name: 'AbortError', code: 'ABORTED' })
|
||||
expect(dispatched).toBe(0)
|
||||
})
|
||||
|
||||
it('replaces a late wrapper success with ABORTED and preserves deferred contexts', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'completed-before-wrapper',
|
||||
async execute(_args, exec) {
|
||||
exec.deferContext({
|
||||
content: [{ type: 'text', text: 'completed child work' }],
|
||||
source: { kind: 'plugin', plugin: 'child' },
|
||||
})
|
||||
return [{ type: 'text', text: 'body complete' }]
|
||||
},
|
||||
})
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.on('tools/execute', async (_exec, next) => {
|
||||
const result = await next()
|
||||
entered.resolve(undefined)
|
||||
await release.promise
|
||||
return result
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.tools.execute({
|
||||
callId: CallId('cancelled-after-body'), name: 'completed-before-wrapper', arguments: {}, signal: controller.signal,
|
||||
})
|
||||
await entered.promise
|
||||
controller.abort('cancelled while wrapper settled')
|
||||
release.resolve(undefined)
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
additionalContexts: [{ source: { kind: 'plugin', plugin: 'child' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('fuses caller cancellation back into a wrapper replacement for the running body', async () => {
|
||||
const ctx = await setup()
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const replacement = new AbortController()
|
||||
let bodySignal: AbortSignal | undefined
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'cooperative',
|
||||
execute(_args, exec) {
|
||||
bodySignal = exec.signal
|
||||
entered.resolve(undefined)
|
||||
if (exec.signal?.aborted) return Promise.resolve([])
|
||||
return new Promise((resolve) => {
|
||||
exec.signal?.addEventListener('abort', () => { resolve([]) }, { once: true })
|
||||
})
|
||||
},
|
||||
})
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
const upstream = exec.signal
|
||||
exec.signal = replacement.signal
|
||||
try {
|
||||
return await next()
|
||||
} finally {
|
||||
if (upstream === undefined) delete exec.signal
|
||||
else exec.signal = upstream
|
||||
}
|
||||
})
|
||||
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.tools.execute({
|
||||
callId: CallId('cancelled-body'), name: 'cooperative', arguments: {}, signal: controller.signal,
|
||||
})
|
||||
await entered.promise
|
||||
expect(bodySignal).not.toBe(controller.signal)
|
||||
expect(bodySignal).not.toBe(replacement.signal)
|
||||
controller.abort('cancel running body')
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
})
|
||||
expect(bodySignal?.aborted).toBe(true)
|
||||
expect(replacement.signal.aborted).toBe(false)
|
||||
})
|
||||
|
||||
it('restores a removed caller signal for dispatch', async () => {
|
||||
const ctx = await setup()
|
||||
let bodySignal: AbortSignal | undefined
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'signal-probe',
|
||||
async execute(_args, exec) { bodySignal = exec.signal; return [] },
|
||||
})
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
const upstream = exec.signal
|
||||
delete exec.signal
|
||||
try {
|
||||
return await next()
|
||||
} finally {
|
||||
if (upstream !== undefined) exec.signal = upstream
|
||||
}
|
||||
})
|
||||
const controller = new AbortController()
|
||||
|
||||
await ctx.tools.execute({
|
||||
callId: CallId('restored-signal'), name: 'signal-probe', arguments: {}, signal: controller.signal,
|
||||
})
|
||||
|
||||
expect(bodySignal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('waits for an uncooperative started body before returning ABORTED', async () => {
|
||||
const ctx = await setup()
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<never[]>()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'uncooperative',
|
||||
execute(_args, exec) {
|
||||
exec.deferContext({
|
||||
content: [{ type: 'text', text: 'nested outcome' }],
|
||||
source: { kind: 'plugin', plugin: 'nested' },
|
||||
})
|
||||
entered.resolve(undefined)
|
||||
return release.promise
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.tools.execute({
|
||||
callId: CallId('drain-body'), name: 'uncooperative', arguments: {}, signal: controller.signal,
|
||||
})
|
||||
await entered.promise
|
||||
controller.abort('must still drain')
|
||||
|
||||
const state = await Promise.race([
|
||||
pending.then(() => 'settled' as const),
|
||||
Promise.resolve('pending' as const),
|
||||
])
|
||||
expect(state).toBe('pending')
|
||||
release.resolve([])
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
additionalContexts: [{ source: { kind: 'plugin', plugin: 'nested' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('lets an already-aborted entry signal reach the body for domain-specific cleanup', async () => {
|
||||
const ctx = await setup()
|
||||
let dispatched = 0
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'domain-abort',
|
||||
async execute(_args, exec) {
|
||||
dispatched += 1
|
||||
expect(exec.signal?.aborted).toBe(true)
|
||||
throw new HarnessError('domain cleanup completed', 'DOMAIN_ABORTED')
|
||||
},
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('pre-aborted'), name: 'domain-abort', arguments: {}, signal: AbortSignal.abort(),
|
||||
})
|
||||
|
||||
expect(dispatched).toBe(1)
|
||||
expect(result.error).toEqual({ name: 'HarnessError', code: 'DOMAIN_ABORTED' })
|
||||
})
|
||||
|
||||
it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
@@ -560,7 +856,7 @@ describe('ToolRegistry', () => {
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: exploded' })
|
||||
})
|
||||
|
||||
it('a tools/execute listener can replace exec.signal for the dispatched tool (deadline pattern)', async () => {
|
||||
it('re-fuses the caller signal with an around-dispatch replacement for the body', async () => {
|
||||
const ctx = await setup()
|
||||
let seenSignal: AbortSignal | undefined
|
||||
ctx.tools.register({
|
||||
@@ -583,7 +879,9 @@ describe('ToolRegistry', () => {
|
||||
})
|
||||
|
||||
await ctx.tools.execute({ callId: CallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream })
|
||||
expect(seenSignal).toBe(replacement) // dispatch saw the wrapper's replacement, not the upstream
|
||||
expect(seenSignal).toBeDefined()
|
||||
expect(seenSignal).not.toBe(upstream)
|
||||
expect(seenSignal).not.toBe(replacement)
|
||||
})
|
||||
|
||||
it('a tools/execute listener can short-circuit dispatch by returning a result without next()', async () => {
|
||||
|
||||
Reference in New Issue
Block a user