Files
feuerwerk/src/editor/agent/commandCatalog.ts
T
adminandClaude Opus 5.5 25af064249
feuerwerk / gate (push) Successful in 4m40s
feuerwerk / suite (push) Successful in 18m43s
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>
2026-10-18 11:08:39 +02:00

427 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
CreateAssetCommand,
DeleteAssetCommand,
DuplicateAssetCommand,
RenameAssetCommand,
ResetAssetPropertyCommand,
SetAssetPropertyCommand,
SetStartSceneCommand,
} from '../../core/commands/assetCommands';
import { type Command, CommandError } from '../../core/commands/Command';
import {
AddNodeCommand,
ConnectCommand,
DisconnectCommand,
MoveNodeCommand,
RemoveNodeCommand,
SetNodeParamCommand,
} from '../../core/commands/graphCommands';
import {
ClearOverrideCommand,
MoveSceneObjectCommand,
PlaceAssetCommand,
RemoveSceneObjectsCommand,
RenameSceneObjectCommand,
SetOverrideCommand,
SetParentCommand,
} from '../../core/commands/sceneObjectCommands';
import {
AddVariableCommand,
RemoveVariableCommand,
RenameVariableCommand,
} from '../../core/commands/variableCommands';
import { ASSET_TYPES, type AssetType } from '../../core/model/AssetTypes';
import type { PropertyBag, PropertyValue } from '../../core/model/Properties';
import { findGraphAsset, findObjectHolder, type Project } from '../../core/model/Project';
import { VARIABLE_KINDS, type VariableKind } from '../../core/properties/kinds';
import { coerceValue } from '../../core/properties/PropertySchema';
import { type PaletteEntry, paletteEntries } from '../graph/paletteEntries';
import type { ArgSchema } from './argCheck';
/** What a command is built from: the project as it stands before the edit, and where new nodes go. */
export interface BuildContext {
project: Project;
/** A free place for a node the agent gave none, right of what the graph and this edit hold. */
spot(assetId: string): { x: number; y: number };
}
/** The command, and the ids of what it makes – what later steps of the same edit can name. */
export interface Built {
command: Command;
made?: Record<string, string>;
}
type Args = Record<string, unknown>;
/** One command the agent can run (#429): what it does, its arguments, and how it is made. */
export interface CatalogCommand {
name: string;
description: string;
args: ArgSchema;
/** Deletes something the user has to agree to first – decision 4 of #429. */
confirms?: true;
build(args: Args, context: BuildContext): Built;
}
/** Assets the agent cannot make: each needs a file, and there is no upload for agents. */
const FILE_TYPES: readonly AssetType[] = ['texture', 'font', 'sound', 'video'];
const id = (description: string): ArgSchema => ({ type: 'string', minLength: 1, description });
const number = (description: string): ArgSchema => ({ type: 'number', description });
const anyValue: ArgSchema = {
description: 'Any JSON value; it is checked against the kind of the property.',
};
const object = (properties: Record<string, ArgSchema>, required: string[]): ArgSchema => ({
type: 'object',
properties,
required,
additionalProperties: false,
});
const ASSET = id('Id of the asset.');
const HOLDER = id('Id of the scene or container the object is in.');
const OBJECT = id('Id of the placed object.');
const GRAPH = id('Id of the asset holding the graph (behavior, state machine, shader, ...).');
const NODE = id('Id of the node.');
const PORT_END = object({ node: NODE, port: id('Key of the port, as get_graph lists it.') }, [
'node',
'port',
]);
const text = (args: Args, key: string): string => args[key] as string;
const numberOf = (args: Args, key: string): number => args[key] as number;
export const COMMANDS: readonly CatalogCommand[] = [
{
name: 'create_asset',
description:
'Makes a new asset. A scene is where objects are placed; a sprite, text or camera is placed into one; ' +
"a behavior is a graph of nodes that runs on the object it is placed under. Textures, fonts, sounds and videos need a file and can't be made here.",
args: object(
{
type: {
type: 'string',
enum: ASSET_TYPES.filter((type) => !FILE_TYPES.includes(type)),
description: 'The kind of asset.',
},
name: { type: 'string', minLength: 1, description: 'Its name in the asset list.' },
props: {
type: 'object',
description: "Starting values of its properties by key (see catalog topic 'asset_types').",
},
},
['type', 'name'],
),
build: (args) => {
const command = new CreateAssetCommand(
text(args, 'type') as AssetType,
text(args, 'name'),
(args.props ?? {}) as PropertyBag,
);
return { command, made: { id: command.asset.id } };
},
},
{
name: 'rename_asset',
description: 'Renames an asset.',
args: object({ asset: ASSET, name: { type: 'string', minLength: 1 } }, ['asset', 'name']),
build: (args) => ({ command: new RenameAssetCommand(text(args, 'asset'), text(args, 'name')) }),
},
{
name: 'duplicate_asset',
description: 'Copies an asset; the copy comes right behind it in the list.',
args: object({ asset: ASSET }, ['asset']),
build: (args) => {
const command = new DuplicateAssetCommand(text(args, 'asset'));
return { command, made: { id: command.copyId } };
},
},
{
name: 'delete_asset',
description:
'Deletes an asset and what depends on it (placed copies of it, for one). The user is asked first and may say no.',
args: object({ asset: ASSET }, ['asset']),
confirms: true,
build: (args) => ({ command: new DeleteAssetCommand(text(args, 'asset')) }),
},
{
name: 'set_asset_property',
description: "Sets a property of an asset by its key (see get_asset, or catalog topic 'asset_types').",
args: object({ asset: ASSET, key: id('Key of the property.'), value: anyValue }, [
'asset',
'key',
'value',
]),
build: (args) => ({
command: new SetAssetPropertyCommand(
text(args, 'asset'),
text(args, 'key'),
args.value as PropertyValue,
),
}),
},
{
name: 'reset_asset_property',
description: 'Puts a property of an asset back to what a new asset starts with.',
args: object({ asset: ASSET, key: id('Key of the property.') }, ['asset', 'key']),
build: (args) => ({ command: new ResetAssetPropertyCommand(text(args, 'asset'), text(args, 'key')) }),
},
{
name: 'set_start_scene',
description: 'Makes a scene the one the game starts in; an empty scene id takes that back.',
args: object({ scene: { type: 'string', description: 'Id of the scene, or "".' } }, ['scene']),
build: (args) => ({ command: new SetStartSceneCommand(text(args, 'scene')) }),
},
{
name: 'add_variable',
description: 'Adds a variable to a behavior or a variables asset, starting at the default of its kind.',
args: object(
{
asset: ASSET,
key: id('Name of the variable: letters, digits and _, not starting with a digit.'),
kind: { type: 'string', enum: VARIABLE_KINDS, description: 'What it holds.' },
},
['asset', 'key', 'kind'],
),
build: (args) => ({
command: new AddVariableCommand(
text(args, 'asset'),
text(args, 'key'),
text(args, 'kind') as VariableKind,
),
}),
},
{
name: 'remove_variable',
description: 'Removes a variable, its value and every override of it on placed objects.',
args: object({ asset: ASSET, key: id('Name of the variable.') }, ['asset', 'key']),
build: (args) => ({ command: new RemoveVariableCommand(text(args, 'asset'), text(args, 'key')) }),
},
{
name: 'rename_variable',
description: 'Renames a variable; nodes and overrides that use it follow.',
args: object({ asset: ASSET, from: id('Its name now.'), to: id('Its new name.') }, [
'asset',
'from',
'to',
]),
build: (args) => ({
command: new RenameVariableCommand(text(args, 'asset'), text(args, 'from'), text(args, 'to')),
}),
},
{
name: 'place_asset',
description:
'Places an asset into a scene or container as a new object, at a position in the world. ' +
'A behavior, collider or rigidbody is placed under the object it belongs to (parent).',
args: object(
{
in: HOLDER,
asset: ASSET,
x: number('World x, pixels.'),
y: number('World y, pixels, down is positive.'),
parent: {
type: 'string',
description: 'Id of the object to place it under, or "" for the top level.',
},
},
['in', 'asset', 'x', 'y'],
),
build: (args) => {
const command = new PlaceAssetCommand(
text(args, 'in'),
text(args, 'asset'),
{ x: numberOf(args, 'x'), y: numberOf(args, 'y') },
(args.parent as string | undefined) ?? '',
);
return { command, made: { id: command.objectId } };
},
},
{
name: 'remove_objects',
description: 'Removes placed objects together with everything placed under them.',
args: object({ in: HOLDER, objects: { type: 'array', items: OBJECT, minItems: 1 } }, ['in', 'objects']),
build: (args) => ({ command: new RemoveSceneObjectsCommand(text(args, 'in'), args.objects as string[]) }),
},
{
name: 'move_object',
description: 'Moves a placed object; x and y are relative to its parent (the world at the top level).',
args: object({ in: HOLDER, object: OBJECT, x: number('x'), y: number('y'), z: number('Draw order.') }, [
'in',
'object',
'x',
'y',
]),
build: (args, { project }) => {
const z =
typeof args.z === 'number' ? args.z : currentZ(project, text(args, 'in'), text(args, 'object'));
return {
command: new MoveSceneObjectCommand(text(args, 'in'), text(args, 'object'), {
x: numberOf(args, 'x'),
y: numberOf(args, 'y'),
z,
}),
};
},
},
{
name: 'rename_object',
description: 'Names a placed object; "" goes back to the name of its asset.',
args: object({ in: HOLDER, object: OBJECT, name: { type: 'string' } }, ['in', 'object', 'name']),
build: (args) => ({
command: new RenameSceneObjectCommand(text(args, 'in'), text(args, 'object'), text(args, 'name')),
}),
},
{
name: 'set_override',
description: 'Gives one placed object its own value for a property of its asset.',
args: object({ in: HOLDER, object: OBJECT, key: id('Key of the property.'), value: anyValue }, [
'in',
'object',
'key',
'value',
]),
build: (args) => ({
command: new SetOverrideCommand(
text(args, 'in'),
text(args, 'object'),
text(args, 'key'),
args.value as PropertyValue,
),
}),
},
{
name: 'clear_override',
description: "Lets a placed object follow its asset's value for a property again.",
args: object({ in: HOLDER, object: OBJECT, key: id('Key of the property.') }, ['in', 'object', 'key']),
build: (args) => ({
command: new ClearOverrideCommand(text(args, 'in'), text(args, 'object'), text(args, 'key')),
}),
},
{
name: 'set_parent',
description:
'Puts a placed object under another one ("" for the top level); it keeps its place in the world.',
args: object({ in: HOLDER, object: OBJECT, parent: { type: 'string' } }, ['in', 'object', 'parent']),
build: (args) => ({
command: new SetParentCommand(text(args, 'in'), text(args, 'object'), text(args, 'parent')),
}),
},
{
name: 'add_node',
description:
"Adds a node to a graph. `node` is an entry of catalog topic 'nodes' for that graph (its id, e.g. " +
'"OnKeyHeld", "Move", or "RunBehavior:<id>"). Leave x and y out: the editor finds the node a place.',
args: object(
{
asset: GRAPH,
node: id('The entry id from the catalog.'),
params: { type: 'object', description: 'Settings and typed-in input values by key.' },
x: number('Only to place it by hand.'),
y: number('Only to place it by hand.'),
},
['asset', 'node'],
),
build: (args, context) => {
const assetId = text(args, 'asset');
const entry = paletteEntry(context.project, assetId, text(args, 'node'));
const spot =
typeof args.x === 'number' && typeof args.y === 'number'
? { x: args.x, y: args.y }
: context.spot(assetId);
const params = { ...entry.params, ...coercedParams(entry, (args.params ?? {}) as PropertyBag) };
const command = new AddNodeCommand(assetId, entry.def.type, spot.x, spot.y, params);
const made: Record<string, string> = { id: command.node.id };
if (command.closing) made.closing = command.closing.id;
return { command, made };
},
},
{
name: 'remove_node',
description: 'Removes a node and every edge on it.',
args: object({ asset: GRAPH, node: NODE }, ['asset', 'node']),
build: (args) => ({ command: new RemoveNodeCommand(text(args, 'asset'), text(args, 'node')) }),
},
{
name: 'move_node',
description: 'Moves a node in its graph.',
args: object({ asset: GRAPH, node: NODE, x: number('x'), y: number('y') }, ['asset', 'node', 'x', 'y']),
build: (args) => ({
command: new MoveNodeCommand(
text(args, 'asset'),
text(args, 'node'),
numberOf(args, 'x'),
numberOf(args, 'y'),
),
}),
},
{
name: 'set_node_param',
description: 'Sets a setting of a node, or the value typed into one of its inputs that has no edge.',
args: object({ asset: GRAPH, node: NODE, key: id('Key of the setting or input.'), value: anyValue }, [
'asset',
'node',
'key',
'value',
]),
build: (args) => ({
command: new SetNodeParamCommand(
text(args, 'asset'),
text(args, 'node'),
text(args, 'key'),
args.value as PropertyValue,
),
}),
},
{
name: 'connect',
description:
'Draws an edge from an output to an input. Exec ports carry the flow, the others values; an input ' +
'that holds one value loses the edge it had.',
args: object({ asset: GRAPH, from: PORT_END, to: PORT_END }, ['asset', 'from', 'to']),
build: (args) => {
const end = (key: string) => args[key] as { node: string; port: string };
const command = new ConnectCommand(text(args, 'asset'), end('from'), end('to'));
return { command, made: { id: command.edge.id } };
},
},
{
name: 'disconnect',
description: 'Removes an edge.',
args: object({ asset: GRAPH, edge: id('Id of the edge.') }, ['asset', 'edge']),
build: (args) => ({ command: new DisconnectCommand(text(args, 'asset'), text(args, 'edge')) }),
},
];
export function catalogCommand(name: string): CatalogCommand | undefined {
return COMMANDS.find((command) => command.name === name);
}
/** The palette entry a node is added from: by its id, else by its label when only one has it. */
export function paletteEntry(project: Project, assetId: string, wanted: string): PaletteEntry {
const holder = findGraphAsset(project, assetId);
if (!holder) throw new CommandError(`There is no graph asset ${assetId}`);
const entries = paletteEntries(project, holder);
const byId = entries.find((entry) => entry.id === wanted);
if (byId) return byId;
const byLabel = entries.filter((entry) => entry.def.label.toLowerCase() === wanted.toLowerCase());
if (byLabel.length === 1) return byLabel[0]!;
throw new CommandError(
`A ${holder.type} graph has no node "${wanted}" – look it up with catalog topic "nodes" and this asset`,
);
}
/** Settings are held to their kind, as the inspector holds them; a typed-in input value stays as given. */
function coercedParams(entry: PaletteEntry, params: PropertyBag): PropertyBag {
const coerced: PropertyBag = {};
for (const [key, value] of Object.entries(params)) {
const param = entry.def.params.find((p) => p.key === key);
coerced[key] = param && param.kind !== 'enum' ? coerceValue(param, value) : value;
}
return coerced;
}
function currentZ(project: Project, holderId: string, objectId: string): number {
return findObjectHolder(project, holderId)?.objects.find((o) => o.id === objectId)?.position.z ?? 0;
}