mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
feat: choose per tool whether MCP, voice or both offer it
src/tools/surfaces.js lists the tools a surface leaves out, and the MCP server, the voice session's tool list and the browser catalog voice calls all filter through it. Voice leaves out tools that answer with images, link to the app, or repeat what its app actions already answer.
This commit is contained in:
1 parent
b4067f9859
commit
df0e0f32e8
9 files changed
+138
-17
No files matched your search
@@ -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"
|
||||
]
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user