Let an AI agent read and edit the open project through MCP (#429)
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:
1 parent
48ec722660
commit
25af064249
19 files changed
+1913
-60
No files matched your search
+118
-6
@@ -2,19 +2,34 @@ import { authHeaders, SESSION_COOKIE, sessionToken } from '../lib/auth.mjs';
|
||||
|
||||
/** The tokens this step makes; a run that died halfway leaves them for the next to revoke. */
|
||||
const TOKEN_NAME = 'e2e 71';
|
||||
const TOOLS = [
|
||||
'get_view',
|
||||
'screenshot',
|
||||
'list_assets',
|
||||
'get_asset',
|
||||
'get_graph',
|
||||
'catalog',
|
||||
'run_commands',
|
||||
'open',
|
||||
'select',
|
||||
'play',
|
||||
'undo',
|
||||
'redo',
|
||||
];
|
||||
|
||||
/**
|
||||
* An agent in the editor over the MCP relay (#429): a token from the account menu's dialog, the
|
||||
* agent switched on there, and an MCP client that reads what the user sees – the tab, the
|
||||
* selection, a screenshot – and hears when it changes. Switched off at the badge, the agent gets
|
||||
* no further.
|
||||
* selection, a screenshot – and hears when it changes. It builds a walking object in one call,
|
||||
* plays it, and one Ctrl+Z of the user takes it all back; deleting waits for the user's answer.
|
||||
* Switched off at the badge, the agent gets no further.
|
||||
*
|
||||
* In CI the relay stands in front of the PHP server on a port of its own (`FEUERWERK_MCP_BASE_URL`,
|
||||
* see tools/ci/serve.sh); on stein2 nginx serves it beside the editor.
|
||||
*/
|
||||
export async function mcp(t) {
|
||||
const { check, json, sleep, waitUntil, settle, press, locate, click, cdp, baseUrl, projectId } = t;
|
||||
const { clickText, menuTicked, exceptions, navigate, shot } = t;
|
||||
const { clickText, menuTicked, exceptions, navigate, shot, holdKey, entity } = t;
|
||||
const site = process.env.FEUERWERK_MCP_BASE_URL ?? baseUrl;
|
||||
const endpoint = `${site}mcp`;
|
||||
|
||||
@@ -117,10 +132,10 @@ export async function mcp(t) {
|
||||
const tools = (await rpc('tools/list')).body?.result?.tools.map((tool) => tool.name);
|
||||
const lonely = await callTool('get_view');
|
||||
check(
|
||||
'an agent with the token starts a session, lists get_view and screenshot, and hears no editor is on',
|
||||
'an agent with the token starts a session, lists its tools, and hears no editor is on',
|
||||
init.status === 200 &&
|
||||
init.body?.result?.serverInfo?.name === 'feuerwerk' &&
|
||||
JSON.stringify(tools) === '["get_view","screenshot"]' &&
|
||||
JSON.stringify(tools) === JSON.stringify(TOOLS) &&
|
||||
lonely?.isError === true &&
|
||||
/No Feuerwerk editor is connected/.test(lonely.content[0].text),
|
||||
JSON.stringify({ status: init.status, tools, lonely }),
|
||||
@@ -204,7 +219,98 @@ export async function mcp(t) {
|
||||
JSON.stringify({ shownTab, width, height, canvas, bytes: png.length, told }),
|
||||
);
|
||||
|
||||
// 71g. The badge is the off switch: the agent gets no further, and a revoked token not even in.
|
||||
// 71g. One call builds an object that walks left while the left arrow is held: a sprite, a behavior
|
||||
// placed under it, three nodes and three edges, as one undo step.
|
||||
const sceneId = await json('__gm.state.sceneToPlay()?.id');
|
||||
const assetsBefore = await json('JSON.stringify(__gm.state.project.assets)');
|
||||
const built = await callTool('run_commands', {
|
||||
label: 'walk left',
|
||||
steps: [
|
||||
{ command: 'create_asset', args: { type: 'sprite', name: `${TOKEN_NAME} hero` }, as: 'hero' },
|
||||
{ command: 'create_asset', args: { type: 'behavior', name: `${TOKEN_NAME} walk` }, as: 'walk' },
|
||||
{ command: 'place_asset', args: { in: sceneId, asset: '$hero', x: 0, y: 0 }, as: 'player' },
|
||||
{ command: 'place_asset', args: { in: sceneId, asset: '$walk', x: 0, y: 0, parent: '$player' } },
|
||||
{
|
||||
command: 'add_node',
|
||||
args: { asset: '$walk', node: 'OnKeyHeld', params: { key: 'ArrowLeft' } },
|
||||
as: 'key',
|
||||
},
|
||||
{ command: 'add_node', args: { asset: '$walk', node: 'Move', params: { dx: -300 } }, as: 'move' },
|
||||
{ command: 'add_node', args: { asset: '$walk', node: 'Owner' }, as: 'owner' },
|
||||
...[
|
||||
['$key', 'exec', 'exec'],
|
||||
['$key', 'dt', 'dt'],
|
||||
['$owner', 'object', 'object'],
|
||||
].map(([node, from, to]) => ({
|
||||
command: 'connect',
|
||||
args: { asset: '$walk', from: { node, port: from }, to: { node: '$move', port: to } },
|
||||
})),
|
||||
],
|
||||
});
|
||||
const made = built?.structuredContent?.made ?? [];
|
||||
const walkId = made.find((entry) => entry.step === 2)?.id;
|
||||
const playerId = made.find((entry) => entry.step === 3)?.id;
|
||||
const graph = walkId ? (await callTool('get_graph', { asset: walkId }))?.structuredContent : null;
|
||||
check(
|
||||
'run_commands builds a sprite with a walking behavior in one call, one undo step named by the agent',
|
||||
built?.isError === false &&
|
||||
(await json('__gm.state.commands.undoLabel')) === 'Agent: walk left' &&
|
||||
graph?.nodes?.map((node) => node.type).join() === 'OnKeyHeld,Move,Owner' &&
|
||||
graph.edges.length === 3,
|
||||
JSON.stringify({ built: built?.content?.[0]?.text?.slice(0, 300), graph: graph?.edges?.length }),
|
||||
);
|
||||
|
||||
// 71h. Played by the agent, the object walks left while the user holds the arrow.
|
||||
const started = await callTool('play', { action: 'play' });
|
||||
await waitUntil('__gm.state.mode === "play"', { timeout: 10000, label: 'the game runs' });
|
||||
await sleep(300);
|
||||
const x0 = (await entity(playerId))?.x;
|
||||
await holdKey('ArrowLeft', 400);
|
||||
const x1 = (await entity(playerId))?.x;
|
||||
const stopped = await callTool('play', { action: 'stop' });
|
||||
await waitUntil('__gm.state.mode === "edit"', { label: 'the game stopped' });
|
||||
check(
|
||||
'play started by the agent runs its behavior: the object walks left while ArrowLeft is held',
|
||||
started?.isError === false && stopped?.isError === false && x0 === 0 && x1 < -40,
|
||||
JSON.stringify({ started: started?.content, x0, x1 }),
|
||||
);
|
||||
|
||||
// 71i. One Ctrl+Z of the user takes the agent's whole step back.
|
||||
await press('z', { ctrl: true });
|
||||
await settle();
|
||||
check(
|
||||
'one Ctrl+Z takes back everything the call made',
|
||||
(await json('JSON.stringify(__gm.state.project.assets)')) === assetsBefore,
|
||||
JSON.stringify({ undo: await json('__gm.state.commands.undoLabel') }),
|
||||
);
|
||||
|
||||
// 71j. Deleting asks the user: the call waits for the answer, and a no leaves the asset.
|
||||
const temp = await callTool('run_commands', {
|
||||
steps: [{ command: 'create_asset', args: { type: 'sprite', name: `${TOKEN_NAME} temp` } }],
|
||||
});
|
||||
const tempId = temp?.structuredContent?.made?.[0]?.id;
|
||||
const deleting = callTool('run_commands', {
|
||||
steps: [{ command: 'delete_asset', args: { asset: tempId } }],
|
||||
});
|
||||
const asks = `The agent asks: Delete "${TOKEN_NAME} temp"?`;
|
||||
await waitUntil(`Boolean(__gm.locate(${JSON.stringify(asks)}, false))`, {
|
||||
label: 'the question is open',
|
||||
});
|
||||
await shot('71-agent-delete');
|
||||
await clickText('Cancel');
|
||||
const refused = await deleting;
|
||||
const kept = await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(tempId)})`);
|
||||
await callTool('undo');
|
||||
check(
|
||||
'deleting asks the user first; the call waits, and on Cancel the asset stays',
|
||||
refused?.isError === true &&
|
||||
/said no/.test(refused.content[0].text) &&
|
||||
kept === true &&
|
||||
(await json('JSON.stringify(__gm.state.project.assets)')) === assetsBefore,
|
||||
JSON.stringify({ refused: refused?.content, kept }),
|
||||
);
|
||||
|
||||
// 71k. The badge is the off switch: the agent gets no further, and a revoked token not even in.
|
||||
const badge = await locate('Agent · 1');
|
||||
await click(badge.x, badge.y);
|
||||
await waitUntil('__gm.agent().status === "off"', { label: 'the agent is off' });
|
||||
@@ -217,10 +323,16 @@ export async function mcp(t) {
|
||||
JSON.stringify({ after, revoked: revoked.status }),
|
||||
);
|
||||
check('the agent session threw nothing', exceptions().length === 0, exceptions().join(' | '));
|
||||
} catch (error) {
|
||||
// The clean-up below can fail on a page that hangs, and its error would hide this one.
|
||||
console.error('71 stopped:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await stream?.cancel().catch(() => {});
|
||||
await t.evaluate('__gm.switchAgent?.(false)').catch(() => {});
|
||||
await sweep();
|
||||
// An unsaved project holds the page with "Leave site?", which no one here answers.
|
||||
await waitUntil('__gm.state.saveStatus === "saved"', { timeout: 10000, label: 'saved' }).catch(() => {});
|
||||
if (site !== baseUrl) await navigate();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export interface RelayOptions {
|
||||
backend?: string;
|
||||
log?: (line: string) => void;
|
||||
callTimeoutMs?: number;
|
||||
/** How long a call may take once the tab said it is asking the user. */
|
||||
waitTimeoutMs?: number;
|
||||
helloTimeoutMs?: number;
|
||||
pingMs?: number;
|
||||
maxMessageBytes?: number;
|
||||
|
||||
+25
-8
@@ -34,6 +34,10 @@ const INSTRUCTIONS = [
|
||||
"Feuerwerk is a 2D game editor in the browser. You work inside the user's open editor tab,",
|
||||
'live: the user sees every change you make and can take it back with Ctrl+Z.',
|
||||
'Start with get_view to learn what the user is looking at; screenshot shows it as a picture.',
|
||||
'Read the project with list_assets, get_asset and get_graph. Change it with run_commands: its steps',
|
||||
'are commands from catalog topic "commands", run as one undo step, and a step can name what an',
|
||||
'earlier one made ("as": "x", then "$x"). Nodes for a graph come from catalog topic "nodes".',
|
||||
'A behavior acts on the object it is placed under: place it with parent set to that object.',
|
||||
'When a tool answers that no editor is connected, ask the user to open the editor and switch on',
|
||||
'"Agent" in its account menu.',
|
||||
].join(' ');
|
||||
@@ -66,6 +70,7 @@ export async function startRelay(options) {
|
||||
backend,
|
||||
log = () => {},
|
||||
callTimeoutMs = 30_000,
|
||||
waitTimeoutMs = 120_000,
|
||||
helloTimeoutMs = 10_000,
|
||||
pingMs = 25_000,
|
||||
maxMessageBytes = 32 * 1024 * 1024,
|
||||
@@ -421,7 +426,7 @@ export async function startRelay(options) {
|
||||
detach(old, 'Another editor tab took over');
|
||||
old.conn.close(CLOSE.replaced, 'replaced');
|
||||
}
|
||||
const tab = new Tab(user, conn, callTimeoutMs);
|
||||
const tab = new Tab(user, conn, callTimeoutMs, waitTimeoutMs);
|
||||
tab.onView = () => viewChanged(key);
|
||||
tabs.set(key, tab);
|
||||
conn.send(JSON.stringify({ type: 'welcome', user }));
|
||||
@@ -472,27 +477,39 @@ class Tab {
|
||||
#pending = new Map();
|
||||
#next = 1;
|
||||
|
||||
constructor(user, conn, timeoutMs) {
|
||||
constructor(user, conn, timeoutMs, waitTimeoutMs) {
|
||||
this.user = user;
|
||||
this.conn = conn;
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.waitTimeoutMs = waitTimeoutMs;
|
||||
}
|
||||
|
||||
/** @returns {Promise<{content: object[], isError?: boolean, structuredContent?: object}>} */
|
||||
call(tool, args) {
|
||||
const id = this.#next++;
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.#pending.delete(id);
|
||||
resolve(failure(`The editor did not answer within ${Math.round(this.timeoutMs / 1000)} s`));
|
||||
}, this.timeoutMs);
|
||||
this.#pending.set(id, { resolve, timer });
|
||||
this.#pending.set(id, { resolve, timer: this.#timer(id, this.timeoutMs) });
|
||||
this.conn.send(JSON.stringify({ type: 'call', id, tool, args }));
|
||||
});
|
||||
}
|
||||
|
||||
#timer(id, ms) {
|
||||
return setTimeout(() => {
|
||||
const waiting = this.#pending.get(id);
|
||||
this.#pending.delete(id);
|
||||
waiting?.resolve(failure(`The editor did not answer within ${Math.round(ms / 1000)} s`));
|
||||
}, ms);
|
||||
}
|
||||
|
||||
receive(message) {
|
||||
if (message?.type === 'result') {
|
||||
if (message?.type === 'wait') {
|
||||
// The tab is asking the user something: the call gets the longer time, once.
|
||||
const waiting = this.#pending.get(message.id);
|
||||
if (!waiting || waiting.extended) return;
|
||||
clearTimeout(waiting.timer);
|
||||
waiting.timer = this.#timer(message.id, this.waitTimeoutMs);
|
||||
waiting.extended = true;
|
||||
} else if (message?.type === 'result') {
|
||||
const waiting = this.#pending.get(message.id);
|
||||
if (!waiting) return;
|
||||
this.#pending.delete(message.id);
|
||||
|
||||
Reference in new issue
Block a user