Files
learnharness/src/agent/subagents/demo-builder.ts
T
amosblomqvist 8ebaf0da6e wave E stage 3: demos run in the app's own Chromium (browser-service helper)
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.
2026-10-11 07:03:45 +03:00

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) };
}