fix(tools): limit cancellation guard to pre-execution

This commit is contained in:
Rudy Celekli committed 2026-10-14 14:17:55 -04:00
1 parent fffbe67b66
commit aacb409fbf
4 files changed
+52 -87

No files matched your search

+2
View File
@@ -17,6 +17,8 @@
- Cancelled tool calls refuse actions before execution and discard results that settle after cancellation. - Cancelled tool calls refuse actions before execution and discard results that settle after cancellation.
- Cancelled catalog queries refuse execution when their signal is already aborted.
- Treat explicitly current, complete empty vessel coverage as a successful update, - Treat explicitly current, complete empty vessel coverage as a successful update,
clearing obsolete contacts while retaining stale or incomplete snapshots. clearing obsolete contacts while retaining stale or incomplete snapshots.
+3 -4
View File
@@ -97,10 +97,9 @@ lists at 25 rows by default. Voice offers the same queries next to its app
actions: the session lists them, and the browser runs them through the same actions: the session lists them, and the browser runs them through the same
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
Catalog calls check their cancellation signal before tool execution and after Catalog calls check their cancellation signal immediately before executing a
the tool resolves, including nested calls. Cancellation discards late catalog tool. Already-cancelled queries do not start; cancellation after execution starts
results without promising rollback of completed actions or invalidating correct continues to follow the existing tool and caller behavior.
static-source caches.
## Cyber HUD — September 30, 2026 ## Cyber HUD — September 30, 2026
+45 -72
View File
@@ -1,78 +1,51 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { composeCatalog, defineTool } from './catalog.js'; import { composeCatalog, defineTool } from './catalog.js';
test('cancelled catalog calls cannot execute or return late results', async () => {
let calls = 0,
release,
block = false;
const action = defineTool({
name: 'fixture_action',
kind: 'action',
title: 'Fixture',
description: 'Offline fixture',
inputSchema: { type: 'object' },
run: async () => {
calls++;
return block
? new Promise((r) => (release = r))
: { summary: 'fixture', data: {} };
},
});
const catalog = composeCatalog({ tools: [action] });
const c = new AbortController();
c.abort();
await assert.rejects(
catalog.call('fixture_action', {}, { signal: c.signal }),
{ name: 'AbortError' },
);
assert.equal(calls, 0);
block = true;
const running = new AbortController();
const pending = catalog.call(
'fixture_action',
{},
{ signal: running.signal },
);
while (!release) await Promise.resolve();
running.abort();
release({ summary: 'late', data: {} });
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(calls, 1);
});
test('cancellation during an awaited interceptor cannot publish its late result', async () => { for (const intercept of [false, true]) {
const controller = new AbortController(); test(`cancelled query refuses execution${intercept ? ' after an awaited interceptor' : ''}`, async () => {
const reason = new Error('interceptor caller canceled'); let calls = 0;
let calls = 0; const controller = new AbortController();
const action = defineTool({ const query = defineTool({
name: 'fixture_intercepted_action', name: 'fixture_query',
kind: 'action', kind: 'query',
title: 'Fixture', title: 'Fixture',
description: 'Offline fixture', description: 'Offline fixture',
inputSchema: { type: 'object' }, inputSchema: { type: 'object' },
run: async () => { run: async () => {
calls++; calls++;
return { summary: 'completed action', data: {} }; return { summary: 'fixture', data: {} };
},
});
const catalog = composeCatalog({
tools: [action],
interceptors: [
async (call, next) => {
const result = await next(call);
await Promise.resolve();
controller.abort(reason);
return result;
}, },
], });
const catalog = composeCatalog({
tools: [query],
interceptors: intercept
? [
async (call, next) => {
await Promise.resolve();
controller.abort();
return next(call);
},
]
: [],
});
if (!intercept) controller.abort();
await assert.rejects(
catalog.call('fixture_query', {}, { signal: controller.signal }),
{ name: 'AbortError' },
);
assert.equal(calls, 0);
const fresh = composeCatalog({ tools: [query] });
assert.equal(
(
await fresh.call(
'fixture_query',
{},
{ signal: new AbortController().signal },
)
).summary,
'fixture',
);
assert.equal(calls, 1);
}); });
await assert.rejects( }
catalog.call(
'fixture_intercepted_action',
{},
{ signal: controller.signal },
),
(error) => error === reason,
);
assert.equal(calls, 1, 'completed actions are not rolled back or rerun');
});
+2 -11
View File
@@ -169,21 +169,13 @@ export function composeCatalog({
); );
const index = new Map(available.map((tool) => [tool.name, tool])); const index = new Map(available.map((tool) => [tool.name, tool]));
const invoke = async (name, args, signal, parent) => { const invoke = (name, args, signal, parent) => {
signal?.throwIfAborted();
const tool = index.get(name); const tool = index.get(name);
if (!tool) if (!tool)
return Promise.reject( return Promise.reject(
new ToolError('unsupported', `No tool named ${name} is available`), new ToolError('unsupported', `No tool named ${name} is available`),
); );
const result = await chain({ return chain({ tool, args, signal, ...(parent ? { parent } : {}) });
tool,
args,
signal,
...(parent ? { parent } : {}),
});
signal?.throwIfAborted();
return result;
}; };
const execute = async ({ tool, args, signal }) => { const execute = async ({ tool, args, signal }) => {
signal?.throwIfAborted(); signal?.throwIfAborted();
@@ -203,7 +195,6 @@ export function composeCatalog({
} catch (error) { } catch (error) {
throw fromSourceError(error); throw fromSourceError(error);
} }
signal?.throwIfAborted();
if (!result || typeof result.summary !== 'string' || !result.data) if (!result || typeof result.summary !== 'string' || !result.data)
throw new TypeError(`${tool.name} returned no summary or data`); throw new TypeError(`${tool.name} returned no summary or data`);
if ( if (