Let an AI agent read and edit the open project through MCP (#429)
feuerwerk / gate (push) Successful in 4m40s
feuerwerk / suite (push) Successful in 18m43s

Stage 2 of #429. Until now an agent could only look at the editor.
Now it reads the project and changes it through the same commands
the user's own edits run, so every change shows up live and one
Ctrl+Z takes back a whole agent call.

- Read tools: list_assets, get_asset, get_graph and catalog. The
  catalog lists the commands with their arguments, the nodes of a
  graph from the same palette the user sees, asset types and
  variable kinds.
- run_commands runs its steps as one undo step named "Agent: ...".
  A failing step rolls back the steps before it. The answer names
  the step and the reason the editor gave.
- A step can name what it made ("as") and later steps use "$name".
  Each step is built after the one before has run, so one call can
  make a behavior and fill it.
- 23 commands cover assets, variables, scene objects and graphs.
  Textures, fonts, sounds and videos need a file and are left out.
- open, select, play and undo/redo drive the editor.
- Deleting asks the user with the same text as their own delete.
  The tab sends "wait", and the relay then gives the call 2 minutes
  instead of 30 seconds.
- tools.json is the only source of the argument schemas. argCheck
  checks every call against them before anything runs.
- An open menu now names its first entries in get_view, instead of
  an empty title.
- The new host functions live in editorToolHost.ts, so Editor.ts
  does not grow further.

Found while testing: e2e 71 hung for 30 s in Page.navigate after a
failure. The beforeunload guard of an unsaved project held the page
and hid the real error. The module now reports the error first and
waits for the save before it navigates.

Tests:
- Gate on melektaus-atx: CHECK EXIT 0, 317 files, 4903 tests.
- agent-edit 14/14, agent-view 10/10, relay 24/24.
- e2e 71 16/16 on atx and against the live site. One call builds a
  sprite with an arrow-key behavior, play moves it from x 0 to -125,
  and one undo restores the assets exactly.
- Counter-checks turned red: no rollback, a partial undo, no delete
  question, a relay that ignores "wait".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-18 11:08:39 +02:00
1 parent 48ec722660
commit 25af064249
19 files changed
+1913 -60

No files matched your search

+124 -5
View File
@@ -1,4 +1,10 @@
import type { Command } from '../../core/commands/Command';
import type { Project } from '../../core/model/Project';
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 {
@@ -7,19 +13,57 @@ export interface ToolResult {
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<string | null>;
/** 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;
}
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.
@@ -29,17 +73,25 @@ export async function runTool(
args: Record<string, unknown>,
host: ToolHost,
): Promise<ToolResult> {
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<string, unknown>, host: ToolHost): Promise<ToolResult> {
switch (name) {
case 'get_view': {
const view = host.view();
return { content: [{ type: 'text', text: JSON.stringify(view) }], structuredContent: view };
}
case 'screenshot': {
const maxWidth = args.maxWidth ?? DEFAULT_SCREENSHOT_WIDTH;
if (typeof maxWidth !== 'number' || !Number.isInteger(maxWidth) || maxWidth < 320 || maxWidth > 3840) {
return failure('maxWidth has to be a whole number from 320 to 3840');
}
const shot = await host.screenshot(maxWidth);
const shot = await host.screenshot((args.maxWidth as number | undefined) ?? DEFAULT_SCREENSHOT_WIDTH);
return {
content: [
{ type: 'image', data: shot.data, mimeType: 'image/png' },
@@ -47,7 +99,74 @@ export async function runTool(
],
};
}
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 '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<string, unknown>, host: ToolHost): Promise<ToolResult> {
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);
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<string, ArgSchema> = Object.fromEntries(
(TOOLS as unknown as { name: string; inputSchema: ArgSchema }[]).map((tool) => [
tool.name,
tool.inputSchema,
]),
);