sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
+494
View File
@@ -0,0 +1,494 @@
/**
* 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') };
}