mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Hosts serve panels from their own sites and may refuse other addresses; Codex refuses any address on the user's machine. The panel now never requests the app's server: it loads the app's panel build and data through panel_request, a tool only the panel may call, which requests the path from the app's server. One path in every host; only map providers load directly. npm run build:panel writes the panel build to dist/panel, served at /panel/: one app script, one stylesheet, Cesium's script, and Cesium's workers as one script Cesium runs from memory. Tools may now be marked app-only. The panel CORS rules are gone.
227 lines
7.4 KiB
JavaScript
227 lines
7.4 KiB
JavaScript
/**
|
|
* Tool definitions and their composition into a callable catalog.
|
|
*
|
|
* A tool declares what it does, its arguments, and which services it reads.
|
|
* It knows nothing about the surface that exposes it (MCP, voice) or the
|
|
* transport its services use. Applications compose the tools they want with
|
|
* the services they supply, plus optional interceptors that wrap every call.
|
|
*/
|
|
|
|
import { assertSupportedSchema, validateValue } from './schema.js';
|
|
|
|
const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
const KINDS = new Set(['query', 'action']);
|
|
/** Who may call a tool, as MCP Apps names them: the model, the app's panel. */
|
|
const VISIBILITY = new Set(['model', 'app']);
|
|
|
|
/** Error codes a tool may report. Each is safe to show to a model or person. */
|
|
export const TOOL_ERROR_CODES = Object.freeze([
|
|
'invalid_arguments',
|
|
'unavailable',
|
|
'unsupported',
|
|
'malformed',
|
|
'retry_later',
|
|
]);
|
|
|
|
/** An expected failure with a stable code and a message written for the caller. */
|
|
export class ToolError extends Error {
|
|
constructor(code, message, { retryAfterSeconds = null } = {}) {
|
|
if (!TOOL_ERROR_CODES.includes(code))
|
|
throw new TypeError(`Unknown tool error code: ${code}`);
|
|
super(message);
|
|
this.name = 'ToolError';
|
|
this.code = code;
|
|
this.retryAfterSeconds = retryAfterSeconds;
|
|
}
|
|
}
|
|
|
|
// Live sources report failures with these codes (src/sources/live/contract.js).
|
|
const SOURCE_ERROR_CODES = {
|
|
limited: 'retry_later',
|
|
denied: 'unavailable',
|
|
unavailable: 'unavailable',
|
|
malformed: 'malformed',
|
|
unsupported: 'unsupported',
|
|
};
|
|
|
|
/** Translate a source failure into a tool error; other errors pass through. */
|
|
export function fromSourceError(error) {
|
|
const code =
|
|
error?.name === 'LiveSourceError' &&
|
|
Object.hasOwn(SOURCE_ERROR_CODES, error.code)
|
|
? SOURCE_ERROR_CODES[error.code]
|
|
: null;
|
|
if (!code) return error;
|
|
const retryAfterSeconds =
|
|
code === 'retry_later' && Number.isFinite(error.retryAfterMs)
|
|
? Math.ceil(error.retryAfterMs / 1000)
|
|
: null;
|
|
return new ToolError(code, error.message, { retryAfterSeconds });
|
|
}
|
|
|
|
/**
|
|
* Validate and freeze a tool definition.
|
|
*
|
|
* `run(args, { services, signal, tools })` resolves to `{ summary, data }`: a
|
|
* short sentence for people and a structured object for programs. It may add
|
|
* `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({
|
|
name,
|
|
kind = 'query',
|
|
title,
|
|
description,
|
|
inputSchema,
|
|
annotations = {},
|
|
requires = [],
|
|
ui = null,
|
|
run,
|
|
}) {
|
|
if (!TOOL_NAME.test(String(name)))
|
|
throw new TypeError(`Invalid tool name: ${name}`);
|
|
if (!KINDS.has(kind)) throw new TypeError(`Invalid tool kind: ${kind}`);
|
|
if (typeof title !== 'string' || !title)
|
|
throw new TypeError(`${name} needs a title`);
|
|
if (typeof description !== 'string' || !description)
|
|
throw new TypeError(`${name} needs a description`);
|
|
assertSupportedSchema(inputSchema, `${name}.inputSchema`);
|
|
if (inputSchema.type !== 'object')
|
|
throw new TypeError(`${name}.inputSchema must describe an object`);
|
|
if (
|
|
!Array.isArray(requires) ||
|
|
requires.some((key) => typeof key !== 'string')
|
|
)
|
|
throw new TypeError(`${name}.requires must list service names`);
|
|
if (typeof run !== 'function') throw new TypeError(`${name} needs run()`);
|
|
if (ui !== null) {
|
|
if (
|
|
ui?.resourceUri !== undefined &&
|
|
!(
|
|
typeof ui.resourceUri === 'string' && ui.resourceUri.startsWith('ui://')
|
|
)
|
|
)
|
|
throw new TypeError(`${name}.ui.resourceUri must be a ui:// URI`);
|
|
if (
|
|
ui?.visibility !== undefined &&
|
|
!(
|
|
Array.isArray(ui.visibility) &&
|
|
ui.visibility.length > 0 &&
|
|
ui.visibility.every((who) => VISIBILITY.has(who))
|
|
)
|
|
)
|
|
throw new TypeError(`${name}.ui.visibility must list model and/or app`);
|
|
if (ui?.resourceUri === undefined && ui?.visibility === undefined)
|
|
throw new TypeError(`${name}.ui needs a resourceUri or a visibility`);
|
|
}
|
|
return Object.freeze({
|
|
name,
|
|
kind,
|
|
title,
|
|
description,
|
|
inputSchema: structuredClone(inputSchema),
|
|
annotations: Object.freeze({
|
|
readOnlyHint: kind === 'query',
|
|
...annotations,
|
|
}),
|
|
requires: Object.freeze([...requires]),
|
|
ui: ui
|
|
? Object.freeze({
|
|
...(ui.resourceUri ? { resourceUri: ui.resourceUri } : {}),
|
|
...(ui.visibility
|
|
? { visibility: Object.freeze([...ui.visibility]) }
|
|
: {}),
|
|
})
|
|
: null,
|
|
run,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Compose tools with the services an application supplies.
|
|
*
|
|
* - Duplicate names fail unless the later tool is listed in `replace`.
|
|
* - Tools whose required services are missing are left out.
|
|
* - Interceptors wrap every call in order, outermost first, as
|
|
* `(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({
|
|
tools,
|
|
replace = [],
|
|
services = {},
|
|
interceptors = [],
|
|
}) {
|
|
const byName = new Map();
|
|
const replaceable = new Set(replace);
|
|
for (const tool of tools) {
|
|
if (byName.has(tool.name) && !replaceable.has(tool.name))
|
|
throw new TypeError(`Duplicate tool name: ${tool.name}`);
|
|
byName.set(tool.name, tool);
|
|
}
|
|
for (const name of replaceable) {
|
|
if (!byName.has(name))
|
|
throw new TypeError(`Cannot replace unknown tool: ${name}`);
|
|
}
|
|
const available = [...byName.values()].filter((tool) =>
|
|
tool.requires.every((key) => services[key] != null),
|
|
);
|
|
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 problems = validateValue(tool.inputSchema, args);
|
|
if (problems.length)
|
|
throw new ToolError('invalid_arguments', problems.join('; '));
|
|
let result;
|
|
try {
|
|
result = await tool.run(args, {
|
|
services,
|
|
signal,
|
|
tools: {
|
|
has: (name) => index.has(name),
|
|
call: (name, nested = {}) => invoke(name, nested, signal, tool.name),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
throw fromSourceError(error);
|
|
}
|
|
if (!result || typeof result.summary !== 'string' || !result.data)
|
|
throw new TypeError(`${tool.name} returned no summary or data`);
|
|
if (
|
|
result.images !== undefined &&
|
|
(!Array.isArray(result.images) ||
|
|
!result.images.every(
|
|
(item) =>
|
|
typeof item?.mimeType === 'string' &&
|
|
typeof item?.data === 'string',
|
|
))
|
|
)
|
|
throw new TypeError(`${tool.name} returned malformed images`);
|
|
return result;
|
|
};
|
|
const chain = interceptors.reduceRight(
|
|
(next, interceptor) => (call) => interceptor(call, next),
|
|
execute,
|
|
);
|
|
|
|
return Object.freeze({
|
|
/** Available tool definitions, in composition order. */
|
|
list: () => [...available],
|
|
/** The available tool with this name, or undefined. */
|
|
get: (name) => index.get(name),
|
|
/** Validate arguments and run a tool through the interceptors. */
|
|
async call(name, args = {}, { signal } = {}) {
|
|
return invoke(name, args, signal);
|
|
},
|
|
});
|
|
}
|