Files
feuerwerk/src/editor/agent/agentTools.ts
T
adminandClaude Opus 5.5 b9d1602b94 Arrange graph nodes for the agent and with a button (#429)
An agent that built a graph through MCP left every new node in one
row. In the first real test that put nine nodes on a line 2080 px
long, with the Owner's links running straight through all the others.
Placing nodes by hand cost nine move_node calls and guessed heights.
The agent now gives no coordinates, and the editor lays the nodes out.

- arrange.ts is a pure function. It puts the flow into columns from
  left to right and sets an Owner or Get right before the first node
  it feeds. It orders each column for the fewest crossings and lines
  nodes up by their linked ports without overlaps.
- Node sizes come from the drawn NodeView in the browser and from an
  estimate in unit tests. Both share the constants in nodeBox.ts.
- run_commands arranges the nodes that came without x and y when the
  call ends, inside the same undo step. Nodes that were there stay.
- New command arrange_graph and new tool lint_graph. The lint counts
  overlaps, crossings, links through foreign nodes, backward links
  and the longest links, with example ids.
- The relay instructions tell the agent not to give coordinates and
  to fetch far values with a second Get or Owner node.
- An Arrange button next to the snap magnet in all eight graph tabs.
  It arranges the selected nodes, or all of them, as one undo step.

Found while testing on a real project:

- lint_graph missed a crossing where two links swap rows exactly
  mirrored. Both curves then share a sample point in the middle, and
  only a crossing between two sample points was counted. Links that
  meet on a point count now (segmentsMeet).
- The Arrow-key graph with one Owner for four Moves cannot get below
  8 crossings. The Owner feeds object in row 2 of Move, between exec
  and dt of the key, so it always crosses one of them.

Tests:
- graph-arrange, agent-edit, snap-toggle: 33/33. The swapped-rows
  test was red before the fix (0 instead of 1).
- Four counter-checks that bend arrange or the edit plan, all red.
- e2e 71 on melektaus-atx: 18/18. Lint after the build shows 0
  overlaps, 0 links through nodes, 0 backward. Arrange takes a
  tangled graph from 3 backward links and 2 crossings to 0, and one
  Ctrl+Z restores it.
- Gate on melektaus-atx: CHECK EXIT 0, 318 files, 4917 tests,
  measured before the lint fix.
- Live through MCP on the user's project: lint_graph reports 8
  crossings after Arrange, matching the screenshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:09:52 +02:00

181 lines
7.6 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 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<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;
/** 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<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 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<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, 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<string, ArgSchema> = Object.fromEntries(
(TOOLS as unknown as { name: string; inputSchema: ArgSchema }[]).map((tool) => [
tool.name,
tool.inputSchema,
]),
);