mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
fix: run composite tool sections through the catalog
situation_brief, military_awareness and the HUD caption called other tools directly, so replaced tools and interceptors did not apply to their sections. Tools now reach each other through the catalog, and interceptors see these calls with the calling tool as parent. Surfaces become a view over the composed catalog, so a composite offered on voice keeps sections that voice does not list.
This commit is contained in:
1 parent
7ddee426e7
commit
963c791a5c
9 files changed
+219
-65
No files matched your search
@@ -43,6 +43,21 @@ as `structuredContent`, for clients that read only one of them.
|
|||||||
whose services are not supplied are left out.
|
whose services are not supplied are left out.
|
||||||
- **Interceptors**: `(call, next) => next(call)` functions wrap every call,
|
- **Interceptors**: `(call, next) => next(call)` functions wrap every call,
|
||||||
outermost first. They can observe, reject or change a call.
|
outermost first. They can observe, reject or change a call.
|
||||||
|
- **Composite tools**: `run` receives `tools`, with `has(name)` and
|
||||||
|
`call(name, args)`, to call other tools through the same catalog, so
|
||||||
|
replaced tools and interceptors apply. Interceptors see such calls with
|
||||||
|
`parent`, the calling tool's name.
|
||||||
|
|
||||||
|
### Surfaces
|
||||||
|
|
||||||
|
`src/tools/surfaces.js` lists which tools MCP and voice offer. A tool is on
|
||||||
|
both unless `TOOL_SURFACES` turns it off; edit an entry to turn a tool on or
|
||||||
|
off for one surface. `catalogForSurface(catalog, surface, overrides)` is the
|
||||||
|
view a surface exposes: it lists and calls only the tools it offers, while
|
||||||
|
composite tools still reach the whole catalog. `toolsForSurface` gives the
|
||||||
|
same selection as a list of definitions, such as for the voice session's tool
|
||||||
|
list. Voice leaves out tools that answer with images, link to the app, or
|
||||||
|
repeat what its app actions answer.
|
||||||
|
|
||||||
Expected failures throw `ToolError` with one of `invalid_arguments`,
|
Expected failures throw `ToolError` with one of `invalid_arguments`,
|
||||||
`unavailable`, `unsupported`, `malformed` or `retry_later`. Other errors are
|
`unavailable`, `unsupported`, `malformed` or `retry_later`. Other errors are
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
|
|||||||
import {
|
import {
|
||||||
composeCatalog,
|
composeCatalog,
|
||||||
coreTools,
|
coreTools,
|
||||||
toolsForSurface,
|
catalogForSurface,
|
||||||
} from '../../src/tools/index.js';
|
} from '../../src/tools/index.js';
|
||||||
import { createMcpServer } from '../../src/tools/mcp/index.js';
|
import { createMcpServer } from '../../src/tools/mcp/index.js';
|
||||||
import { DEFAULT_API_BASE, createLocalToolServices } from './services.js';
|
import { DEFAULT_API_BASE, createLocalToolServices } from './services.js';
|
||||||
@@ -23,10 +23,13 @@ export function createLocalMcpServer({
|
|||||||
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
|
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
|
||||||
);
|
);
|
||||||
return createMcpServer({
|
return createMcpServer({
|
||||||
catalog: composeCatalog({
|
catalog: catalogForSurface(
|
||||||
tools: toolsForSurface(coreTools, 'mcp'),
|
composeCatalog({
|
||||||
services: createLocalToolServices({ apiBase, fetchImpl }),
|
tools: coreTools,
|
||||||
}),
|
services: createLocalToolServices({ apiBase, fetchImpl }),
|
||||||
|
}),
|
||||||
|
'mcp',
|
||||||
|
),
|
||||||
name: 'gods-eye-view',
|
name: 'gods-eye-view',
|
||||||
version,
|
version,
|
||||||
instructions: INSTRUCTIONS,
|
instructions: INSTRUCTIONS,
|
||||||
|
|||||||
@@ -8,16 +8,19 @@ export function loadToolCatalog() {
|
|||||||
import('../tools/services.js'),
|
import('../tools/services.js'),
|
||||||
]).then(
|
]).then(
|
||||||
([
|
([
|
||||||
{ composeCatalog, coreTools, toolsForSurface },
|
{ composeCatalog, coreTools, catalogForSurface },
|
||||||
{ createToolServices },
|
{ createToolServices },
|
||||||
]) =>
|
]) =>
|
||||||
composeCatalog({
|
catalogForSurface(
|
||||||
tools: toolsForSurface(coreTools, 'voice'),
|
composeCatalog({
|
||||||
services: createToolServices({
|
tools: coreTools,
|
||||||
fetchImpl: (...args) => globalThis.fetch(...args),
|
services: createToolServices({
|
||||||
appUrl: globalThis.location.origin,
|
fetchImpl: (...args) => globalThis.fetch(...args),
|
||||||
|
appUrl: globalThis.location.origin,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
'voice',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
pending.catch(() => {
|
pending.catch(() => {
|
||||||
pending = null;
|
pending = null;
|
||||||
|
|||||||
+24
-12
@@ -60,9 +60,11 @@ export function fromSourceError(error) {
|
|||||||
/**
|
/**
|
||||||
* Validate and freeze a tool definition.
|
* Validate and freeze a tool definition.
|
||||||
*
|
*
|
||||||
* `run(args, { services, signal })` resolves to `{ summary, data }`: a short
|
* `run(args, { services, signal, tools })` resolves to `{ summary, data }`: a
|
||||||
* sentence for people and a structured object for programs. It may add
|
* short sentence for people and a structured object for programs. It may add
|
||||||
* `images`, each `{ mimeType, data }` with base64 data.
|
* `images`, each `{ mimeType, data }` with base64 data. `tools` reaches other
|
||||||
|
* tools through the same catalog: `tools.has(name)` and
|
||||||
|
* `tools.call(name, args)`.
|
||||||
*/
|
*/
|
||||||
export function defineTool({
|
export function defineTool({
|
||||||
name,
|
name,
|
||||||
@@ -111,7 +113,8 @@ export function defineTool({
|
|||||||
* - Duplicate names fail unless the later tool is listed in `replace`.
|
* - Duplicate names fail unless the later tool is listed in `replace`.
|
||||||
* - Tools whose required services are missing are left out.
|
* - Tools whose required services are missing are left out.
|
||||||
* - Interceptors wrap every call in order, outermost first, as
|
* - Interceptors wrap every call in order, outermost first, as
|
||||||
* `(call, next) => next(call)` where `call` is `{ tool, args, signal }`.
|
* `(call, next) => next(call)` where `call` is `{ tool, args, signal }`,
|
||||||
|
* plus `parent`, the calling tool's name, when one tool calls another.
|
||||||
*/
|
*/
|
||||||
export function composeCatalog({
|
export function composeCatalog({
|
||||||
tools,
|
tools,
|
||||||
@@ -135,13 +138,28 @@ 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 = (name, args, signal, parent) => {
|
||||||
|
const tool = index.get(name);
|
||||||
|
if (!tool)
|
||||||
|
return Promise.reject(
|
||||||
|
new ToolError('unsupported', `No tool named ${name} is available`),
|
||||||
|
);
|
||||||
|
return chain({ tool, args, signal, ...(parent ? { parent } : {}) });
|
||||||
|
};
|
||||||
const execute = async ({ tool, args, signal }) => {
|
const execute = async ({ tool, args, signal }) => {
|
||||||
const problems = validateValue(tool.inputSchema, args);
|
const problems = validateValue(tool.inputSchema, args);
|
||||||
if (problems.length)
|
if (problems.length)
|
||||||
throw new ToolError('invalid_arguments', problems.join('; '));
|
throw new ToolError('invalid_arguments', problems.join('; '));
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await tool.run(args, { services, signal });
|
result = await tool.run(args, {
|
||||||
|
services,
|
||||||
|
signal,
|
||||||
|
tools: {
|
||||||
|
has: (name) => index.has(name),
|
||||||
|
call: (name, nested = {}) => invoke(name, nested, signal, tool.name),
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw fromSourceError(error);
|
throw fromSourceError(error);
|
||||||
}
|
}
|
||||||
@@ -171,13 +189,7 @@ export function composeCatalog({
|
|||||||
get: (name) => index.get(name),
|
get: (name) => index.get(name),
|
||||||
/** Validate arguments and run a tool through the interceptors. */
|
/** Validate arguments and run a tool through the interceptors. */
|
||||||
async call(name, args = {}, { signal } = {}) {
|
async call(name, args = {}, { signal } = {}) {
|
||||||
const tool = index.get(name);
|
return invoke(name, args, signal);
|
||||||
if (!tool)
|
|
||||||
throw new ToolError(
|
|
||||||
'unsupported',
|
|
||||||
`No tool named ${name} is available`,
|
|
||||||
);
|
|
||||||
return chain({ tool, args, signal });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+6
-1
@@ -68,7 +68,12 @@ export {
|
|||||||
} from './area.js';
|
} from './area.js';
|
||||||
export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js';
|
export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js';
|
||||||
export { toFunctionOutput, toFunctionTools } from './functions.js';
|
export { toFunctionOutput, toFunctionTools } from './functions.js';
|
||||||
export { SURFACES, TOOL_SURFACES, toolsForSurface } from './surfaces.js';
|
export {
|
||||||
|
SURFACES,
|
||||||
|
TOOL_SURFACES,
|
||||||
|
catalogForSurface,
|
||||||
|
toolsForSurface,
|
||||||
|
} from './surfaces.js';
|
||||||
export {
|
export {
|
||||||
createGeocodePlaceService,
|
createGeocodePlaceService,
|
||||||
createPlaceSearchService,
|
createPlaceSearchService,
|
||||||
|
|||||||
+21
-30
@@ -8,25 +8,17 @@ import {
|
|||||||
resolveArea,
|
resolveArea,
|
||||||
resolvePoint,
|
resolvePoint,
|
||||||
} from '../area.js';
|
} from '../area.js';
|
||||||
import { aircraftInArea } from './aviation.js';
|
|
||||||
import {
|
|
||||||
findMilitaryInstallations,
|
|
||||||
getCyclones,
|
|
||||||
getWeather,
|
|
||||||
} from './environment.js';
|
|
||||||
import { getActiveFires, getEarthquakes } from './hazards.js';
|
|
||||||
import { vesselsInArea } from './maritime.js';
|
|
||||||
|
|
||||||
const SECTION_LIMIT = 5;
|
const SECTION_LIMIT = 5;
|
||||||
const AWARENESS_LIMIT = 10;
|
const AWARENESS_LIMIT = 10;
|
||||||
const AWARENESS_RADIUS_KM = 250;
|
const AWARENESS_RADIUS_KM = 250;
|
||||||
|
|
||||||
/** Brief sections: the tool each one reuses and the services it needs. */
|
/** Brief sections: the tool each one calls and the arguments it passes. */
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{
|
{
|
||||||
key: 'weather',
|
key: 'weather',
|
||||||
label: 'Weather',
|
label: 'Weather',
|
||||||
tool: getWeather,
|
tool: 'get_weather',
|
||||||
args: (area, center) => ({
|
args: (area, center) => ({
|
||||||
location: { lat: center.lat, lon: center.lon },
|
location: { lat: center.lat, lon: center.lon },
|
||||||
}),
|
}),
|
||||||
@@ -34,31 +26,31 @@ const SECTIONS = [
|
|||||||
{
|
{
|
||||||
key: 'earthquakes',
|
key: 'earthquakes',
|
||||||
label: 'Earthquakes',
|
label: 'Earthquakes',
|
||||||
tool: getEarthquakes,
|
tool: 'get_earthquakes',
|
||||||
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'fires',
|
key: 'fires',
|
||||||
label: 'Active fires',
|
label: 'Active fires',
|
||||||
tool: getActiveFires,
|
tool: 'get_active_fires',
|
||||||
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'aircraft',
|
key: 'aircraft',
|
||||||
label: 'Aircraft',
|
label: 'Aircraft',
|
||||||
tool: aircraftInArea,
|
tool: 'aircraft_in_area',
|
||||||
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'vessels',
|
key: 'vessels',
|
||||||
label: 'Ships',
|
label: 'Ships',
|
||||||
tool: vesselsInArea,
|
tool: 'vessels_in_area',
|
||||||
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'cyclones',
|
key: 'cyclones',
|
||||||
label: 'Tropical cyclones',
|
label: 'Tropical cyclones',
|
||||||
tool: getCyclones,
|
tool: 'get_cyclones',
|
||||||
args: (area) => ({ area: area.argument }),
|
args: (area) => ({ area: area.argument }),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -67,7 +59,7 @@ const SECTIONS = [
|
|||||||
const AWARENESS_SECTIONS = [
|
const AWARENESS_SECTIONS = [
|
||||||
{
|
{
|
||||||
key: 'military_aircraft',
|
key: 'military_aircraft',
|
||||||
tool: aircraftInArea,
|
tool: 'aircraft_in_area',
|
||||||
args: (area) => ({
|
args: (area) => ({
|
||||||
area: area.argument,
|
area: area.argument,
|
||||||
military: true,
|
military: true,
|
||||||
@@ -76,17 +68,17 @@ const AWARENESS_SECTIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'aircraft',
|
key: 'aircraft',
|
||||||
tool: aircraftInArea,
|
tool: 'aircraft_in_area',
|
||||||
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'vessels',
|
key: 'vessels',
|
||||||
tool: vesselsInArea,
|
tool: 'vessels_in_area',
|
||||||
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'installations',
|
key: 'installations',
|
||||||
tool: findMilitaryInstallations,
|
tool: 'find_military_installations',
|
||||||
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -108,17 +100,16 @@ function sectionArea(resolved) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run every section whose services are supplied. A failing section is
|
* Run every section whose tool the catalog offers, through the catalog so
|
||||||
* reported as unavailable instead of failing the whole answer.
|
* replaced tools and interceptors apply. A failing section is reported as
|
||||||
|
* unavailable instead of failing the whole answer.
|
||||||
*/
|
*/
|
||||||
async function runSections(all, area, { services, signal }) {
|
async function runSections(all, area, { tools, signal }) {
|
||||||
const center = areaCenter(area);
|
const center = areaCenter(area);
|
||||||
const sections = all.filter(({ tool }) =>
|
const sections = all.filter(({ tool }) => tools.has(tool));
|
||||||
tool.requires.every((key) => services[key] != null),
|
|
||||||
);
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
sections.map(({ tool, args: build }) =>
|
sections.map(({ tool, args: build }) =>
|
||||||
tool.run(build(area, center), { services, signal }),
|
tools.call(tool, build(area, center)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
@@ -140,11 +131,11 @@ async function runSections(all, area, { services, signal }) {
|
|||||||
return { center, answers, lines };
|
return { center, answers, lines };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildBrief(args, { services, signal }) {
|
async function buildBrief(args, { services, signal, tools }) {
|
||||||
// Sections receive the resolved box so a place name is looked up once.
|
// Sections receive the resolved box so a place name is looked up once.
|
||||||
const area = sectionArea(await resolveArea(args.area, { services, signal }));
|
const area = sectionArea(await resolveArea(args.area, { services, signal }));
|
||||||
const { center, answers, lines } = await runSections(SECTIONS, area, {
|
const { center, answers, lines } = await runSections(SECTIONS, area, {
|
||||||
services,
|
tools,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
return { area, center, brief: answers, lines };
|
return { area, center, brief: answers, lines };
|
||||||
@@ -241,7 +232,7 @@ export const militaryAwareness = defineTool({
|
|||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
requires: ['military'],
|
requires: ['military'],
|
||||||
async run(args, { services, signal }) {
|
async run(args, { services, signal, tools }) {
|
||||||
const point = await resolvePoint(args.location, { services, signal });
|
const point = await resolvePoint(args.location, { services, signal });
|
||||||
const radiusKm = args.radius_km ?? AWARENESS_RADIUS_KM;
|
const radiusKm = args.radius_km ?? AWARENESS_RADIUS_KM;
|
||||||
const area = sectionArea(
|
const area = sectionArea(
|
||||||
@@ -251,7 +242,7 @@ export const militaryAwareness = defineTool({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const { answers, lines } = await runSections(AWARENESS_SECTIONS, area, {
|
const { answers, lines } = await runSections(AWARENESS_SECTIONS, area, {
|
||||||
services,
|
tools,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { composeCatalog, coreTools, ToolError } from '../index.js';
|
import {
|
||||||
|
catalogForSurface,
|
||||||
|
composeCatalog,
|
||||||
|
coreTools,
|
||||||
|
defineTool,
|
||||||
|
ToolError,
|
||||||
|
} from '../index.js';
|
||||||
|
|
||||||
const conditions = {
|
const conditions = {
|
||||||
observedAt: '2026-10-01T19:15:00.000Z',
|
observedAt: '2026-10-01T19:15:00.000Z',
|
||||||
@@ -536,6 +542,19 @@ test('military awareness gathers contacts around a point and marks failures', as
|
|||||||
reason: 'unavailable right now',
|
reason: 'unavailable right now',
|
||||||
});
|
});
|
||||||
assert.equal(result.data.radius_km, 100);
|
assert.equal(result.data.radius_km, 100);
|
||||||
|
const voice = catalogForSurface(catalog, 'voice');
|
||||||
|
assert.equal(voice.get('aircraft_in_area'), undefined);
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(
|
||||||
|
(
|
||||||
|
await voice.call('military_awareness', {
|
||||||
|
location: { lat: 32.7, lon: -117.2 },
|
||||||
|
radius_km: 100,
|
||||||
|
})
|
||||||
|
).data.sections,
|
||||||
|
),
|
||||||
|
['military_aircraft', 'aircraft', 'installations'],
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(await catalog.call('military_awareness', { location: { lat: 0, lon: 0 } }))
|
(await catalog.call('military_awareness', { location: { lat: 0, lon: 0 } }))
|
||||||
.data.radius_km,
|
.data.radius_km,
|
||||||
@@ -549,3 +568,36 @@ test('military awareness gathers contacts around a point and marks failures', as
|
|||||||
/radius_km/,
|
/radius_km/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('composites reach their sections through the catalog', async () => {
|
||||||
|
const replacement = defineTool({
|
||||||
|
name: 'get_weather',
|
||||||
|
title: 'Weather',
|
||||||
|
description: 'Replaced weather.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { location: { type: 'object' } },
|
||||||
|
},
|
||||||
|
run: async () => ({ summary: 'Replaced weather.', data: {} }),
|
||||||
|
});
|
||||||
|
const seen = [];
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: [...coreTools, replacement],
|
||||||
|
replace: ['get_weather'],
|
||||||
|
services: { weather, places },
|
||||||
|
interceptors: [
|
||||||
|
(call, next) => {
|
||||||
|
seen.push([call.tool.name, call.parent ?? null]);
|
||||||
|
return next(call);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = await catalog.call('situation_brief', {
|
||||||
|
area: { place: 'Austin' },
|
||||||
|
});
|
||||||
|
assert.equal(result.data.sections.weather.summary, 'Replaced weather.');
|
||||||
|
assert.deepEqual(seen, [
|
||||||
|
['situation_brief', null],
|
||||||
|
['get_weather', 'situation_brief'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
+34
-8
@@ -4,6 +4,8 @@
|
|||||||
* or both. Applications can pass overrides when they compose a surface.
|
* or both. Applications can pass overrides when they compose a surface.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { ToolError } from './catalog.js';
|
||||||
|
|
||||||
export const SURFACES = Object.freeze(['mcp', 'voice']);
|
export const SURFACES = Object.freeze(['mcp', 'voice']);
|
||||||
|
|
||||||
const voiceOff = Object.freeze({ voice: false });
|
const voiceOff = Object.freeze({ voice: false });
|
||||||
@@ -33,19 +35,18 @@ export const TOOL_SURFACES = Object.freeze({
|
|||||||
/**
|
/**
|
||||||
* The tools a surface offers, in their original order. `overrides` uses the
|
* The tools a surface offers, in their original order. `overrides` uses the
|
||||||
* table's shape and wins over it, such as `{ get_weather_map: { voice: true } }`.
|
* table's shape and wins over it, such as `{ get_weather_map: { voice: true } }`.
|
||||||
* Unknown tool names or surfaces throw, so a typo cannot silently do nothing.
|
* An override naming an unknown tool or surface throws, so a typo cannot
|
||||||
|
* silently do nothing.
|
||||||
*/
|
*/
|
||||||
export function toolsForSurface(tools, surface, overrides = {}) {
|
export function toolsForSurface(tools, surface, overrides = {}) {
|
||||||
if (!SURFACES.includes(surface))
|
if (!SURFACES.includes(surface))
|
||||||
throw new TypeError(`Unknown tool surface: ${surface}`);
|
throw new TypeError(`Unknown tool surface: ${surface}`);
|
||||||
const names = new Set(tools.map((tool) => tool.name));
|
const names = new Set(tools.map((tool) => tool.name));
|
||||||
for (const table of [TOOL_SURFACES, overrides]) {
|
for (const [name, entry] of Object.entries(overrides)) {
|
||||||
for (const [name, entry] of Object.entries(table)) {
|
if (!names.has(name)) throw new TypeError(`Unknown tool: ${name}`);
|
||||||
if (!names.has(name)) throw new TypeError(`Unknown tool: ${name}`);
|
for (const key of Object.keys(entry)) {
|
||||||
for (const key of Object.keys(entry)) {
|
if (!SURFACES.includes(key))
|
||||||
if (!SURFACES.includes(key))
|
throw new TypeError(`Unknown tool surface for ${name}: ${key}`);
|
||||||
throw new TypeError(`Unknown tool surface for ${name}: ${key}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tools.filter(
|
return tools.filter(
|
||||||
@@ -54,3 +55,28 @@ export function toolsForSurface(tools, surface, overrides = {}) {
|
|||||||
TOOL_SURFACES[tool.name]?.[surface]) !== false,
|
TOOL_SURFACES[tool.name]?.[surface]) !== false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A view of a composed catalog that lists and calls only the tools a surface
|
||||||
|
* offers. Tools that combine others still reach the whole catalog, so a
|
||||||
|
* composite such as military_awareness keeps its sections on every surface.
|
||||||
|
*/
|
||||||
|
export function catalogForSurface(catalog, surface, overrides = {}) {
|
||||||
|
const offered = new Set(
|
||||||
|
toolsForSurface(catalog.list(), surface, overrides).map(
|
||||||
|
(tool) => tool.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
list: () => catalog.list().filter((tool) => offered.has(tool.name)),
|
||||||
|
get: (name) => (offered.has(name) ? catalog.get(name) : undefined),
|
||||||
|
async call(name, args, options) {
|
||||||
|
if (!offered.has(name))
|
||||||
|
throw new ToolError(
|
||||||
|
'unsupported',
|
||||||
|
`No tool named ${name} is available`,
|
||||||
|
);
|
||||||
|
return catalog.call(name, args, options);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { TOOL_SURFACES, coreTools, toolsForSurface } from './index.js';
|
import {
|
||||||
|
TOOL_SURFACES,
|
||||||
|
catalogForSurface,
|
||||||
|
composeCatalog,
|
||||||
|
coreTools,
|
||||||
|
defineTool,
|
||||||
|
toolsForSurface,
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
const names = (tools) => tools.map((tool) => tool.name);
|
const names = (tools) => tools.map((tool) => tool.name);
|
||||||
|
|
||||||
@@ -33,6 +40,46 @@ test('overrides turn a tool on or off for one surface', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('every table entry names a core tool and a known surface', () => {
|
||||||
|
const names = new Set(coreTools.map((tool) => tool.name));
|
||||||
|
for (const [name, entry] of Object.entries(TOOL_SURFACES)) {
|
||||||
|
assert.ok(names.has(name), name);
|
||||||
|
assert.deepEqual(Object.keys(entry), ['voice']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a surface view hides tools from clients but not from composites', async () => {
|
||||||
|
const leaf = defineTool({
|
||||||
|
name: 'leaf',
|
||||||
|
title: 'Leaf',
|
||||||
|
description: 'A hidden building block.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
run: async () => ({ summary: 'leaf', data: {} }),
|
||||||
|
});
|
||||||
|
const outer = defineTool({
|
||||||
|
name: 'outer',
|
||||||
|
title: 'Outer',
|
||||||
|
description: 'Calls leaf.',
|
||||||
|
inputSchema: { type: 'object', properties: {} },
|
||||||
|
run: async (args, { tools }) => tools.call('leaf', {}),
|
||||||
|
});
|
||||||
|
const view = catalogForSurface(
|
||||||
|
composeCatalog({ tools: [leaf, outer] }),
|
||||||
|
'voice',
|
||||||
|
{ leaf: { voice: false } },
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
view.list().map((tool) => tool.name),
|
||||||
|
['outer'],
|
||||||
|
);
|
||||||
|
assert.equal(view.get('leaf'), undefined);
|
||||||
|
await assert.rejects(
|
||||||
|
view.call('leaf', {}),
|
||||||
|
(error) => error.code === 'unsupported',
|
||||||
|
);
|
||||||
|
assert.equal((await view.call('outer', {})).summary, 'leaf');
|
||||||
|
});
|
||||||
|
|
||||||
test('unknown surfaces and tool names are rejected', () => {
|
test('unknown surfaces and tool names are rejected', () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => toolsForSurface(coreTools, 'email'),
|
() => toolsForSurface(coreTools, 'email'),
|
||||||
|
|||||||
Reference in new issue
Block a user