Demos execute JS and need a browser; the app's own binary now provides one: main.cjs boots a hidden window + CDP port under --browser-service (before the single-instance lock - a helper is deliberately a second instance of this binary), lifetime tied to the server's stdin (EOF = quit, no orphans). headless.ts connects over CDP as PRIMARY (owner ruling 2026-10-10), system Chrome stays the fallback, graceful refusal when neither. Electron's CDP has no Target.createTarget, so the helper's pre-created window is THE page, reused and reset per job behind a serialized queue. PDFs and SVGs never reach a browser anymore (native since stages 1-2); stale Chrome wording in model-facing tool copy made engine-neutral. Live-proofed end-to-end from the dev tree (helper seen by process watch, js-ran 42 probe, no orphan). Gates 1523/1523.
258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
import { Type } from "typebox";
|
|
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
|
import type { Usage } from "@earendil-works/pi-ai";
|
|
import { serverExecute } from "../tool-execute.ts";
|
|
import type { PiEngine } from "../models.ts";
|
|
import { runSubagent } from "./pi-run.ts";
|
|
import type { DemoResult } from "../../core/contracts.ts";
|
|
import { getHeadless, type DemoRunResult, type HeadlessApi } from "./headless.ts";
|
|
import { healToolRecord } from "../tools/heal.ts";
|
|
|
|
/**
|
|
* The demo-builder sub-agent (vision §2 interactive demos + §2.5 "sub-agents
|
|
* verify; they never one-shot"): given a brief it writes a self-contained
|
|
* single-file HTML/JS demo, TESTS IT HEADLESSLY (run_demo), and iterates
|
|
* fix → re-run until clean — before anything reaches the lesson.
|
|
*
|
|
* Shape: a ToolLoopAgent with two executable tools (write_demo / run_demo) —
|
|
* the engine is injectable (tests use a scripted faux engine) and the headless
|
|
* API is injectable (tests use a fake; production uses the app's own
|
|
* Chromium browser service, system Chrome as fallback).
|
|
*
|
|
* Failure semantics (decided in the W6 brief): `tested: true` means "the
|
|
* verify loop ran"; on failure the result is { title, html: "", tested: true,
|
|
* notes: "FAILED: <reason>" } — the card renders the failure note, no iframe.
|
|
* The job wrapper treats html === "" as failure and marks the job error.
|
|
*/
|
|
|
|
export interface DemoBuilderInput {
|
|
title: string;
|
|
brief: string;
|
|
}
|
|
|
|
export interface DemoBuilderOptions {
|
|
/** Test seam: scripted engine; production uses the demoBuilder role. */
|
|
engine?: PiEngine;
|
|
headless?: HeadlessApi;
|
|
/** Max agent steps (each write/run/fix is one step). Default 24 ≈ 6 cycles. */
|
|
maxSteps?: number;
|
|
/** Live activity-line callback (updates the job record). */
|
|
onActivity?: (line: string) => void;
|
|
/** Kills the run mid-model-call (2026-09 real-kill abort semantics). */
|
|
abortSignal?: AbortSignal;
|
|
/** Money channel: one call per agent step, folded into JobRecord.usage. */
|
|
onUsage?: (usage: Usage) => void;
|
|
/** Debug: raw provider-event recording key (LH_PROVIDER_EVENTS=1). */
|
|
chatId?: string;
|
|
}
|
|
|
|
const INSTRUCTIONS = `You are a demo builder — a specialist sub-agent inside a learning application. You
|
|
receive a brief for ONE interactive teaching instrument and deliver a single self-contained HTML document
|
|
that runs it. You never see the lesson; the brief is everything you know.
|
|
|
|
Runtime contract (the document runs in a sandboxed iframe, sandbox="allow-scripts", opaque origin):
|
|
- Single file. Vanilla JS + Canvas 2D or SVG. CDN imports (e.g. a plotting or math library) are allowed
|
|
when they genuinely help — but prefer dependency-free; you own the failure if a CDN import fails.
|
|
- NO localStorage, sessionStorage, or document.cookie — the sandbox makes them throw.
|
|
- The parent sizes the iframe to the document height automatically; just build normal flowing layout.
|
|
Make the demo fill the width and size its own height sensibly.
|
|
|
|
Build quality:
|
|
- The demo must directly serve the brief: every control named in the brief must exist and do the thing.
|
|
- Controls need labels; sliders/inputs need current-value readouts. A learner should operate it unguided.
|
|
- Smooth enough interaction; requestAnimationFrame for animation, not setInterval when it matters.
|
|
- Dark-mode neutral: pick a clean light background for the demo area so it reads on any card.
|
|
|
|
THE HOUSE RULE — never deliver untested:
|
|
1. Call write_demo with the complete HTML document.
|
|
2. Call run_demo. It loads the file in a headless browser, waits ~2s, and reports console errors, uncaught
|
|
errors (window.onerror), and failed requests. Optionally pass a probe: a JS expression evaluated in
|
|
the page (e.g. "typeof window.simulate === 'function'" or a state assertion) to check deeper.
|
|
3. If run_demo reports problems, FIX the document and call write_demo again, then run_demo again.
|
|
Iterate until run_demo reports PASSED.
|
|
4. Only then finish. Your final message is 1-3 sentences of NOTES for the teacher (what the demo shows,
|
|
its controls, what to try) — NEVER the HTML itself.
|
|
|
|
Never claim success you did not see: a PASSED run_demo is the only definition of done.`;
|
|
|
|
interface BuilderState {
|
|
html: string | null;
|
|
/** The html that the last run_demo tested (null = never tested). */
|
|
testedHtml: string | null;
|
|
lastResult: DemoRunResult | null;
|
|
}
|
|
|
|
const writeDemoInput = Type.Object({
|
|
html: Type.String({ description: "The complete HTML document: <!doctype html>…</html>, all JS inline." }),
|
|
});
|
|
|
|
const runDemoInput = Type.Object({
|
|
probe: Type.Optional(
|
|
Type.String({
|
|
description:
|
|
"Optional JS expression evaluated in the page after settling, e.g. \"document.querySelectorAll('input').length\".",
|
|
|
|
}),
|
|
),
|
|
});
|
|
|
|
export function createDemoBuilderTools(
|
|
state: BuilderState,
|
|
headless: HeadlessApi,
|
|
onActivity?: (line: string) => void,
|
|
): Record<string, AgentTool<any>> {
|
|
return healToolRecord({
|
|
write_demo: {
|
|
name: "write_demo",
|
|
label: "write_demo",
|
|
description:
|
|
"Write the complete self-contained HTML document for the demo (overwrites any previous version). " +
|
|
"Always follow with run_demo to test it headlessly.",
|
|
parameters: writeDemoInput,
|
|
execute: serverExecute("write_demo", writeDemoInput, async ({ html }) => {
|
|
if (!html || html.trim().length === 0) return "ERROR: empty document — write the full HTML.";
|
|
state.html = html;
|
|
onActivity?.(`write_demo: ${(html.length / 1024).toFixed(1)} KB`);
|
|
return `Saved (${html.length} bytes). Now call run_demo to test it — never deliver untested.`;
|
|
}),
|
|
},
|
|
run_demo: {
|
|
name: "run_demo",
|
|
label: "run_demo",
|
|
description:
|
|
"Headlessly execute the current demo in a browser and report console errors, uncaught errors, and failed requests. " +
|
|
"Optionally pass a probe expression to assert on page state.",
|
|
parameters: runDemoInput,
|
|
execute: serverExecute("run_demo", runDemoInput, async ({ probe }) => {
|
|
if (!state.html) return "ERROR: nothing to run — call write_demo first.";
|
|
const reason = headless.unavailableReason();
|
|
if (reason) return `HEADLESS UNAVAILABLE: ${reason}`;
|
|
let result: DemoRunResult;
|
|
try {
|
|
result = await headless.runDemo(state.html, probe ? { probe } : undefined);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
onActivity?.("run_demo: browser error");
|
|
return `HEADLESS ERROR (retrying may help): ${msg}`;
|
|
}
|
|
state.testedHtml = state.html;
|
|
state.lastResult = result;
|
|
onActivity?.(
|
|
result.ok ? "run_demo: clean" : `run_demo: ${summarizeCounts(result)}`,
|
|
);
|
|
return formatRunResult(result);
|
|
}),
|
|
},
|
|
});
|
|
}
|
|
|
|
function summarizeCounts(result: DemoRunResult): string {
|
|
const counts = [
|
|
result.consoleErrors.length > 0 ? `${result.consoleErrors.length} console error${result.consoleErrors.length === 1 ? "" : "s"}` : null,
|
|
result.pageErrors.length > 0 ? `${result.pageErrors.length} page error${result.pageErrors.length === 1 ? "" : "s"}` : null,
|
|
result.failedRequests.length > 0 ? `${result.failedRequests.length} failed request${result.failedRequests.length === 1 ? "" : "s"}` : null,
|
|
].filter(Boolean);
|
|
return counts.length > 0 ? counts.join(", ") : "ok";
|
|
}
|
|
|
|
function formatRunResult(result: DemoRunResult): string {
|
|
if (result.ok) {
|
|
const probe =
|
|
result.probeResult !== undefined
|
|
? ` Probe result: ${JSON.stringify(result.probeResult) ?? "undefined"}.`
|
|
: "";
|
|
return `PASSED: no console errors, no uncaught errors, no failed requests.${probe} If you are satisfied, finish with your notes; otherwise keep improving and re-test.`;
|
|
}
|
|
const sections: string[] = [`FAILED: ${summarizeCounts(result)}.`];
|
|
if (result.consoleErrors.length > 0)
|
|
sections.push(`Console errors:\n${result.consoleErrors.slice(0, 10).map((e) => `- ${e}`).join("\n")}`);
|
|
if (result.pageErrors.length > 0)
|
|
sections.push(`Uncaught errors:\n${result.pageErrors.slice(0, 10).map((e) => `- ${e}`).join("\n")}`);
|
|
if (result.failedRequests.length > 0)
|
|
sections.push(`Failed requests:\n${result.failedRequests.slice(0, 10).map((r) => `- ${r}`).join("\n")}`);
|
|
sections.push("Fix the document (write_demo) and re-test (run_demo).");
|
|
return sections.join("\n");
|
|
}
|
|
|
|
/** Short summary of a run for failure notes. */
|
|
function failureSummary(result: DemoRunResult | null): string {
|
|
if (!result) return "never tested";
|
|
const details = [...result.consoleErrors, ...result.pageErrors, ...result.failedRequests].slice(0, 5);
|
|
return details.length > 0 ? `${summarizeCounts(result)} — ${details.join(" | ")}` : summarizeCounts(result);
|
|
}
|
|
|
|
export async function runDemoBuilder(input: DemoBuilderInput, opts: DemoBuilderOptions = {}): Promise<DemoResult> {
|
|
const headless = opts.headless ?? getHeadless();
|
|
const title = input.title.trim() || "Interactive demo";
|
|
const brief = input.brief.trim() || "(no brief)";
|
|
const notes = (text: string) => (text.trim() ? text.trim() : undefined);
|
|
|
|
const reason = headless.unavailableReason();
|
|
if (reason) {
|
|
return { title, html: "", tested: true, notes: `FAILED: ${reason}` };
|
|
}
|
|
|
|
const state: BuilderState = { html: null, testedHtml: null, lastResult: null };
|
|
|
|
const prompt = [
|
|
`Demo title: ${title}`,
|
|
`Brief (all you know about the lesson — this comes from the teacher, verbatim):`,
|
|
brief,
|
|
"",
|
|
"Build the demo, test it with run_demo, iterate until PASSED, then finish with your notes.",
|
|
].join("\n");
|
|
|
|
let finalText = "";
|
|
try {
|
|
const result = await runSubagent(Object.values(createDemoBuilderTools(state, headless, opts.onActivity)), {
|
|
role: "demoBuilder",
|
|
engine: opts.engine,
|
|
systemPrompt: INSTRUCTIONS,
|
|
prompt,
|
|
maxTurns: opts.maxSteps ?? 24,
|
|
abortSignal: opts.abortSignal,
|
|
onUsage: (usage) => opts.onUsage?.(usage),
|
|
onToolStart: (toolName) => opts.onActivity?.(toolName),
|
|
chatId: opts.chatId,
|
|
});
|
|
finalText = result.text;
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return { title, html: "", tested: true, notes: `FAILED: the builder crashed — ${msg}` };
|
|
}
|
|
|
|
// The model produced nothing.
|
|
if (!state.html) {
|
|
return {
|
|
title,
|
|
html: "",
|
|
tested: true,
|
|
notes: `FAILED: the builder never wrote a demo.${finalText ? ` (${finalText.slice(0, 300)})` : ""}`,
|
|
};
|
|
}
|
|
|
|
// The verify gate: the delivered html must have a clean headless run —
|
|
// even when the model claims success or forgets the final run_demo.
|
|
if (state.testedHtml !== state.html || !state.lastResult?.ok) {
|
|
opts.onActivity?.("final verification");
|
|
let verify: DemoRunResult;
|
|
try {
|
|
verify = await headless.runDemo(state.html);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return { title, html: "", tested: true, notes: `FAILED: final verification crashed — ${msg}` };
|
|
}
|
|
state.lastResult = verify;
|
|
if (!verify.ok) {
|
|
return {
|
|
title,
|
|
html: "",
|
|
tested: true,
|
|
notes: `FAILED: the demo never passed headless verification (${failureSummary(verify)}).`,
|
|
};
|
|
}
|
|
}
|
|
|
|
return { title, html: state.html, tested: true, notes: notes(finalText) };
|
|
}
|