import type { Command } from '../../core/commands/Command'; import type { Project } from '../../core/model/Project'; import { lintLayout } from '../graph/arrange'; import { layoutOf, type MeasureNode } from '../graph/graphLayout'; import { type ArgSchema, argProblem } from './argCheck'; import type { AgentView } from './agentView'; import { AgentEdit, deletedAssets, type EditStep } from './editPlan'; import { catalog, type CatalogTopic, getAsset, getGraph, listAssets } from './readTools'; import TOOLS from './tools.json'; /** What a tool answers, as the relay hands it on to the agent (MCP's `CallToolResult`). */ export interface ToolResult { content: ({ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string })[]; isError?: boolean; structuredContent?: object; } /** What the agent can pick for the user to see; the schema in `tools.json` leaves every field but `kind` open. */ export interface AgentSelection { kind: 'none' | 'asset' | 'objects' | 'nodes'; asset?: string; in?: string; objects?: string[]; nodes?: string[]; } export type PlayAction = 'play' | 'pause' | 'resume' | 'stop'; /** What the tools need of the editor. */ export interface ToolHost { view(): AgentView; /** The editor as drawn, as a base64 PNG at most `maxWidth` pixels wide. */ screenshot(maxWidth: number): Promise<{ data: string; width: number; height: number }>; project(): Project; /** Runs a command as one undo step: null once done, else why it was turned down. */ execute(command: Command): string | null; /** Asks the user whether these assets may go; null once they agreed, else why not. */ confirmDeletes(assetIds: readonly string[]): Promise; /** Brings up an asset's tab, or a fixed tab by its id; null once done, else why not. */ open(target: { asset?: string; tab?: string }): string | null; select(selection: AgentSelection): string | null; play(action: PlayAction): string | null; /** Takes back (or does again) the last edit: its label, null with nothing to take. */ undo(): string | null; redo(): string | null; /** How the nodes of a graph are measured: drawn off screen in the editor, worked out without one. */ measure?(assetId: string): MeasureNode; } const DEFAULT_SCREENSHOT_WIDTH = 1280; /** Longer than this, an answer is cut: an agent reads it whole, every time. */ const MAX_ANSWER_CHARS = 60_000; export function failure(text: string): ToolResult { return { content: [{ type: 'text', text }], isError: true }; } function answer(data: unknown): ToolResult { let text = JSON.stringify(data); if (text.length > MAX_ANSWER_CHARS) { text = `${text.slice(0, MAX_ANSWER_CHARS)}… [cut at ${MAX_ANSWER_CHARS} characters – ask for less]`; } const structured = data !== null && typeof data === 'object' && !Array.isArray(data); return { content: [{ type: 'text', text }], ...(structured ? { structuredContent: data } : {}) }; } function done(text: string, structuredContent?: object): ToolResult { return { content: [{ type: 'text', text }], ...(structuredContent ? { structuredContent } : {}) }; } /** * Runs one of the tools of `tools.json` (#429). The relay has checked the name against the same * list; the arguments are checked here, where their meaning is known. */ export async function runTool( name: string, args: Record, host: ToolHost, ): Promise { const schema = ARGS[name]; if (!schema) return failure(`This editor has no tool ${name}; reload it to get the newest.`); const problem = argProblem(schema, args); if (problem) return failure(problem); try { return await run(name, args, host); } catch (error) { return failure(error instanceof Error ? error.message : String(error)); } } async function run(name: string, args: Record, host: ToolHost): Promise { switch (name) { case 'get_view': { const view = host.view(); return { content: [{ type: 'text', text: JSON.stringify(view) }], structuredContent: view }; } case 'screenshot': { const shot = await host.screenshot((args.maxWidth as number | undefined) ?? DEFAULT_SCREENSHOT_WIDTH); return { content: [ { type: 'image', data: shot.data, mimeType: 'image/png' }, { type: 'text', text: `The editor as the user sees it, ${shot.width}x${shot.height} pixels.` }, ], }; } case 'list_assets': return answer({ assets: listAssets(host.project(), args.type as string, args.query as string) }); case 'get_asset': return answer(getAsset(host.project(), args.asset as string) ?? missing(args.asset)); case 'get_graph': return answer(getGraph(host.project(), args.asset as string) ?? missing(args.asset, ' with a graph')); case 'lint_graph': { const layout = layoutOf(host.project(), args.asset as string, host.measure?.(args.asset as string)); return answer(layout ? lintLayout(layout) : missing(args.asset, ' with a graph')); } case 'catalog': return answer({ [args.topic as string]: catalog(host.project(), args.topic as CatalogTopic, args) }); case 'run_commands': return runCommands(args, host); case 'open': return outcome(host.open(args), `Opened; the user sees it now.`); case 'select': return outcome(host.select(args as unknown as AgentSelection), 'Selected.'); case 'play': return outcome(host.play(args.action as PlayAction), `${args.action as string}: done.`); case 'undo': case 'redo': { const step = name === 'undo' ? () => host.undo() : () => host.redo(); const labels: string[] = []; for (let i = 0; i < ((args.steps as number | undefined) ?? 1); i++) { const label = step(); if (label === null) break; labels.push(label); } if (labels.length === 0) return failure(`There is nothing to ${name}.`); return done(`${name === 'undo' ? 'Undone' : 'Redone'}: ${labels.join('; ')}.`, { [name]: labels }); } default: return failure(`This editor has no tool ${name}; reload it to get the newest.`); } } function missing(id: unknown, what = ''): never { throw new Error(`There is no asset ${String(id)}${what} – list_assets has the ids`); } function outcome(refusal: string | null, text: string): ToolResult { return refusal === null ? done(text) : failure(refusal); } async function runCommands(args: Record, host: ToolHost): Promise { const steps = args.steps as EditStep[]; const deletes = deletedAssets(steps); if (deletes.length > 0) { const refusal = await host.confirmDeletes(deletes); if (refusal !== null) return failure(refusal); } const label = `Agent: ${(args.label as string | undefined) ?? steps.map((step) => step.command).join(', ')}`; const edit = new AgentEdit(label, steps, host.measure && ((assetId) => host.measure!(assetId))); const refusal = host.execute(edit); if (refusal !== null) return failure(`Nothing was changed. ${refusal}`); const made = edit.made.flatMap((ids, index) => (ids ? [{ step: index + 1, ...ids }] : [])); const playing = host.view().play !== 'editing'; const lines = [ `Done as one undo step ("${label}").`, ...made.map(({ step, ...ids }) => `Step ${step} made ${JSON.stringify(ids)}.`), ...(playing ? ['The game is running: this changed the running copy only, and it is gone when the game stops.'] : []), ]; return done(lines.join('\n'), { label, made }); } /** The arguments of each tool, as `tools.json` gives them to the agent and the relay. */ const ARGS: Record = Object.fromEntries( (TOOLS as unknown as { name: string; inputSchema: ArgSchema }[]).map((tool) => [ tool.name, tool.inputSchema, ]), );