mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
fix(tools): limit cancellation guard to pre-execution
This commit is contained in:
1 parent
fffbe67b66
commit
aacb409fbf
4 files changed
+52
-87
No files matched your search
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 (
|
||||||
|
|||||||
Reference in new issue
Block a user