diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 69312e248..9fd4c4a4c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -886,5 +886,6 @@ "src/tools/queries/surveillance.test.mjs", "src/tools/queries/infrastructure.test.mjs", "src/tools/queries/events.test.mjs", - "src/tools/functions.test.mjs" + "src/tools/functions.test.mjs", + "src/tools/surfaces.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9b14f6392..03a648a71 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3122,7 +3122,8 @@ "src/tools/queries/infrastructure.js", "src/tools/queries/events.js", "src/data/bhoteKoshiFloodPath.js", - "src/tools/functions.js" + "src/tools/functions.js", + "src/tools/surfaces.js" ], "external": ["satellite.js"] }, diff --git a/server/mcp/server.js b/server/mcp/server.js index daacd3d3c..3cf75ee0f 100644 --- a/server/mcp/server.js +++ b/server/mcp/server.js @@ -1,7 +1,11 @@ /** The local MCP server: Core's tools over services backed by a running app. */ import { readFileSync } from 'node:fs'; -import { composeCatalog, coreTools } from '../../src/tools/index.js'; +import { + composeCatalog, + coreTools, + toolsForSurface, +} from '../../src/tools/index.js'; import { createMcpServer } from '../../src/tools/mcp/index.js'; import { DEFAULT_API_BASE, createLocalToolServices } from './services.js'; @@ -20,7 +24,7 @@ export function createLocalMcpServer({ ); return createMcpServer({ catalog: composeCatalog({ - tools: coreTools, + tools: toolsForSurface(coreTools, 'mcp'), services: createLocalToolServices({ apiBase, fetchImpl }), }), name: 'gods-eye-view', diff --git a/server/standalone/voiceTools.js b/server/standalone/voiceTools.js index 9e7c26dce..35dc47440 100644 --- a/server/standalone/voiceTools.js +++ b/server/standalone/voiceTools.js @@ -1,16 +1,18 @@ /** * The voice session's tools for the standalone server: the app actions plus - * Core's catalog queries, which the browser runs through the same catalog. - * Links to the app are left out, since voice runs inside it. + * the catalog tools voice offers (see src/tools/surfaces.js), which the + * browser runs through the same catalog. */ -import { coreTools, toFunctionTools } from '../../src/tools/index.js'; +import { + coreTools, + toFunctionTools, + toolsForSurface, +} from '../../src/tools/index.js'; import { realtimeSessionTools } from '../providers/openai/tools.js'; -export const VOICE_EXCLUDED_QUERIES = Object.freeze(['open_in_gods_eye_view']); - export function standaloneVoiceTools() { return realtimeSessionTools( - toFunctionTools(coreTools, { exclude: VOICE_EXCLUDED_QUERIES }), + toFunctionTools(toolsForSurface(coreTools, 'voice')), ); } diff --git a/src/standalone/toolCatalog.js b/src/standalone/toolCatalog.js index b8c550427..ef1aa709a 100644 --- a/src/standalone/toolCatalog.js +++ b/src/standalone/toolCatalog.js @@ -6,14 +6,18 @@ export function loadToolCatalog() { pending ??= Promise.all([ import('../tools/index.js'), import('../tools/services.js'), - ]).then(([{ composeCatalog, coreTools }, { createToolServices }]) => - composeCatalog({ - tools: coreTools, - services: createToolServices({ - fetchImpl: (...args) => globalThis.fetch(...args), - appUrl: globalThis.location.origin, + ]).then( + ([ + { composeCatalog, coreTools, toolsForSurface }, + { createToolServices }, + ]) => + composeCatalog({ + tools: toolsForSurface(coreTools, 'voice'), + services: createToolServices({ + fetchImpl: (...args) => globalThis.fetch(...args), + appUrl: globalThis.location.origin, + }), }), - }), ); pending.catch(() => { pending = null; diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index e8ff998a0..d6d6d4325 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -221,6 +221,8 @@ test('Realtime sessions carry supplied tools, and the standalone voice adds the assert.ok(names.includes('get_weather')); assert.ok(names.includes('military_awareness')); assert.ok(!names.includes('open_in_gods_eye_view')); + assert.ok(!names.includes('aircraft_in_area')); + assert.ok(!names.includes('get_weather_map')); // The action of the same name answers satellite passes. assert.equal( tools.findLast((tool) => tool.name === 'next_satellite_pass'), diff --git a/src/tools/index.js b/src/tools/index.js index 0ba01004b..66aa90bac 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -68,6 +68,7 @@ export { } from './area.js'; export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js'; export { toFunctionOutput, toFunctionTools } from './functions.js'; +export { SURFACES, TOOL_SURFACES, toolsForSurface } from './surfaces.js'; export { createGeocodePlaceService, createPlaceSearchService, diff --git a/src/tools/surfaces.js b/src/tools/surfaces.js new file mode 100644 index 000000000..45c18f015 --- /dev/null +++ b/src/tools/surfaces.js @@ -0,0 +1,56 @@ +/** + * Which surfaces offer each tool. A tool is on every surface unless this + * table turns it off; edit an entry to turn a tool on or off for MCP, voice, + * or both. Applications can pass overrides when they compose a surface. + */ + +export const SURFACES = Object.freeze(['mcp', 'voice']); + +const voiceOff = Object.freeze({ voice: false }); + +export const TOOL_SURFACES = Object.freeze({ + // Voice runs inside the app, so a link to it adds nothing. + open_in_gods_eye_view: voiceOff, + // Voice has an app action of the same name. + next_satellite_pass: voiceOff, + // Voice receives text only, so tools that answer with an image are off. + get_weather_map: voiceOff, + get_recent_imagery: voiceOff, + get_cctv_snapshot: voiceOff, + // Voice shows the HUD caption in the app itself. + get_hud_caption: voiceOff, + // Voice answers these from the loaded layers with analyst_query. + aircraft_in_area: voiceOff, + vessels_in_area: voiceOff, + find_aircraft: voiceOff, + find_vessel: voiceOff, + get_earthquakes: voiceOff, + get_active_fires: voiceOff, + find_infrastructure: voiceOff, + satellites_overhead: voiceOff, +}); + +/** + * 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 } }`. + * Unknown tool names or surfaces throw, so a typo cannot silently do nothing. + */ +export function toolsForSurface(tools, surface, overrides = {}) { + if (!SURFACES.includes(surface)) + throw new TypeError(`Unknown tool surface: ${surface}`); + const names = new Set(tools.map((tool) => tool.name)); + for (const table of [TOOL_SURFACES, overrides]) { + for (const [name, entry] of Object.entries(table)) { + if (!names.has(name)) throw new TypeError(`Unknown tool: ${name}`); + for (const key of Object.keys(entry)) { + if (!SURFACES.includes(key)) + throw new TypeError(`Unknown tool surface for ${name}: ${key}`); + } + } + } + return tools.filter( + (tool) => + (overrides[tool.name]?.[surface] ?? + TOOL_SURFACES[tool.name]?.[surface]) !== false, + ); +} diff --git a/src/tools/surfaces.test.mjs b/src/tools/surfaces.test.mjs new file mode 100644 index 000000000..4305783f9 --- /dev/null +++ b/src/tools/surfaces.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { TOOL_SURFACES, coreTools, toolsForSurface } from './index.js'; + +const names = (tools) => tools.map((tool) => tool.name); + +test('MCP offers every tool and voice leaves out the table entries', () => { + assert.deepEqual(names(toolsForSurface(coreTools, 'mcp')), names(coreTools)); + const voice = names(toolsForSurface(coreTools, 'voice')); + assert.equal( + voice.length, + coreTools.length - Object.keys(TOOL_SURFACES).length, + ); + for (const name of Object.keys(TOOL_SURFACES)) + assert.ok(!voice.includes(name), name); + assert.ok(voice.includes('get_weather')); + assert.ok(voice.includes('military_awareness')); +}); + +test('overrides turn a tool on or off for one surface', () => { + const voice = names( + toolsForSurface(coreTools, 'voice', { + get_weather_map: { voice: true }, + get_weather: { voice: false }, + }), + ); + assert.ok(voice.includes('get_weather_map')); + assert.ok(!voice.includes('get_weather')); + assert.ok( + !names( + toolsForSurface(coreTools, 'mcp', { get_weather: { mcp: false } }), + ).includes('get_weather'), + ); +}); + +test('unknown surfaces and tool names are rejected', () => { + assert.throws( + () => toolsForSurface(coreTools, 'email'), + /Unknown tool surface/, + ); + assert.throws( + () => toolsForSurface(coreTools, 'voice', { get_wether: { voice: false } }), + /Unknown tool: get_wether/, + ); + assert.throws( + () => + toolsForSurface(coreTools, 'voice', { get_weather: { voise: false } }), + /Unknown tool surface for get_weather: voise/, + ); +});