Files
maestro/src/engine/tools/mermaid.ts
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

495 lines
21 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.
/**
* RenderMermaid (#1025) — check a diagram, then show it.
*
* The agent gets no guidance anywhere about how to write Mermaid, so it picks a
* CDN and a version per document and the syntax it writes often doesn't match
* the version that ends up running. This tool closes that loop: it parses with
* the version WE ship, hands back the failing line, renders a PNG the agent can
* actually look at (ReadImage takes no SVG), and — for HTML — swaps the diagram
* source for an inline SVG so the diagram itself needs no script, no network and
* no version agreement with anything.
*
* One render pass produces both files, so what the agent inspects and what the
* document carries can never disagree.
*
* The rest of the page is the author's. This tool replaces diagram blocks and
* touches nothing else — in particular it never deletes the page's scripts, not
* even a Mermaid loader the SVG has made redundant (#1027).
*/
import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'fs';
import { dirname, join, extname, resolve } from 'path';
import { fileURLToPath } from 'url';
import { chromium } from 'playwright';
import sharp from 'sharp';
import type { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './tool-context.js';
import { resolveAndGuard, resolveOutputPathWithin, assertWritable } from './core.js';
import { buildLaunchOptions } from '../browser-launch.js';
import { logger } from '../../logger.js';
import { loadConfig } from '../../config.js';
import { extractMermaidBlocks, replaceHtmlBlocksWithSvg, type MermaidSourceKind } from './mermaid-blocks.js';
const __dirnameMermaid = dirname(fileURLToPath(import.meta.url));
/**
* Budget for the WHOLE call, shared by every page operation. Per-diagram limits
* alone don't bound anything: 30 diagrams × 20s is ten minutes of worker slot
* (#1027 P2), so the deadline is what actually protects it.
*/
const RENDER_TIMEOUT_MS = 60_000;
/** Ceiling for one diagram, capped further by whatever is left of the budget. */
const DIAGRAM_TIMEOUT_MS = 20_000;
const MAX_DIAGRAMS = 30;
/** A diagram this long is a mistake, and it is cheaper to refuse than to render. */
const MAX_CODE_CHARS = 20_000;
/**
* The PNG is a screenshot, so it carries exactly as many pixels as the browser
* says the device has. At 1 the labels came out at the CSS size and turned to
* mush the moment anyone zoomed or printed the page (#1257). 2 doubles the
* raster without touching the layout — the SVG in the HTML output is unaffected.
*/
const DEVICE_SCALE_FACTOR = 2;
/**
* Ceiling on the pixel count of the PNG we write, applied after the screenshot.
*
* The doc tells the agent to open the file with ReadImage, and ReadImage does
* not shrink anything — the bytes go into the conversation as they are. The gate
* in agent-loop/image-context.ts allows at most 8,000 tokens of image per round
* and, when the total is over, drops EVERY image of that round, not just the big
* one. context/token-estimate.ts charges pixels / 750 and stops counting at
* 1568×1568 = 2,458,624 px = 3,279 tokens.
*
* So the cap has to sit well under that plateau. Cutting at 2.45 Mpx would buy
* nothing: a measured 25-step chain came out 504×5174 = 2,607,696 px at scale 2,
* already charged the full 3,279, so three of them are 9,837 — over the ceiling
* no matter how much context is free. Dividing the ceiling by five diagrams
* gives 1,600 tokens each, and 1,600 × 750 = 1,200,000 px. Five capped images
* total exactly 8,000, and the gate compares with `>`, so they still get in. The
* same 25-step chain measures 341×3501 = 1,193,841 px after the cap: 1,592
* tokens, three of them 4,776.
*
* The cost: nothing until the picture is past 1.2 Mpx (the 10-step chain at
* 1,035,216 px is untouched), and output below what scale 1 used to give only
* once the CSS box ITSELF is past 1.2 Mpx — a chain of roughly 45 steps in the
* shape measured above. The capped 25-step is still 1.8× its own CSS box.
*/
const MAX_PNG_PIXELS = 1_200_000;
/**
* page.evaluate takes no timeout of its own — Playwright's default only covers
* navigation and locator work. Without this, a pathological diagram parks a
* Chromium instance and a worker slot forever; the reject unwinds into the
* finally that closes the browser.
*/
async function withTimeout<T>(work: Promise<T>, ms: number, what: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
work,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
/** A PNG carries its pixel size in the IHDR chunk: width at byte 16, height at 20. */
export function readPngPixelSize(png: Buffer): { width: number; height: number } {
if (png.length < 24 || png.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG');
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
}
/**
* Shrink the screenshot to MAX_PNG_PIXELS if it is over, keeping the shape.
*
* Both sides are floored on purpose: floor × floor is always at or under the
* cap, which is what makes the token estimate provably bounded. Scaling one side
* and letting the other be derived can round the product a few hundred pixels
* past the cap, and the estimator rounds tokens up.
*/
export async function capPngPixels(png: Buffer, maxPixels = MAX_PNG_PIXELS): Promise<Buffer> {
const { width, height } = readPngPixelSize(png);
const pixels = width * height;
if (pixels <= maxPixels) return png;
const scale = Math.sqrt(maxPixels / pixels);
const w = Math.max(1, Math.floor(width * scale));
const h = Math.max(1, Math.floor(height * scale));
// fit 'inside' preserves the aspect ratio; sharp's default ('cover') crops.
return await sharp(png).resize(w, h, { fit: 'inside' }).png().toBuffer();
}
/**
* Where the Mermaid bundle lives at runtime. `vendor/` is what the Docker image
* ships (the builder copies it out of the UI's node_modules); the node_modules
* path is the source checkout. Deliberately ONE source — the UI's own
* dependency — so the version that validates is the version that renders.
*/
export function mermaidBundleCandidates(rootDir: string): string[] {
return [
join(rootDir, 'vendor', 'mermaid', 'mermaid.min.js'),
join(rootDir, 'ui', 'node_modules', 'mermaid', 'dist', 'mermaid.min.js'),
];
}
export function resolveMermaidBundle(rootDir = resolve(__dirnameMermaid, '../../..')): string {
for (const candidate of mermaidBundleCandidates(rootDir)) {
if (existsSync(candidate)) return candidate;
}
throw new Error(
'Mermaid bundle not found. Run `npm run build:ui` (it copies mermaid into vendor/) or install the UI dependencies.',
);
}
/**
* The UMD bundle doesn't carry its own version, so read it from the sidecar the
* copy script writes (vendor/) or from the package we copied it out of (dev).
* Reporting it matters: "which version said this was fine" is the question the
* whole tool exists to answer.
*/
export function readMermaidVersion(bundlePath: string): string {
const sidecar = join(dirname(bundlePath), 'version.txt');
if (existsSync(sidecar)) return readFileSync(sidecar, 'utf8').trim();
const pkg = join(dirname(bundlePath), '..', 'package.json');
if (existsSync(pkg)) {
try {
return String(JSON.parse(readFileSync(pkg, 'utf8')).version ?? 'unknown');
} catch { /* fall through */ }
}
return 'unknown';
}
export interface MermaidDiagnostic {
message: string;
/** 1-based line within the diagram source, when the parser reported one. */
line?: number;
/** The offending line itself — the message alone rarely points anywhere useful. */
sourceLine?: string;
}
/**
* Turn a Mermaid parse error into something the agent can act on. Mermaid says
* "Parse error on line 2:" and then quotes a flattened, context-free excerpt;
* pairing the number with the real line is what makes it fixable.
*/
export function parseMermaidError(message: string, code: string): MermaidDiagnostic {
const firstLine = message.split('\n')[0].trim();
const match = message.match(/(?:on|at) line (\d+)/i);
if (!match) return { message: firstLine };
const line = Number(match[1]);
const sourceLine = code.split('\n')[line - 1];
return { message: firstLine, line, ...(sourceLine !== undefined ? { sourceLine } : {}) };
}
export interface MermaidRenderResult {
ok: boolean;
svg?: string;
png?: Buffer;
diagnostic?: MermaidDiagnostic;
}
/**
* Render every diagram in ONE browser. Launching Chromium costs about a second,
* so a page per diagram would make checking a document with five of them
* pointlessly slow.
*/
export async function renderMermaidDiagrams(
codes: string[],
opts: { bundlePath?: string; timeoutMs?: number; totalTimeoutMs?: number } = {},
): Promise<{ version: string; results: MermaidRenderResult[] }> {
const totalMs = opts.totalTimeoutMs ?? RENDER_TIMEOUT_MS;
const perDiagramMs = Math.min(opts.timeoutMs ?? DIAGRAM_TIMEOUT_MS, totalMs);
const deadline = Date.now() + totalMs;
const remaining = () => deadline - Date.now();
const bundlePath = opts.bundlePath ?? resolveMermaidBundle();
const bundle = readFileSync(bundlePath, 'utf8');
// Honour the operator's browser settings (executable path / channel) exactly
// like the browser tool and the session manager do — this was the only launch
// site ignoring them.
const browser = await chromium.launch(buildLaunchOptions(loadConfig().browser, true));
try {
const page = await browser.newPage({ deviceScaleFactor: DEVICE_SCALE_FACTOR });
page.setDefaultTimeout(perDiagramMs);
await page.setContent('<!doctype html><body><div id="stage"></div></body>');
await page.addScriptTag({ content: bundle });
await page.evaluate(() => {
const m = (window as unknown as { mermaid: { initialize: (c: unknown) => void } }).mermaid;
// Same options the UI initializes with (FilePreview) — a diagram that
// passes here must behave the same when the app renders it.
m.initialize({ startOnLoad: false, theme: 'default' });
});
const version = readMermaidVersion(bundlePath);
const results: MermaidRenderResult[] = [];
// A page that stopped answering does not come back, so the first timeout
// ends the run. Carrying on would spend the full per-diagram budget on
// every remaining diagram for nothing (#1027 P2).
let giveUp: string | null = null;
for (let i = 0; i < codes.length; i++) {
if (!giveUp && remaining() <= 0) giveUp = `the ${totalMs}ms render budget ran out`;
if (giveUp) {
results.push({ ok: false, diagnostic: { message: `Not rendered: ${giveUp}.` } });
continue;
}
const budget = Math.min(perDiagramMs, remaining());
const rendered = await withTimeout(
page.evaluate(
async ([code, id]) => {
const m = (window as unknown as {
mermaid: { parse: (c: string) => Promise<unknown>; render: (id: string, c: string) => Promise<{ svg: string }> };
}).mermaid;
try {
await m.parse(code);
const { svg } = await m.render(id, code);
return { ok: true as const, svg };
} catch (e) {
const err = e as { str?: string; message?: string };
return { ok: false as const, message: String(err?.str ?? err?.message ?? e) };
}
},
[codes[i], `render-${i}`] as [string, string],
),
budget,
`diagram ${i + 1}`,
).catch((e: unknown) => ({ ok: false as const, timedOut: true, message: (e as Error).message ?? String(e) }));
if (!rendered.ok) {
if ('timedOut' in rendered) giveUp = `the renderer stopped responding (diagram ${i + 1})`;
results.push({ ok: false, diagnostic: parseMermaidError(rendered.message, codes[i]) });
continue;
}
// Screenshot the SVG we just produced — the PNG is a picture OF the
// embedded artwork, not a second, independent render. Both steps draw on
// the same budget, so the whole call stays bounded.
const png = await withTimeout(
(async () => {
await page.evaluate((svg) => {
const stage = document.getElementById('stage')!;
stage.innerHTML = svg;
stage.style.cssText = 'display:inline-block;background:#fff;padding:8px';
}, rendered.svg);
return page.locator('#stage').screenshot({ type: 'png' });
})(),
Math.max(1, Math.min(perDiagramMs, remaining())),
`diagram ${i + 1} screenshot`,
).catch((e: unknown) => {
giveUp = `the renderer stopped responding (diagram ${i + 1})`;
logger.warn(`[mermaid] screenshot failed: ${(e as Error).message ?? String(e)}`);
return undefined;
});
// Scale 2 is what makes the labels readable, but it also quadruples the
// pixels, and a tall diagram then blows the image budget and takes every
// other image of that round down with it. Shrink here, once, so the file
// on disk is the one the agent can actually open.
const capped = png
? await capPngPixels(png).catch((e: unknown) => {
logger.warn(`[mermaid] downscale failed: ${(e as Error).message ?? String(e)}`);
return undefined;
})
: undefined;
if (!capped) {
results.push({ ok: false, diagnostic: { message: `Rendered, but the image could not be produced (diagram ${i + 1}).` } });
continue;
}
results.push({ ok: true, svg: rendered.svg, png: capped });
}
return { version, results };
} finally {
await browser.close().catch(() => {});
}
}
// --- Tool surface -----------------------------------------------------------
export const TOOL_DEFS: Record<string, ToolDef> = {
RenderMermaid: {
type: 'function',
function: {
name: 'RenderMermaid',
description:
'Mermaid 図の構文を検査して PNG 画像に描画し、HTML ファイルなら図を SVG に置き換えて外部依存を無くす。詳細は ReadToolDoc({ name: "RenderMermaid" }) で取得可能。',
parameters: {
type: 'object',
properties: {
code: { type: 'string', description: 'Mermaid diagram source to check and render.' },
path: {
type: 'string',
description:
'Workspace file (.md / .html) to check instead: every Mermaid block in it is validated and rendered.',
},
embed: {
type: 'boolean',
description:
'HTML only, default true: replace each diagram with the rendered SVG so it displays without a script.',
},
},
},
},
},
};
function detectKind(filePath: string): MermaidSourceKind {
return /\.html?$/i.test(filePath) ? 'html' : 'md';
}
/**
* PNG name for a source file. Flattening the WHOLE relative path matters:
* naming by basename alone made `output/a.html` and `output/sub/a.html` write
* the same file, so the second overwrote the first and the agent then looked at
* the wrong diagram (#1027 P3).
*/
function outputStem(filePath: string | undefined): string {
if (!filePath) return join('output', 'diagrams', 'diagram');
const withoutExt = filePath.slice(0, filePath.length - extname(filePath).length);
const flat = withoutExt.replace(/[^\w.-]+/g, '_').replace(/^_+/, '') || 'diagram';
return join('output', 'diagrams', flat);
}
/** `String(e)` on an Error prints "Error: …" into a sentence that already says
* what went wrong (#1027 P3). */
function errText(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/** Returns null for names this module doesn't own — the dispatch chain in
* tools/index.ts relies on that to fall through to the next module. */
export async function executeTool(name: string, input: unknown, ctx: ToolContext): Promise<ToolResult | null> {
if (name !== 'RenderMermaid') return null;
const args = (input ?? {}) as { code?: unknown; path?: unknown; embed?: unknown };
const codeArg = typeof args.code === 'string' ? args.code : undefined;
const pathArg = typeof args.path === 'string' ? args.path : undefined;
if (!codeArg && !pathArg) return { isError: true, output: 'RenderMermaid needs either `code` or `path`.' };
if (codeArg && pathArg) return { isError: true, output: 'RenderMermaid takes `code` or `path`, not both.' };
// Every path through this tool saves a PNG, so a task that may not write
// files cannot use it at all. Say so before launching a browser, the way
// PdfToImages does — rendering first and refusing afterwards left images on
// disk under a report that said nothing had been written (#1027 P3).
if (!ctx.editAllowed) {
return { isError: true, output: 'RenderMermaid is not allowed: edit flag is false' };
}
let sourceText = '';
let absPath: string | undefined;
let kind: MermaidSourceKind = 'md';
if (pathArg) {
try {
absPath = resolveAndGuard(ctx.workspacePath, pathArg);
sourceText = readFileSync(absPath, 'utf8');
} catch (e) {
return { isError: true, output: `Could not read ${pathArg}: ${errText(e)}` };
}
kind = detectKind(pathArg);
}
// Same reasoning, and the same rule Write/Edit enforce: readonly/ is the
// user's own area. Refuse before rendering rather than after, so the refusal
// and what is left on disk agree.
const wantsEmbed = Boolean(pathArg) && kind === 'html' && args.embed !== false;
if (wantsEmbed && absPath) {
try {
assertWritable(ctx.workspacePath, absPath);
} catch (e) {
return {
isError: true,
output:
`Could not update ${pathArg}: ${errText(e)}\n` +
'Pass embed: false to check the diagrams and get their PNGs without rewriting the file.',
};
}
}
const blocks = codeArg ? [{ code: codeArg, start: 0, end: 0 }] : extractMermaidBlocks(sourceText, kind);
if (blocks.length === 0) {
return { isError: false, output: `No Mermaid blocks found in ${pathArg}.` };
}
if (blocks.length > MAX_DIAGRAMS) {
return { isError: true, output: `Too many diagrams (${blocks.length}); the limit is ${MAX_DIAGRAMS}.` };
}
const oversized = blocks.findIndex(b => b.code.length > MAX_CODE_CHARS);
if (oversized >= 0) {
return {
isError: true,
output:
`Diagram ${oversized + 1} is ${blocks[oversized].code.length} characters; the limit is ${MAX_CODE_CHARS}. ` +
'Split it into several smaller diagrams — one that large is unreadable anyway.',
};
}
let version: string;
let results: MermaidRenderResult[];
try {
({ version, results } = await renderMermaidDiagrams(blocks.map(b => b.code)));
} catch (e) {
logger.warn(`[mermaid] render failed: ${errText(e)}`);
return { isError: true, output: `Mermaid rendering is unavailable: ${errText(e)}` };
}
const stem = outputStem(pathArg);
const lines: string[] = [`mermaid ${version} — ${blocks.length} diagram(s)`];
const svgs: (string | null)[] = [];
for (let i = 0; i < results.length; i++) {
const r = results[i];
const label = blocks.length === 1 ? '' : ` #${i + 1}`;
if (!r.ok) {
svgs.push(null);
const d = r.diagnostic!;
lines.push(
`FAILED${label}: ${d.message}` +
(d.line ? `\n line ${d.line}: ${d.sourceLine ?? ''}` : ''),
);
continue;
}
svgs.push(r.svg ?? null);
const pngRel = `${stem}${blocks.length === 1 ? '' : `-${i + 1}`}.png`;
try {
const pngAbs = resolveOutputPathWithin(ctx.workspacePath, pngRel, ['output']);
mkdirSync(dirname(pngAbs), { recursive: true });
writeFileSync(pngAbs, r.png!);
lines.push(`OK${label}: ${pngRel} — open it with ReadImage to check the diagram is what you meant.`);
} catch (e) {
lines.push(`OK${label}: rendered, but the image could not be saved (${errText(e)}).`);
}
}
const failed = results.filter(r => !r.ok).length;
// Failing to write is a result the agent must not read past — every diagram
// can render fine and the file still not be updated (#1027 P3).
let embedError: string | null = null;
if (wantsEmbed && absPath) {
const rendered = svgs.filter(Boolean).length;
if (rendered > 0) {
try {
writeFileSync(absPath, replaceHtmlBlocksWithSvg(sourceText, blocks, svgs), 'utf8');
// Claim only what is true, and only about the diagrams: nothing else in
// the file was touched, so nothing else can be promised about it.
lines.push(
`Embedded ${rendered} diagram(s) into ${pathArg} as inline SVG — they display with no script and no ` +
'network. Everything else in the file is untouched, and the original source is kept in ' +
'data-mermaid-source for later edits.',
);
} catch (e) {
embedError = errText(e);
lines.push(`Could not update ${pathArg}: ${embedError}`);
}
}
}
if (failed > 0) {
lines.push(
`${failed} diagram(s) failed. Common cause: unquoted parentheses or brackets in a label — ` +
'write A["text (note)"] instead of A[text (note)].',
);
}
return { isError: failed > 0 || embedError !== null, output: lines.join('\n') };
}