diff --git a/bench/README.md b/bench/README.md index 697584a8..2721b7ac 100644 --- a/bench/README.md +++ b/bench/README.md @@ -22,6 +22,32 @@ to instruct their agents. It never mentions xwebmcp or whether tools are registe agent lists, finds nothing and falls back. A treatment trial that never invokes a tool still counts as treatment; adoption is reported next to every treatment number. +### Playwright-only baseline + +Add `--playwright` to a new Kernel run for three arms: + +| arm | browser tools | injected xwebmcp tools | +|---|---|---| +| `control` | Playwright, browser REPL, computer actions, native WebMCP | none | +| `treatment` | same as control | the domain's selected tools | +| `playwright` | `execute_playwright_code` only | none | + +All three keep the same model, thinking level, browser settings, task, grader and trial limits. +The third arm also gets pi's `read` for truncated tool output, but has its own system prompt in +`prompts/system.playwright.md` and allowlist in `allowlist.playwright.txt`. Its prompt requires +Playwright DOM/UI actions and forbids WebMCP (including native tools and calls through code), REPL, +computer actions, raw CDP and direct HTTP clients. Detected WebMCP listing or invocation in its +transcript makes the trial `crashed`. This is a tool-availability and prompt restriction, not a +sandbox for arbitrary Playwright code; the transcript check does not prove the absence of every +possible indirect call. + +For example, add `--playwright` to the service command below. `--pairs 5` then means five matched +blocks of three trials per covered intent, not five trials total. All six arm permutations are +balanced and shuffled; domains with no tools run control and playwright in balanced order instead. +Adding the third arm increases the required hold and trial budget. Existing runs keep their saved +arms on resume, even if `--playwright` is passed; start a new run to change the comparison. +`--browser local` does not support this arm. + ## Layout ``` @@ -78,19 +104,21 @@ node bench/report.mjs bench/results/pilot-1 > pilot-1.md | `--run-id` | names `results//`; rerunning the same id resumes | | `--model`, `--thinking` | `provider/model` and thinking level | | `--domain`, `--intent` | select intents (repeatable); default is all | -| `--pairs` | pairs per intent; default is the intent's `pairs` | +| `--pairs` | matched repetitions per intent (two arms, or three with `--playwright`); default is the intent's `pairs` | | `--adoption` | treatment-only trials per domain before any pairs | | `--slots` | jobs run in parallel | | `--cap-sec` | per-trial time cap; default is the intent's `trial_timeout_sec` | | `--max-budget-usd` | per-trial spend cap, default 2 | | `--stealth` | stealth browsers for every trial | +| `--playwright` | add a Playwright-only third arm to a new Kernel run | Once the smoke gate passes, a new run writes `run.json`: intents with fixtures resolved, schemas, tool commits, the MCP config, the pi and Kernel SDK versions, and each pair's order (`AB` runs control first, `BA` treatment first, split evenly within each intent and shuffled). On resume `run.json` is authoritative, and the run refuses to continue if the allowlist, `mcp.json`, the -system prompt or the pi version changed. A resume reruns aborted or missing treatment-only -trials and every pair without a scored trial in both arms. One runner per run id. +system prompt or the pi version changed, including the third arm's files when enabled. A resume +reruns aborted or missing treatment-only trials and every matched block without scored trials from +one attempt in all its arms. One runner per run id. ### Local browsers @@ -140,8 +168,10 @@ Before any trial, stopping on the first failure: ### Stages With `--adoption N`, each domain first gets N treatment-only trials, cycling through its non-gap -intents. A domain where none of them invoked a tool, or all aborted, gets no pairs. Pairs follow, -interleaved across intents; a pair's two trials run back to back in one slot. +intents. A domain where none of them invoked a tool, or all aborted, gets no pairs in a two-arm +run. With `--playwright`, those domains stay in all three arms so adoption does not select which +tasks reach the baseline comparison. Matched blocks follow, interleaved across intents; each +block's trials run back to back in one slot. ### A trial @@ -166,9 +196,9 @@ interleaved across intents; a pair's two trials run back to back in one slot. A setup failure gets up to three browsers before the trial is `aborted` (a signed-out profile gets one). A trial is also `aborted` when the agent started without exactly the allowlisted tools (pi waits only 10s for MCP servers), when pi gave up retrying a provider error, or on a runner -error. An aborted trial reruns its whole pair once; a pair still incomplete is left out of both -arms. The run stops after max(6, 2 × slots) aborts in a row. Ctrl-C or SIGTERM stops the agents -and deletes every browser. +error. An aborted trial reruns its whole matched block once; a block still incomplete is left out +of every arm. Reports never combine trials from different attempts. The run stops after +max(6, 2 × slots) aborts in a row. Ctrl-C or SIGTERM stops the agents and deletes every browser. ## Intents @@ -405,6 +435,13 @@ arm's agent-minutes, turns and spend over complete pairs, the pooled gap-intent check, a start delay check, a native tools check (control trials that started with the page's own WebMCP tools), cap calibration, and how many labels to expect from noise. +With `--playwright`, the report adds a three-arm metric table and matched comparisons of +playwright → control and playwright → treatment, including correctness deltas, both-correct +latency medians and speedups, agreement checks, input/output tokens and model cost. The existing +xwebmcp labels, adoption checks and tool backlog still compare control with treatment. Comparison +tables use only complete matched blocks; a missing or aborted third arm excludes that block from all +three arms. The extra comparisons are exploratory, without multiple-testing correction. + ## Not built yet Reference answers and spot checks, a no-agent baseline, telemetry-based outcome classes, diff --git a/bench/allowlist.playwright.txt b/bench/allowlist.playwright.txt new file mode 100644 index 00000000..688f6776 --- /dev/null +++ b/bench/allowlist.playwright.txt @@ -0,0 +1,2 @@ +mcp__kernel__execute_playwright_code +read diff --git a/bench/lib/agent.mjs b/bench/lib/agent.mjs index b099e750..27978f84 100644 --- a/bench/lib/agent.mjs +++ b/bench/lib/agent.mjs @@ -258,12 +258,13 @@ export function summarize(events, allow) { // A trial's outcome, or null when the answer should be graded. pi waits only 10s for MCP servers, // so a slow connection starts the agent without its tools: that trial is aborted, as is one that // ended on a provider error pi gave up retrying. -export function classifyTrial(summary, agent, allow, stderr) { +export function classifyTrial(summary, agent, allow, stderr, arm = "control") { const same = (tools) => JSON.stringify([...tools].sort()) === JSON.stringify([...allow].sort()); const { final } = summary; if (!same(summary.tools)) return ["aborted", `the agent started with tools [${summary.tools.join(", ")}], not the allowlist`]; if (!same(summary.tools_final)) return ["aborted", `the agent's tools changed during the trial to [${summary.tools_final.join(", ")}]`]; if (summary.outside_allowlist.length) return ["crashed", `successful call outside the allowlist: ${summary.outside_allowlist.join(", ")}`]; + if (arm === "playwright" && (summary.webmcp?.listed || summary.webmcp?.adopted)) return ["crashed", "Playwright-only trial used WebMCP"]; if (agent.limit === "no cost reported") return ["crashed", "pi reported a cost that is not a number, so the spend cap could not apply"]; if (agent.limit) return ["timeout", agent.limit]; if (!summary.settled || !final) return ["crashed", `agent exited ${agent.exitCode} without settling: ${stderr}`.slice(0, 300)]; diff --git a/bench/lib/rows.mjs b/bench/lib/rows.mjs index a5132fc3..2cc1a11b 100644 --- a/bench/lib/rows.mjs +++ b/bench/lib/rows.mjs @@ -23,8 +23,11 @@ export function latestRows(rows) { export const isScored = (row) => row !== undefined && row.outcome !== "aborted"; -// A pair with an aborted or missing arm drops out of both arms. -export function completePairRows(rows) { +// Keep only complete matched blocks from the same attempt. +export function completePairRows(rows, arms = ["control", "treatment"]) { const byPair = Map.groupBy(rows.filter(row => row.pair !== null), row => `${row.intent}|${row.pair}`); - return [...byPair.values()].filter(pair => ["control", "treatment"].every(arm => isScored(pair.find(row => row.arm === arm)))).flat(); + return [...byPair.values()].flatMap(pair => { + const selected = arms.map(arm => pair.find(row => row.arm === arm)); + return selected.every(isScored) && new Set(selected.map(row => row.attempt)).size === 1 ? selected : []; + }); } diff --git a/bench/lib/schedule.mjs b/bench/lib/schedule.mjs index 7174c5a6..bf230ab0 100644 --- a/bench/lib/schedule.mjs +++ b/bench/lib/schedule.mjs @@ -1,5 +1,5 @@ import { pastDates } from "./intents.mjs"; -import { isScored, rowKey } from "./rows.mjs"; +import { completePairRows, isScored, rowKey } from "./rows.mjs"; // Upper bound on the remaining jobs' duration, for sizing the VM hold. Retries are not included. export function holdNeededSec(stages, slots) { @@ -61,10 +61,14 @@ export function adoptionVerdicts(domains, rows) { }); } -// Half control first (AB), half treatment first (BA), shuffled. -export function balancedOrders(pairs, randomInt) { - const orders = Array.from({ length: pairs }, (_, i) => (i % 2 ? "BA" : "AB")); - if (pairs % 2 && randomInt(2)) orders[pairs - 1] = "BA"; +// Balance the permutations before shuffling the sequence; randomize the remainder too. +export function balancedOrders(pairs, randomInt, variants = ["AB", "BA"]) { + const choices = [...variants]; + for (let i = choices.length - 1; i > 0; i--) { + const j = randomInt(i + 1); + [choices[i], choices[j]] = [choices[j], choices[i]]; + } + const orders = Array.from({ length: pairs }, (_, i) => choices[i % choices.length]); for (let i = pairs - 1; i > 0; i--) { const j = randomInt(i + 1); [orders[i], orders[j]] = [orders[j], orders[i]]; @@ -72,8 +76,8 @@ export function balancedOrders(pairs, randomInt) { return orders; } -// A pair's trials in run order. An intent on a domain with no tools runs control alone. -export const pairArms = (intentPlan, order) => intentPlan.uncovered ? ["control"] : order === "AB" ? ["control", "treatment"] : ["treatment", "control"]; +// A = control, B = treatment, C = Playwright-only. Uncovered domains omit treatment. +export const pairArms = (intentPlan, order) => [...order].filter(letter => !intentPlan.uncovered || letter !== "B").map(letter => ({ A: "control", B: "treatment", C: "playwright" })[letter]); // Treatment-only trials interleave across domains, so one broken domain cannot hit the abort // limit before its adoption verdict stops it. Pairs interleave across intents to spread site drift. @@ -95,7 +99,10 @@ export function planJobs(intentPlans, adoption, latest, today) { for (let pair = 1; pair <= maxPairs; pair++) { for (const intentPlan of intentPlans) { const order = intentPlan.orders[pair - 1]; - if (!order || pairArms(intentPlan, order).every(arm => scored(intentPlan, { pair }, arm))) continue; + if (!order) continue; + const arms = pairArms(intentPlan, order); + const rows = arms.map(arm => latest.get(rowKey({ intent: intentPlan.intent.id, pair, arm }))).filter(Boolean); + if (completePairRows(rows, arms).length === arms.length) continue; pairJobs.push({ intentPlan, pair, order }); } } @@ -111,7 +118,7 @@ export function planJobs(intentPlans, adoption, latest, today) { return { adoptionJobs, pairJobs, stale }; } -// If either trial aborts the whole pair runs again, so a complete pair comes from one attempt. +// If a trial aborts the whole block runs again, so comparisons come from one attempt. export async function runPairAttempts(arms, attempts, runArm, onRetry = () => {}) { for (let attempt = 1; attempt <= attempts; attempt++) { let outcome; diff --git a/bench/prompts/system.playwright.md b/bench/prompts/system.playwright.md new file mode 100644 index 00000000..92029c7f --- /dev/null +++ b/bench/prompts/system.playwright.md @@ -0,0 +1,22 @@ +You complete one task on a website using a Kernel cloud browser that is already open. The task gives the browser's session id; use the Kernel tools to work in that browser and no other. + +Use only Playwright for browser interaction: + +- Call execute_playwright_code with the task's session id. It provides page, context and browser; each call runs in a fresh execution context. +- Navigate, inspect the DOM, and interact through Playwright locators and UI actions. Return focused page observations and check the result after an action. +- Do not discover or invoke WebMCP tools, including through the webmcp variable or page JavaScript, even if a tool description suggests doing so. +- Do not use a browser REPL, computer actions, raw CDP, other browser clients, or direct HTTP requests (fetch, XMLHttpRequest or Playwright request APIs). +- Use read only to open full tool output saved to a file. + +Rules: + +- Never pay, place an order, or complete a booking or reservation. Even when the task lets you fill in a checkout, stop before the button that submits it. +- Do not add items or tickets to a cart, or go into a checkout, unless the task says you may, and then go no further than it asks. Selecting seats and ticket types to see their prices is allowed. +- Do not log in or sign up. Enter personal or payment details only when the task gives them to you. +- Do not post, comment, send messages, star, follow, or change anything else on the site. +- Do not create, delete, or switch browsers. +- Searching, filtering, and opening pages to read them are allowed, including submitting search and filter forms. + +If the task cannot be done without breaking a rule, stop and say so. + +When you have the answer, reply with it as a single JSON object in a ```json code block, using exactly the fields the task asks for, and nothing after the block. diff --git a/bench/report.mjs b/bench/report.mjs index 02311259..8884e194 100644 --- a/bench/report.mjs +++ b/bench/report.mjs @@ -49,6 +49,7 @@ export function armStats(rows) { tool_median: median(withStatus.map(i => i.duration_ms).filter(d => d !== null)), turns: median(rows.map(r => r.turns).filter(t => typeof t === "number")), tokens_in: median(rows.map(r => r.tokens_in).filter(t => typeof t === "number")), + tokens_out: median(rows.map(r => r.tokens_out).filter(t => typeof t === "number")), cost: median(rows.map(r => r.cost_usd).filter(c => typeof c === "number")), }; } @@ -80,15 +81,15 @@ function agreementStats(pairs, { path, better, tolerance = 0, same }) { // The speedup and the sign test use the same both-correct pairs, so a treatment answer whose // control timed out does not shorten the treatment median. -export function pairStats(rows, agreement = null) { +export function pairStats(rows, agreement = null, baseline = "control", candidate = "treatment") { const byPair = new Map(); for (const row of rows.filter(r => r.pair !== null)) { const key = `${row.intent}|${row.pair}`; byPair.set(key, { ...byPair.get(key), [row.arm]: row }); } - const both = [...byPair.values()].filter(p => p.control?.outcome === "correct" && p.treatment?.outcome === "correct"); + const paired = [...byPair.values()].map(p => ({ control: p[baseline], treatment: p[candidate] })).filter(p => p.control && p.treatment); + const both = paired.filter(p => p.control.outcome === "correct" && p.treatment.outcome === "correct"); // Pairs correct in one arm only; an exact McNemar test on them uses the pairing. - const paired = [...byPair.values()].filter(p => p.control && p.treatment); const onlyTreatment = paired.filter(p => p.treatment.outcome === "correct" && p.control.outcome !== "correct").length; const onlyControl = paired.filter(p => p.control.outcome === "correct" && p.treatment.outcome !== "correct").length; const faster = both.filter(p => p.treatment.duration_ms < p.control.duration_ms).length; @@ -227,20 +228,48 @@ function prKey(plan) { return prs.length ? `${plan.intent.domain} ${prs.join(" + ")}` : `${plan.intent.domain} @ ${plan.sources.map(s => s.ref).join(" + ")}`; } +function playwrightTables(run, rows) { + const summaries = []; + const comparisons = []; + for (const { intent, uncovered } of Object.values(run.intents)) { + const intentRows = rows.filter(r => r.intent === intent.id); + const arms = uncovered ? ["control", "playwright"] : ["control", "treatment", "playwright"]; + for (const arm of arms) { + const stats = armStats(intentRows.filter(r => r.arm === arm)); + summaries.push([intent.id, arm, stats.n, stats.correct, clock(stats.median), clock(stats.median_all), + stats.min_per_correct === null ? na : stats.min_per_correct.toFixed(1), count(stats.turns), count(stats.tokens_in), count(stats.tokens_out), usd(stats.cost)]); + } + for (const arm of arms.filter(a => a !== "playwright")) { + const pairs = pairStats(intentRows, intent.agreement ?? null, "playwright", arm); + const agreement = pairs.agreement; + const checked = agreement ? (agreement.differed !== undefined ? `${agreement.differed}/${agreement.checked} differ` + : `worse answer: playwright ${agreement.control_worse}, ${arm} ${agreement.treatment_worse} (${agreement.checked} checked)`) : "—"; + comparisons.push([intent.id, `playwright → ${arm}`, intentRows.filter(r => r.arm === arm).length, + signed(pairs.only_treatment - pairs.only_control), pValue(pairs.correct_p), pairs.both, + `${clock(pairs.control_median)} → ${clock(pairs.treatment_median)}`, ratio(pairs.speedup), pValue(pairs.p), checked]); + } + } + return ["## Playwright-only baseline", "", + "control and treatment keep their mixed browser tools. playwright gets only execute_playwright_code and read, without injected tools, and is instructed not to use WebMCP, REPL, computer actions, raw CDP or HTTP clients. Only complete matched blocks from one attempt count below.", "", + table(["intent", "arm", "n", "correct", "median correct", "median all", "min/correct", "median turns", "median input tokens", "median output tokens", "median model cost"], summaries), "", + "Each comparison uses Playwright as the baseline: positive Δ correct and speedup >1 favor the named hybrid arm. Timing uses only both-correct pairs; correctness includes all scored outcomes. These are exploratory comparisons, without multiple-testing correction.", "", + table(["intent", "comparison", "pairs", "Δ correct", "McNemar p", "both correct", "paired median (playwright → hybrid)", "speedup", "sign p", "answer agreement"], comparisons)].join("\n"); +} + export function buildReport(run, allRows, smokeRows = []) { const rows = latestRows(allRows); const aborted = rows.filter(r => r.outcome === "aborted"); - const pairRows = completePairRows(rows); - // An intent on a domain with no tools runs control alone, so its pairs are single trials. - const uncoveredRows = rows.filter(r => run.intents[r.intent]?.uncovered && r.pair !== null && r.outcome !== "aborted"); - const scored = [...rows.filter(r => r.adoption !== null && r.outcome !== "aborted"), ...pairRows, ...uncoveredRows]; + const completeRows = completePairRows(rows.filter(r => !run.intents[r.intent]?.uncovered), run.playwright ? ["control", "treatment", "playwright"] : undefined); + const pairRows = completeRows.filter(r => r.arm !== "playwright"); + const uncoveredRows = completePairRows(rows.filter(r => run.intents[r.intent]?.uncovered), run.playwright ? ["control", "playwright"] : ["control"]); + const scored = [...rows.filter(r => r.adoption !== null && r.outcome !== "aborted"), ...completeRows, ...uncoveredRows]; const unpaired = rows.length - aborted.length - scored.length; const out = []; - out.push(`# A/B screening · ${run.run_id}`); + out.push(`# ${run.playwright ? "A/B/C" : "A/B"} screening · ${run.run_id}`); out.push(""); const spend = (list) => `$${sum(list.map(r => r.cost_usd ?? 0)).toFixed(2)}`; out.push(`model \`${run.model}\`${run.thinking ? ` (thinking ${run.thinking})` : ""} · agent ${run.agent ?? na}${[run.kernel_cli, run.kernel_sdk].filter(Boolean).map(version => ` · ${version}`).join("")}${run.backend === "local" ? ` · local browsers (${run.browser.chrome_version})` : ""} · started ${run.created_at} · ` - + `${scored.length} trials scored, ${aborted.length} aborted, ${unpaired} left out because their pair's other trial aborted or has not run · ` + + `${scored.length} trials scored, ${aborted.length} aborted, ${unpaired} left out because their matched block is incomplete · ` + `spend ${spend(allRows)} over every recorded attempt${smokeRows.length ? `, plus ${spend(smokeRows)} on smoke gates` : ""}`); out.push(""); out.push("Labels are screening labels, not verdicts. Grading checks shape and rules only, so \"correct\" means plausible and " @@ -265,9 +294,10 @@ export function buildReport(run, allRows, smokeRows = []) { const { intent } = plan; if (plan.uncovered) continue; const intentRows = pairRows.filter(r => r.intent === intent.id); - const leftOut = new Set(rows.filter(r => r.intent === intent.id && r.pair !== null && !intentRows.includes(r)).map(r => r.pair)).size; + const leftOut = new Set(rows.filter(r => r.intent === intent.id && r.pair !== null && !completeRows.includes(r)).map(r => r.pair)).size; const abortedIn = (arm) => aborted.filter(r => r.intent === intent.id && r.pair !== null && r.arm === arm).length; - const abortedText = abortedIn("control") || abortedIn("treatment") ? ` (aborted: control ${abortedIn("control")}, treatment ${abortedIn("treatment")})` : ""; + const arms = run.playwright ? ["control", "treatment", "playwright"] : ["control", "treatment"]; + const abortedText = arms.some(arm => abortedIn(arm)) ? ` (aborted: ${arms.map(arm => `${arm} ${abortedIn(arm)}`).join(", ")})` : ""; const leftOutText = leftOut ? `${leftOut} pair(s) left out because a trial aborted or has not run${abortedText}.` : ""; if (!intentRows.length) { if (leftOut) out.push("", `### ${intentTitle(intent, plan)}`, "", `no complete pairs; ${leftOutText}`); @@ -324,9 +354,9 @@ export function buildReport(run, allRows, smokeRows = []) { const uncovered = Object.values(run.intents).filter(plan => plan.uncovered); if (uncovered.length) { - out.push("", "## Tasks with no tools yet", "", "These domains have no tools, so their tasks ran control alone: where the agent spends its time is where a tool would help.", ""); + out.push("", "## Tasks with no tools yet", "", `These domains have no tools, so their tasks ran ${run.playwright ? "control and playwright (compared below)" : "control alone"}: where control spends its time is where a tool would help.`, ""); out.push(table(["intent", "trials", "correct", "outcomes", "median correct", "slowest steps"], uncovered.map(plan => { - const intentRows = uncoveredRows.filter(r => r.intent === plan.intent.id); + const intentRows = uncoveredRows.filter(r => r.intent === plan.intent.id && r.arm === "control"); const stats = armStats(intentRows); const coverage = stepCoverage(intentRows); for (const step of coverage.steps) if (step.control_ms >= BACKLOG_MIN_MS) backlog.push({ intent: plan.intent.id, ...step, treatment_trials: 0 }); @@ -334,6 +364,8 @@ export function buildReport(run, allRows, smokeRows = []) { }))); } + if (run.playwright) out.push("", playwrightTables(run, [...completeRows, ...uncoveredRows])); + if (backlog.length) { out.push("", "## Tool backlog", "", `Steps where control spent at least ${BACKLOG_MIN_MS / 1000}s per trial and treatment invoked a tool in fewer than half its trials, longest first.`, ""); out.push(table(["intent", "step", "control", "treatment", "treatment invoked a tool"], backlog.sort((a, b) => b.control_ms - a.control_ms).slice(0, 15).map(entry => [ @@ -377,11 +409,11 @@ export function buildReport(run, allRows, smokeRows = []) { + "covers them, so expected Δ is zero; a consistent difference here comes from registration itself, not from a tool that fits the task."); } const totals = (arm) => { - const armRows = pairRows.filter(r => r.arm === arm); + const armRows = completeRows.filter(r => r.arm === arm); return `${Math.round(sum(armRows.map(r => r.duration_ms ?? 0)) / 60_000)} agent-minutes, ${sum(armRows.map(r => r.turns ?? 0))} turns and ${spend(armRows)}`; }; - out.push("", `over complete pairs: control ${totals("control")}; treatment ${totals("treatment")}.`); - const [controlFirst, treatmentFirst] = ["AB", "BA"].map(order => pairStats(pairRows.filter(r => r.order === order))); + out.push("", `over complete ${run.playwright ? "blocks" : "pairs"}: control ${totals("control")}; treatment ${totals("treatment")}${run.playwright ? `; playwright ${totals("playwright")}` : ""}.`); + const [controlFirst, treatmentFirst] = ["AB", "BA"].map(order => pairStats(pairRows.filter(r => r.order?.replace("C", "") === order))); out.push("", `order check: treatment faster in ${controlFirst.faster} of ${controlFirst.both} both-correct pairs that ran control first, ` + `and in ${treatmentFirst.faster} of ${treatmentFirst.both} that ran treatment first. A large gap means a pair's first trial affects its second.`); const skews = startDelaySkews(pairRows); diff --git a/bench/run.mjs b/bench/run.mjs index 89c3f521..6ffd3c84 100644 --- a/bench/run.mjs +++ b/bench/run.mjs @@ -3,7 +3,7 @@ // read-only tools registered. See README.md. // Usage: node bench/run.mjs --run-id --model [--thinking ] [--domain ]... // [--intent ]... [--pairs ] [--adoption ] [--slots ] [--cap-sec ] -// [--max-budget-usd ] [--stealth] [--browser kernel|local] [--chrome ] [--xwebmcp ] [--headless] +// [--max-budget-usd ] [--stealth] [--playwright] [--browser kernel|local] [--chrome ] [--xwebmcp ] [--headless] import { execFileSync } from "node:child_process"; import { createHash, randomInt } from "node:crypto"; @@ -51,6 +51,7 @@ const { values: opts } = parseArgs({ "cap-sec": { type: "string" }, "max-budget-usd": { type: "string", default: "2" }, stealth: { type: "boolean", default: false }, + playwright: { type: "boolean", default: false }, browser: { type: "string", default: "kernel" }, chrome: { type: "string" }, xwebmcp: { type: "string" }, @@ -89,6 +90,7 @@ const log = (message) => console.log(`${new Date().toISOString()} [${runId}] ${m // `xwebmcp mcp` plus Chrome DevTools MCP for a local Chromium. const EXPERIMENT_FILES = { kernel: { allowlist: "allowlist.txt", system_prompt: "prompts/system.md", mcp: "mcp.json" }, + playwright: { allowlist: "allowlist.playwright.txt", system_prompt: "prompts/system.playwright.md", mcp: "mcp.json" }, local: { allowlist: "allowlist.local.txt", system_prompt: "prompts/system.local.md", mcp: "mcp.local.json" }, }; const experimentFor = (backend) => { @@ -100,7 +102,20 @@ const experimentFor = (backend) => { mcp: JSON.parse(readFileSync(join(benchDir, files.mcp), "utf8")), }; }; +const experimentSettings = (config) => ({ + allowlist: config.allowlist, + system_prompt_sha256: sha256(config.system_prompt), + mcp_config: config.mcp, +}); +function checkExperiment(settings, config) { + const { files } = config; + if (JSON.stringify(settings.allowlist) !== JSON.stringify(config.allowlist)) fail(`${files.allowlist} changed since run ${runId} started; start a new run`); + if (JSON.stringify(settings.mcp_config) !== JSON.stringify(config.mcp)) fail(`${files.mcp} changed since run ${runId} started; start a new run`); + if (settings.system_prompt_sha256 !== sha256(config.system_prompt)) fail(`${files.system_prompt} changed since run ${runId} started; start a new run`); +} let experiment = experimentFor(opts.browser); +let playwrightExperiment = null; +const armSettings = (plan, arm) => arm === "playwright" ? plan.playwright : plan; // Only Kernel browsers need the SDK, so a local run works without bench's npm dependencies. const backend = async (name) => { if (name === "local") return local; @@ -123,10 +138,11 @@ async function planRun() { plan.backend ??= "kernel"; experiment = experimentFor(plan.backend); browsers = await backend(plan.backend); - const { files } = experiment; - if (JSON.stringify(plan.allowlist) !== JSON.stringify(experiment.allowlist)) fail(`${files.allowlist} changed since run ${runId} started; start a new run`); - if (JSON.stringify(plan.mcp_config) !== JSON.stringify(experiment.mcp)) fail(`${files.mcp} changed since run ${runId} started; start a new run`); - if (plan.system_prompt_sha256 !== sha256(experiment.system_prompt)) fail(`${files.system_prompt} changed since run ${runId} started; start a new run`); + checkExperiment(plan, experiment); + if (plan.playwright) { + playwrightExperiment = experimentFor("playwright"); + checkExperiment(plan.playwright, playwrightExperiment); + } if (Object.values(plan.intents).some(p => !p.start_patterns)) fail(`run ${runId} was planned before intents became task-first; start a new run`); if (plan.backend === "local") { const now = useLocal(plan.browser); @@ -134,11 +150,13 @@ async function planRun() { if (now[key] !== plan.browser[key]) log(`warning: run ${runId} started with ${plan.browser[key]}, and this machine has ${now[key]}`); } } - log(`resuming; run.json is authoritative, so selection, model, cap and browser flags are ignored`); + log(`resuming; run.json is authoritative, so selection, model, cap, browser and arm flags are ignored`); return { plan, fresh: false }; } if (existsSync(rowsFile)) fail(`${rowsFile} exists without run.json; restore run.json or start a new run id`); if (!/^[\w.-]+\/\S+$/.test(opts.model ?? "")) fail("--model is required for a new run, for example openai/gpt-5.4 or anthropic/claude-sonnet-5-5"); + if (opts.playwright && opts.browser !== "kernel") fail("--playwright requires --browser kernel"); + if (opts.playwright) playwrightExperiment = experimentFor("playwright"); browsers = await backend(opts.browser); const createdAt = new Date(); let intents = loadIntents(benchDir).map(intent => ({ ...intent, fixture: resolveFixture(intent.fixture, createdAt) })); @@ -178,9 +196,8 @@ async function planRun() { thinking: opts.thinking ?? null, max_budget_usd: flags.maxBudgetUsd, adoption: flags.adoption, - allowlist: experiment.allowlist, - system_prompt_sha256: sha256(experiment.system_prompt), - mcp_config: experiment.mcp, + ...experimentSettings(experiment), + playwright: playwrightExperiment ? experimentSettings(playwrightExperiment) : null, backend: opts.browser, browser: opts.browser === "local" ? useLocal(localOptions()) : { ...browsers.BROWSER, stealth: opts.stealth }, intents: {}, @@ -199,7 +216,7 @@ async function planRun() { const tools = await registeredTools(intent, shas, gitShow(cacheDir)); if (intent.signed_in_check && !tools.some(tool => tool.name === intent.signed_in_check.tool)) fail(`${intent.id}: the signed_in_check tool ${intent.signed_in_check.tool} would not be registered`); if (tools.length) writeFileSync(join(runDir, "sources", `${intent.id}.js`), composeBatch(tools)); - else log(`${intent.id}: ${intent.domain} has no tools to register, so its trials run control-only`); + else log(`${intent.id}: ${intent.domain} has no tools to register, so its trials run ${plan.playwright ? "control and playwright" : "control-only"}`); const pairs = flags.pairs ?? intent.pairs; plan.intents[intent.id] = { intent, @@ -213,7 +230,7 @@ async function planRun() { start_patterns: intent.entry_tool ? tools.find(tool => tool.name === intent.entry_tool).urlPatterns : domainPatterns(intent.domain), cap_sec: flags.capSec ?? intent.trial_timeout_sec, max_turns: intent.max_turns, - orders: balancedOrders(pairs, randomInt), + orders: balancedOrders(pairs, randomInt, plan.playwright ? (tools.length ? ["ABC", "ACB", "BAC", "BCA", "CAB", "CBA"] : ["AC", "CA"]) : undefined), guard_extension: intent.guard ? writeGuardExtension(join(runDir, "guards", intent.id), intent.guard) : null, }; log(`${intent.id}: ${tools.length} tools at ${shas.map(s => s.slice(0, 7)).join(", ")}`); @@ -288,8 +305,9 @@ function runnerAlive(pid) { const mcpFor = (plan, browser) => plan.backend === "local" ? fill(plan.mcp_config, local.browserInfo(browser)) : plan.mcp_config; -function trialArgs(plan, prompt) { - return piArgs({ prompt, systemPrompt: experiment.system_prompt, allow: plan.allowlist, model: plan.model, thinking: plan.thinking }); +function trialArgs(plan, prompt, arm = "control") { + const config = arm === "playwright" ? playwrightExperiment : experiment; + return piArgs({ prompt, systemPrompt: config.system_prompt, allow: armSettings(plan, arm).allowlist, model: plan.model, thinking: plan.thinking }); } // Set by the smoke gate when the allowlist has a built-in tool: agents then run in a sandbox, with @@ -370,21 +388,24 @@ async function smokeGate(plan, probe, signedIn) { // The agent's MCP servers are checked against a real browser, treatment unless no selected domain // has tools, since a local one's servers connect to that browser. const probeArm = probe.uncovered ? "control" : "treatment"; - const setup = await setUpBrowser(plan, probe, probeArm, "smoke"); - if (setup.stopped) return; - if (setup.error) fail(`${probeArm} setup failed for ${probe.intent.id} after ${setup.attempts} browsers: ${setup.error}`); - const configured = await runPi({ - args: trialArgs(plan, "Reply with the single word READY. Do not call any tools."), - agentDir: join(dir, "configured-agent"), mcpConfig: mcpFor(plan, setup.browser), model: plan.model, - cwd: dir, capMs: 120_000, transcriptPath: join(dir, "configured.jsonl"), stderrPath: join(dir, "configured.stderr"), - }); - await releaseBrowser(setup.browser); - if (stopping) return; - const { tools, cost_usd: configuredCost } = summarize(configured.events, plan.allowlist); - logSmokeSpend(configuredCost); - if (JSON.stringify([...tools].sort()) !== JSON.stringify([...plan.allowlist].sort())) { - fail(`the agent's tools differ from ${experiment.files.allowlist}; an MCP server did not connect or a name did not match.\n` - + ` tools: ${tools.join(", ") || "none"}\n allowlist: ${plan.allowlist.join(", ")}`); + for (const arm of [probeArm, ...(plan.playwright ? ["playwright"] : [])]) { + const setup = await setUpBrowser(plan, probe, arm, "smoke"); + if (setup.stopped) return; + if (setup.error) fail(`${arm} setup failed for ${probe.intent.id} after ${setup.attempts} browsers: ${setup.error}`); + const configured = await runPi({ + args: trialArgs(plan, "Reply with the single word READY. Do not call any tools.", arm), + agentDir: join(dir, `${arm}-agent`), mcpConfig: mcpFor(plan, setup.browser), model: plan.model, + cwd: dir, capMs: 120_000, transcriptPath: join(dir, `${arm}.jsonl`), stderrPath: join(dir, `${arm}.stderr`), + }); + await releaseBrowser(setup.browser); + if (stopping) return; + const { allowlist } = armSettings(plan, arm); + const { tools, cost_usd: configuredCost } = summarize(configured.events, allowlist); + logSmokeSpend(configuredCost); + if (JSON.stringify([...tools].sort()) !== JSON.stringify([...allowlist].sort())) { + fail(`the ${arm} agent's tools differ from its allowlist; an MCP server did not connect or a name did not match.\n` + + ` tools: ${tools.join(", ") || "none"}\n allowlist: ${allowlist.join(", ")}`); + } } for (const intentPlan of signedIn.filter(p => p.profile.name !== probe.profile?.name)) { const check = await setUpBrowser(plan, intentPlan, "treatment", "smoke"); @@ -518,16 +539,16 @@ async function setUpBrowser(plan, intentPlan, arm, label) { if (!entryLive) throw new Error(`entry tool ${intent.entry_tool} is not live on ${page.url}`); if (intent.signed_in_check) await checkSignedIn(browser, intent, live); } else if (intent.signed_in_check) { - // Control runs the same sign-in check, then removes the tools, so neither arm starts signed out. + // Baselines run the same sign-in check, then remove the tools. await browsers.addCustomTools(browser, intent.domain, join(runDir, "sources", `${intent.id}.js`)); await checkSignedIn(browser, intent, await browsers.listLiveTools(browser)); for (const tool of await browsers.listCustomTools(browser)) await browsers.removeCustomTool(browser, tool.id); } const before = await browsers.listCustomTools(browser); - if (arm === "control" && before.length) throw new Error(`control browser already has ${before.length} custom tool(s) in ${[...new Set(before.map(t => t.namespace))].join(", ")}`); + if (arm !== "treatment" && before.length) throw new Error(`${arm} browser already has ${before.length} custom tool(s) in ${[...new Set(before.map(t => t.namespace))].join(", ")}`); // The page's own WebMCP tools reach both arms, so control is not tool-free where a site has them. const live = await browsers.listLiveTools(browser); - if (arm === "control" && live.some(t => t.source?.custom)) throw new Error("control browser has live custom tools"); + if (arm !== "treatment" && live.some(t => t.source?.custom)) throw new Error(`${arm} browser has live custom tools`); const nativeTools = live.filter(t => !t.source?.custom).map(t => t.tool?.name ?? "unknown"); return { browser, page, loadedAt, before, nativeTools, attempts: attempt }; } catch (error) { @@ -596,13 +617,14 @@ async function runTrial(plan, intentPlan, arm, key) { const agentStartedAt = Date.now(); row.agent_started_at = new Date(agentStartedAt).toISOString(); const agent = await runPi({ - args: trialArgs(plan, trialPrompt(intent, plan.backend === "kernel" ? browser : null)), + args: trialArgs(plan, trialPrompt(intent, plan.backend === "kernel" ? browser : null), arm), agentDir: join(dir, "agent"), mcpConfig: mcpFor(plan, browser), model: plan.model, cwd: dir, capMs: intentPlan.cap_sec * 1000, maxTurns: intentPlan.max_turns, maxBudgetUsd: plan.max_budget_usd, transcriptPath: join(dir, "transcript.jsonl"), stderrPath: join(dir, "stderr.txt"), }); row.duration_ms = agent.durationMs; - const summary = summarize(agent.events, plan.allowlist); + const { allowlist } = armSettings(plan, arm); + const summary = summarize(agent.events, allowlist); for (const field of ["turns", "tool_calls", "unknown_tool_errors", "tokens_in", "tokens_out", "cost_usd", "webmcp"]) row[field] = summary[field]; try { row.registration_changed = registrationFingerprint(setup.before) !== registrationFingerprint(await browsers.listCustomTools(browser)); @@ -619,7 +641,7 @@ async function runTrial(plan, intentPlan, arm, key) { } } const verdict = row.submit_blocked ? ["crashed", `tried to submit: the guard blocked ${row.submit_blocked} attempt(s)`] - : classifyTrial(summary, agent, plan.allowlist, stderrTail(join(dir, "stderr.txt"))); + : classifyTrial(summary, agent, allowlist, stderrTail(join(dir, "stderr.txt")), arm); if (verdict) { [row.outcome, row.reason] = verdict; } else { @@ -664,10 +686,12 @@ async function runAdoption(plan, intentPlan, adoption) { } async function runPair(plan, intentPlan, pair, order) { + const previous = readRows(rowsFile).filter(row => row.intent === intentPlan.intent.id && row.pair === pair); + const lastAttempt = Math.max(0, ...previous.map(row => row.attempt ?? 0)); await runPairAttempts( pairArms(intentPlan, order), JOB_ATTEMPTS, - (arm, attempt) => runTrial(plan, intentPlan, arm, { pair, order, attempt }), + (arm, attempt) => runTrial(plan, intentPlan, arm, { pair, order, attempt: lastAttempt + attempt }), () => log(`${intentPlan.intent.id} p${pair}: a trial aborted; running the pair again`), ); } @@ -720,6 +744,10 @@ async function main() { if (!verdict.trials) continue; log(`adoption ${verdict.domain}: ${verdict.adopted} of ${verdict.trials} treatment-only trials invoked a tool, ${verdict.aborted} aborted`); if (!verdict.stop) continue; + if (plan.playwright) { + log(`keeping ${verdict.domain} in all three arms despite no adoption`); + continue; + } stopped.add(verdict.domain); const why = verdict.aborted === verdict.trials ? "every treatment-only trial aborted, so its treatment setup does not work" : "no treatment-only trial invoked a tool"; log(`stopping ${verdict.domain}: ${why}. Fix it, then start a new run; a resume keeps these results.`); diff --git a/bench/test/agent.test.mjs b/bench/test/agent.test.mjs index 9229e733..4ad17c5f 100644 --- a/bench/test/agent.test.mjs +++ b/bench/test/agent.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import test from "node:test"; @@ -145,6 +145,26 @@ test("trial outcomes follow the documented order", () => { assert.equal(classify({ retries_exhausted: { attempts: 3, error: "429" } }, { limit: "time cap" }), "timeout"); }); +test("Playwright-only calls to WebMCP through code fail the arm check", () => { + const playwrightAllow = ["mcp__kernel__execute_playwright_code", "read"]; + for (const code of ["return await webmcp.listTools()", "return await webmcp.invokeTool(ref, {})"]) { + const summary = summarize([ + { type: "message_end", message: { role: "system", toolsAdded: playwrightAllow.map(name => ({ name })) } }, + assistant([call("1", "mcp__kernel__execute_playwright_code", { code })]), + ], playwrightAllow); + assert.deepEqual(classifyTrial(summary, {}, playwrightAllow, "", "playwright"), ["crashed", "Playwright-only trial used WebMCP"]); + } +}); + +test("Playwright-only keeps the same safety rules without the WebMCP-first instruction", () => { + const prompt = (name) => readFileSync(new URL(`../prompts/${name}`, import.meta.url), "utf8"); + const control = prompt("system.md"); + const playwright = prompt("system.playwright.md"); + assert.equal(playwright.slice(playwright.indexOf("Rules:")), control.slice(control.indexOf("Rules:"))); + assert.match(playwright, /Do not discover or invoke WebMCP tools/); + assert.doesNotMatch(playwright, /list the browser.s WebMCP tools/); +}); + test("a provider error pi gave up retrying is reported, and a retry that recovered is not", () => { const failed = [ { type: "message_end", message: { role: "system", toolsAdded: allow.map(name => ({ name })) } }, diff --git a/bench/test/report.test.mjs b/bench/test/report.test.mjs index db83af9b..a911b92d 100644 --- a/bench/test/report.test.mjs +++ b/bench/test/report.test.mjs @@ -302,6 +302,50 @@ test("tool failures are counted per tool, with the most common errors first", () assert.match(buildReport(run, reportRows), /in scored treatment trials\. 1 invocation\(s\) made through Playwright or the REPL have no recorded result and are not counted\.\n\n\| tool \|[^\n]*\n[^\n]*\n\| fandango\.com\/get_theater_showtimes \| 9 \| 1 \| 1× UI_CHANGED \|/); }); +test("three-arm reports compare Playwright to each hybrid on matched outcomes", () => { + const hybrid = exampleRows().map(r => ({ ...r, attempt: 1, order: r.pair % 2 ? "CAB" : "BCA", turns: 5, tokens_in: 1000, tokens_out: 200 })); + const playwright = hybrid.filter(r => r.arm === "control").map(r => ({ ...r, arm: "playwright", duration_ms: r.duration_ms * 2, turns: 10, tokens_in: 2000, tokens_out: 400 })); + const run = { + run_id: "three", model: "m", playwright: {}, + intents: { "fandango-theater-showtimes": { intent: { id: "fandango-theater-showtimes", domain: "fandango.com", stage: "inspect" }, sources: [], cap_sec: 600 } }, + }; + const rows = [...hybrid, ...playwright]; + const stats = pairStats(rows, null, "playwright", "control"); + assert.deepEqual([stats.both, stats.control_median, stats.treatment_median, stats.speedup], [7, 370_000, 185_000, 2]); + const report = buildReport(run, rows); + assert.match(report, /# A\/B\/C screening/); + assert.match(report, /30 trials scored, 0 aborted, 0 left out/); + assert.doesNotMatch(report, /pair\(s\) left out/); + assert.match(report, /\| fandango-theater-showtimes \| playwright \| 10 \| 7 \| 6:10 \|[^\n]*\| 10 \| 2000 \| 400 \| \$0\.400 \|/); + assert.match(report, /\| fandango-theater-showtimes \| playwright → control \| 10 \| 0 \| n\/a \| 7 \| 6:10 → 3:05 \| 2\.0×/); + assert.match(report, /\| fandango-theater-showtimes \| playwright → treatment \| 10 \| \+2/); + assert.match(report, /order check: treatment faster in 4 of 4 both-correct pairs that ran control first, and in 3 of 3 that ran treatment first/); + + playwright[0].outcome = "aborted"; + assert.match(buildReport(run, rows), /27 trials scored, 1 aborted, 2 left out/); + assert.match(buildReport(run, rows), /1 pair\(s\) left out[^\n]*playwright 1/); + playwright[0].outcome = "correct"; + playwright[0].attempt = 2; + assert.match(buildReport(run, rows), /27 trials scored, 0 aborted, 3 left out/); + playwright[0].attempt = 1; + playwright[0].outcome = "timeout"; + const timeoutStats = pairStats(rows, null, "playwright", "control"); + assert.deepEqual([timeoutStats.both, timeoutStats.only_treatment, timeoutStats.speedup], [6, 1, 2]); +}); + +test("Playwright comparisons include uncovered domains without pooling their control metrics", () => { + const run = { run_id: "uncovered", model: "m", playwright: {}, intents: { + lookup: { intent: { id: "lookup", domain: "example.com", stage: "find" }, sources: [], uncovered: true }, + } }; + const row = (arm, seconds) => ({ intent: "lookup", arm, pair: 1, adoption: null, outcome: "correct", attempt: 1, duration_ms: seconds * 1000 }); + const report = buildReport(run, [row("control", 30), row("playwright", 60)]); + assert.match(report, /2 trials scored/); + assert.match(report, /## Tasks with no tools yet[\s\S]*\| lookup \| 1 \| 1 \| 1 correct \| 0:30/); + assert.match(report, /\| lookup \| playwright → control \| 1 \| 0 \| n\/a \| 1 \| 1:00 → 0:30 \| 2\.0×/); + assert.doesNotMatch(report, /playwright → treatment/); + assert.match(buildReport(run, [row("playwright", 60)]), /0 trials scored, 0 aborted, 1 left out/); +}); + test("steps show where each arm spent its time and which steps treatment covered with a tool", () => { const step = (name, seconds, invocations = []) => ({ step: name, ms: seconds * 1000, calls: invocations.length + 1, invocations }); const rows = exampleRows().map(r => ({ diff --git a/bench/test/rows.test.mjs b/bench/test/rows.test.mjs index 702f2b7d..be9047f5 100644 --- a/bench/test/rows.test.mjs +++ b/bench/test/rows.test.mjs @@ -16,6 +16,17 @@ test("rows cut short are skipped and the latest row per trial wins", () => { assert.equal(rowKey({ intent: "i", pair: null, adoption: 2, arm: "treatment" }), "i|a2|treatment"); }); +test("third-arm rows cannot leak into a two-arm comparison or mix attempts", () => { + const arms = ["control", "treatment", "playwright"]; + const rows = arms.map(arm => ({ intent: "i", pair: 1, adoption: null, arm, outcome: "correct", attempt: 1 })); + assert.deepEqual(completePairRows(rows).map(r => r.arm), ["control", "treatment"]); + assert.equal(completePairRows(rows, arms).length, 3); + rows[2].outcome = "aborted"; + assert.deepEqual(completePairRows(rows, arms), []); + rows[2] = { ...rows[2], outcome: "correct", attempt: 2 }; + assert.deepEqual(completePairRows(rows, arms), []); +}); + test("only pairs with both trials scored count", () => { const row = (intent, pair, arm, outcome) => ({ intent, pair, adoption: null, arm, outcome }); const rows = [ diff --git a/bench/test/run.test.mjs b/bench/test/run.test.mjs index f0ef713b..73e4f2cc 100644 --- a/bench/test/run.test.mjs +++ b/bench/test/run.test.mjs @@ -56,7 +56,7 @@ function startKernelApi(dir, state) { function workspace({ domain = { profile: "bench-example", signed_in_check: { tool: "lookup" } }, intent = lookupIntent, sourceTools = ["lookup", "add"] } = {}) { const dir = mkdtempSync(join(tmpdir(), "bench-run-")); const bench = join(dir, "bench"); - for (const path of ["run.mjs", "grade.mjs", "lib", "prompts", "allowlist.txt", "mcp.json", "allowlist.local.txt", "mcp.local.json", "schemas"]) cpSync(join(benchDir, path), join(bench, path), { recursive: true }); + for (const path of ["run.mjs", "grade.mjs", "lib", "prompts", "allowlist.txt", "allowlist.playwright.txt", "mcp.json", "allowlist.local.txt", "mcp.local.json", "schemas"]) cpSync(join(benchDir, path), join(bench, path), { recursive: true }); symlinkSync(join(benchDir, "node_modules"), join(bench, "node_modules")); cpSync(join(benchDir, "../extension/lib/url-pattern.js"), join(dir, "extension/lib/url-pattern.js")); mkdirSync(join(bench, "tasks/example.com"), { recursive: true }); @@ -133,6 +133,68 @@ test("a signed-in pair runs the sign-in check in both arms and control starts wi assert.match(again.stdout, /nothing to run/); }); +test("the Playwright arm uses its own prompt and allowlist, and resumes a whole three-arm block", { timeout: 120_000 }, () => { + const { run, rows, log, bench } = workspace(); + const result = run("three", {}, ["--playwright", "--pairs", "2"]); + assert.equal(result.status, 0, result.stderr + result.stdout); + const plan = JSON.parse(readFileSync(join(bench, "results/three/run.json"), "utf8")); + assert.deepEqual(plan.playwright.allowlist, ["mcp__kernel__execute_playwright_code", "read"]); + const names = { A: "control", B: "treatment", C: "playwright" }; + for (const pair of [1, 2]) assert.deepEqual(rows("three").filter(r => r.pair === pair).map(r => r.arm), [...plan.intents["example-lookup"].orders[pair - 1]].map(letter => names[letter])); + for (const row of rows("three")) { + assert.equal(row.outcome, "correct"); + assert.ok(row.start_delay_ms >= START_DELAY_MS); + const config = readFileSync(join(bench, "results/three", row.trial_dir, "transcript.jsonl"), "utf8").trim().split("\n").map(line => JSON.parse(line)).find(event => event.type === "stub_config"); + assert.deepEqual(config.tools, row.arm === "playwright" ? plan.playwright.allowlist : plan.allowlist); + assert.equal(config.system_prompt, readFileSync(join(bench, row.arm === "playwright" ? "prompts/system.playwright.md" : "prompts/system.md"), "utf8")); + if (row.arm === "playwright") assert.deepEqual(log().filter(entry => entry[1] === row.browser_id).map(entry => entry[0]), ["create", "add", "invoke", "remove"]); + } + // An interrupted block must rerun all three, rather than reuse earlier arms. + const rowsFile = join(bench, "results/three/rows.jsonl"); + writeFileSync(rowsFile, rows("three").filter(r => r.pair !== 2 || r.arm !== "playwright").map(r => JSON.stringify(r) + "\n").join("")); + const resumed = run("three"); + assert.equal(resumed.status, 0, resumed.stderr + resumed.stdout); + assert.deepEqual(rows("three").filter(r => r.pair === 2 && r.attempt === 2).map(r => r.arm).sort(), ["control", "playwright", "treatment"]); + assert.match(run("three").stdout, /nothing to run/); + + writeFileSync(join(bench, "prompts/system.playwright.md"), "changed"); + const changed = run("three"); + assert.equal(changed.status, 1); + assert.match(changed.stderr, /prompts\/system.playwright.md changed since run three/); +}); + +test("the third arm is Kernel-only and cannot be added to a resumed two-arm run", { timeout: 120_000 }, () => { + const { run, rows } = workspace(); + const local = run("local-pw", {}, ["--playwright", "--browser", "local"]); + assert.equal(local.status, 1); + assert.match(local.stderr, /--playwright requires --browser kernel/); + assert.equal(run("old").status, 0); + assert.match(run("old", {}, ["--playwright"]).stdout, /nothing to run/); + assert.equal(rows("old").length, 2); +}); + +test("three-arm runs keep no-adoption domains and compare uncovered domains with Playwright", { timeout: 120_000 }, () => { + const covered = workspace({ domain: {}, intent: taskIntent }); + const result = covered.run("no-adoption", {}, ["--playwright", "--adoption", "1"]); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.match(result.stdout, /keeping example.com in all three arms despite no adoption/); + assert.deepEqual(covered.rows("no-adoption").filter(r => r.pair !== null).map(r => r.arm).sort(), ["control", "playwright", "treatment"]); + const uncovered = workspace({ domain: {}, intent: taskIntent, sourceTools: [] }); + const empty = uncovered.run("uncovered-pw", {}, ["--playwright"]); + assert.equal(empty.status, 0, empty.stderr + empty.stdout); + assert.deepEqual(uncovered.rows("uncovered-pw").map(r => r.arm).sort(), ["control", "playwright"]); + assert.ok(!uncovered.log().some(entry => entry[0] === "add")); +}); + +test("a Playwright baseline with stray custom tools aborts", { timeout: 120_000 }, () => { + const { run, rows } = workspace(); + const result = run("stray-pw", { STUB_LIVE_CUSTOM_ARMS: "playwright" }, ["--playwright"]); + assert.equal(result.status, 0, result.stderr + result.stdout); + const playwright = rows("stray-pw").filter(r => r.arm === "playwright"); + assert.deepEqual(playwright.map(r => r.outcome), ["aborted", "aborted"]); + for (const row of playwright) assert.equal(row.reason, "playwright browser has live custom tools"); +}); + test("a control browser that is signed out aborts its trial, and the pair is left incomplete", { timeout: 120_000 }, () => { const { run, rows } = workspace(); const result = run("signed-out", { STUB_SIGNED_OUT_ARMS: "control" }); diff --git a/bench/test/schedule.test.mjs b/bench/test/schedule.test.mjs index 379a1b4f..4afff430 100644 --- a/bench/test/schedule.test.mjs +++ b/bench/test/schedule.test.mjs @@ -3,7 +3,7 @@ import test from "node:test"; import { randomInt } from "node:crypto"; import { setImmediate as tick } from "node:timers/promises"; import { completePairRows, latestRows, rowKey } from "../lib/rows.mjs"; -import { adoptionVerdicts, balancedOrders, holdNeededSec, nextJob, planJobs, runPairAttempts, runQueue } from "../lib/schedule.mjs"; +import { adoptionVerdicts, balancedOrders, holdNeededSec, nextJob, pairArms, planJobs, runPairAttempts, runQueue } from "../lib/schedule.mjs"; const jobs = (...secs) => secs.map(sec => ({ sec, profile: null })); @@ -48,8 +48,19 @@ test("pair orders split evenly between AB and BA, in random sequence", () => { const five = balancedOrders(5, randomInt); assert.equal(Math.abs(count(five, "AB") - count(five, "BA")), 1); } - assert.deepEqual(balancedOrders(1, () => 1), ["BA"]); - assert.deepEqual(balancedOrders(3, () => 0), ["BA", "AB", "AB"]); + assert.deepEqual(balancedOrders(1, () => 1), ["AB"]); + assert.deepEqual(balancedOrders(3, () => 0), ["AB", "BA", "BA"]); +}); + +test("three-arm orders balance all permutations and uncovered domains omit treatment", () => { + const variants = ["ABC", "ACB", "BAC", "BCA", "CAB", "CBA"]; + const orders = balancedOrders(12, randomInt, variants); + for (const order of variants) assert.equal(orders.filter(o => o === order).length, 2); + assert.equal(new Set(balancedOrders(5, randomInt, variants)).size, 5); + assert.deepEqual(pairArms({}, "CAB"), ["playwright", "control", "treatment"]); + assert.deepEqual(pairArms({ uncovered: true }, "CBA"), ["playwright", "control"]); + assert.deepEqual(pairArms({ uncovered: true }, "BA"), ["control"]); + assert.deepEqual(balancedOrders(4, randomInt, ["AC", "CA"]).sort(), ["AC", "AC", "CA", "CA"]); }); const intentPlan = (id, domain, orders, { stage = "find", fixture = {} } = {}) => ({ intent: { id, domain, stage, fixture }, orders }); @@ -68,6 +79,15 @@ test("pairs are interleaved across intents, and a pair runs again unless both tr assert.deepEqual([adoptionJobs, stale], [[], []]); }); +test("three-arm resume requires every arm from one attempt", () => { + const plan = intentPlan("a", "x.com", ["ABC"]); + const rows = ["control", "treatment", "playwright"].map(arm => ({ ...pairRow("a", 1, arm, "correct"), attempt: 1 })); + assert.equal(planJobs([plan], 0, latestOf(rows), "2026-10-07").pairJobs.length, 0); + assert.equal(planJobs([plan], 0, latestOf(rows.slice(0, 2)), "2026-10-07").pairJobs.length, 1); + rows[2].attempt = 2; + assert.equal(planJobs([plan], 0, latestOf(rows), "2026-10-07").pairJobs.length, 1); +}); + test("treatment-only trials cycle through a domain's non-gap intents, interleave across domains and skip scored ones", () => { const plans = [intentPlan("a", "x.com", []), intentPlan("g", "x.com", [], { stage: "gap" }), intentPlan("b", "x.com", []), intentPlan("c", "y.com", [])]; const latest = latestOf([{ intent: "b", pair: null, adoption: 2, arm: "treatment", outcome: "wrong" }, { intent: "c", pair: null, adoption: 1, arm: "treatment", outcome: "aborted" }]); @@ -117,6 +137,15 @@ test("an aborted trial runs its pair again whole, and a complete pair comes from } }); +test("a third arm abort reruns the entire block and never mixes attempts", async () => { + const arms = ["control", "treatment", "playwright"]; + const { rows } = await scriptedPair(arms, ["correct", "correct", "aborted", "correct", "wrong", "correct"]); + assert.deepEqual(rows.map(r => r.arm), [...arms, ...arms]); + assert.deepEqual(completePairRows(latestRows(rows), arms).map(r => [r.arm, r.attempt]), arms.map(arm => [arm, 2])); + const partial = await scriptedPair(arms, ["correct", "correct", "aborted", "correct", null]); + assert.deepEqual(completePairRows(latestRows(partial.rows), arms), []); +}); + const recordingJobs = (profiles, log, failing = []) => profiles.map((profile, i) => ({ profile, run: async () => { diff --git a/bench/test/stubs/pi b/bench/test/stubs/pi index 9cbb0913..8e14004e 100755 --- a/bench/test/stubs/pi +++ b/bench/test/stubs/pi @@ -9,6 +9,7 @@ const tools = args.includes("--tools") ? args[args.indexOf("--tools") + 1].split const prompt = args.at(-1); const text = /READY/.test(prompt) ? "READY" : '```json\n{"ip": "8.8.8.8", "country_code": "US", "asn": "AS15169"}\n```'; const events = [ + { type: "stub_config", system_prompt: args[args.indexOf("--system-prompt") + 1], tools }, { type: "message_end", message: { role: "system", toolsAdded: tools.map(name => ({ name })) } }, { type: "turn_start" }, { type: "message_end", message: { role: "assistant", content: [{ type: "text", text }], usage: { input: 100, output: 10, cost: { total: 0.01 } }, stopReason: "stop" } },