From bab535148d4025225c042685b423f77ac8ebc0a8 Mon Sep 17 00:00:00 2001 From: yummybomb <19238148+yummybomb@users.noreply.github.com> Date: Fri, 9 Oct 2026 06:06:20 +0000 Subject: [PATCH] bench: task-first intents and time per step Intents describe what a user wants done; `tools`, `names` and `entry_tool` are optional, and treatment gets every read-only tool the domain has (plus its actions where allowed). A task on a domain with no tools runs control alone. Kernel trials record agent time per step (page) from browser telemetry, and the report adds per-intent step tables, tasks with no tools yet, and a tool backlog. --- bench/README.md | 67 ++++++++++++------- bench/lib/agent.mjs | 11 ++- bench/lib/intents.mjs | 28 ++++++-- bench/lib/kernel.mjs | 19 +++++- bench/lib/local.mjs | 2 +- bench/lib/page.mjs | 43 +++++++++++- bench/lib/schedule.mjs | 7 +- bench/lib/tools.mjs | 8 ++- bench/report.mjs | 64 +++++++++++++++++- bench/run.mjs | 53 ++++++++++----- .../bluebottle-checkout-espresso.json | 13 ---- .../delta.com/delta-cart-main-classic.json | 7 +- .../delta.com/delta-checkout-signed-in.json | 18 ----- bench/test/page.test.mjs | 31 ++++++++- bench/test/report.test.mjs | 32 ++++++++- bench/test/run.test.mjs | 35 +++++++++- bench/test/stubs/kernel | 11 ++- bench/test/tasks.test.mjs | 10 ++- 18 files changed, 362 insertions(+), 97 deletions(-) diff --git a/bench/README.md b/bench/README.md index c50a5143..586c0c39 100644 --- a/bench/README.md +++ b/bench/README.md @@ -1,7 +1,12 @@ # bench Measures, per user intent on a site, how much more often and how much faster an agent succeeds -when the domain's read-only xwebmcp tools are registered in its Kernel browser. +when the domain's read-only xwebmcp tools are registered in its Kernel browser, and where on the +site agents spend their time, so the tools can be built for the steps that need them. + +Intents describe what a user wants done on the site, written without looking at the tool list. +Treatment gets whatever tools the domain has; a domain with none runs control alone, and the +report's tool backlog lists the slow steps no tool covers. Each trial runs [pi](https://github.com/earendil-works/pi) (`pi -p --mode json`) against one Kernel browser. Both arms get the same model, prompt, kernel MCP server, tool allowlist and @@ -120,15 +125,18 @@ interleaved across intents; a pair's two trials run back to back in one slot. 1. Create a browser on the start URL, tagged with the run, arm and intent, with the intent's submission guard loaded if it has one. If the page is still loading after 15s, navigate again; then wait up to 15s for `load`. -2. Check the loaded URL matches the entry tool's URL patterns, in both arms. -3. Treatment: register the batch, confirm the `entry_tool` is live on the tab, and on a signed-in +2. Check the loaded URL is on the domain (or matches the `entry_tool`'s URL patterns, when the + intent names one), in both arms. +3. Treatment: register the batch, confirm every tool registered and, with an `entry_tool`, that it + is live on the tab, and on a signed-in domain run the sign-in check. Control: on a signed-in domain, register the batch, run the same check and remove the tools again; then fail if any custom tools are registered or live. Both arms record the page's own WebMCP tools, which both can use. 4. Wait until 10s after load in both arms, then launch the agent. The clock runs to `agent_settled`; the runner stops the agent at the time, turn or spend cap. -5. On a guarded intent, read the guard's count of blocked submit attempts. Grade the answer, - delete the browser and append a row to `rows.jsonl`. Each attempt keeps its own directory +5. On a guarded intent, read the guard's count of blocked submit attempts. Grade the answer, read + the trial's page navigations from the browser's telemetry to split its time into steps (Kernel + browsers only), delete the browser and append a row to `rows.jsonl`. Each attempt keeps its own directory under `trials//` (`p1-control`, then `p1-control-2` for a rerun), named in the row's `trial_dir`. @@ -143,17 +151,20 @@ and deletes every browser. ```json { - "id": "fandango-theater-showtimes", - "domain": "fandango.com", - "stage": "inspect", - "prompt": "On fandango.com, list every film and every showtime at {{theater}} in {{city}} on {{date}}. Reply with JSON: ...", - "fixture": {"theater": "AMC 19th St. East 6", "city": "New York, NY", "date": {"days_from_start": 2}}, - "start_url": "https://www.fandango.com/", - "tools": [{"repo": "kernel/xwebmcp", "ref": "hypeship/fandango-showtimes", "pr": 58, "names": ["search_theaters", "get_theater_showtimes"]}], - "entry_tool": "search_theaters", - "answer_schema": "schemas/theater-showtimes.json", - "rules": [{"path": "date", "equals": "{{date}}"}, {"path": "films[].title", "unique": true}], - "pairs": 5, "trial_timeout_sec": 600, "max_turns": 40 + "id": "delta-cart-main-classic", + "domain": "delta.com", + "stage": "prepare", + "prompt": "On delta.com, put the cheapest Delta Main Classic one-way fare for 1 adult from {{origin}} to {{destination}} on {{date}} in the cart, and stop on Delta's Trip Summary page ... Reply with JSON: ...", + "fixture": {"origin": "Atlanta (ATL)", "destination": "Orlando (MCO)", "date": {"days_from_start": 28}}, + "start_url": "https://www.delta.com/", + "answer_schema": "schemas/cart-flight.json", + "rules": [{"path": "amount_due", "range": [1, 3000]}], + "check": { + "extract": {"amount_due": "Amount Due\\s*(?:USD\\s*)?\\$\\s*([\\d,]+\\.\\d{2})"}, + "rules": [{"path": "url", "matches": "/trip-summary"}, {"path": "text", "matches": "Main Classic"}], + "same_as_answer": [{"answer": "amount_due", "end_state": "extracted.amount_due"}] + }, + "pairs": 5, "trial_timeout_sec": 900, "max_turns": 60 } ``` @@ -168,18 +179,22 @@ and deletes every browser. CSS selectors of inputs, read as `fields.`, with `missing` and `invalid` lists of names. `same_as_answer` lists `{answer, end_state}` path pairs whose values must match, so the answer reports what is on the page. +- `steps` (optional) names the task's pages, `[{"name": "trip summary", "url_patterns": + ["https://www.delta.com/*/trip-summary*"]}]`, for the report's step tables. Without it a step is + the page's host and path, with id-like segments collapsed to `*`. - `allow` (prepare intents only) lets the agent do more than the system prompt allows, with the same sentences added to both arms' prompts: `checkout` to add to the cart and go through checkout up to, never including, the button that pays or books, and `payment_details` to enter the details the task gives, which must be test data. An intent that allows `checkout` needs a `guard`, and see [Checkout intents](#checkout-intents). -- `tools` lists kernel/xwebmcp refs (or full `sha`s) to register from. Treatment gets every tool - `registry.json` lists for the domain at those commits whose file declares - `readOnlyHint: true`; later sources replace earlier tools of the same name, so a PR to an - existing domain lists main first. Tool files are evaluated in a sandboxed child process. - `names` are the tools a treatment agent would use, and the run fails at start if any would not - be registered. A `pr` whose branch is gone resolves to `refs/pull//head`, and the report - marks it. +- `tools` (optional, main by default) lists kernel/xwebmcp refs (or full `sha`s) to register + from, such as a PR's branch after main. Treatment gets every tool `registry.json` lists for the + domain at those commits whose file declares `readOnlyHint: true`; later sources replace earlier + tools of the same name. Tool files are evaluated in a sandboxed child process. `names` + (optional) are tools the intent expects to be used, and the run fails at start if any would not + be registered. `entry_tool` (optional) must be live on the start page. A `pr` whose branch is + gone resolves to `refs/pull//head`, and the report marks it. Leave all three off a new + intent: it describes the user's task, and the report shows which tools it used. - `{{name}}` in `prompt` and `rules` comes from `fixture`. `{"days_from_start": n}` resolves to a date when the run starts. A fixed date that has passed skips the intent, or stops the run if the intent was selected by name; move dates tied to a film's run forward before each run. @@ -295,6 +310,12 @@ scored: slower arm is `mixed` when it was correct in 2 fewer pairs, gave the worse `better` answer in 2 more, or the arms' `same` answers differed in 2 pairs. +Each intent with a step timeline also gets a steps table: agent seconds per trial on each page in +each arm, how many treatment trials invoked a tool there, and which tools. Tasks on domains with no +tools get their own table of control results and slowest steps. The tool backlog then lists, longest +first, the steps where control spent at least 20s per trial and treatment invoked a tool in fewer +than half its trials, or that have no tools at all. + A tool failures table lists, per tool, the treatment invocations that did not complete or whose tool returned `status: "error"`, with their most common error messages. Then the worst label per PR, and per run: spend over every attempt including smoke gates, the label histogram (noting diff --git a/bench/lib/agent.mjs b/bench/lib/agent.mjs index b5c50645..51264646 100644 --- a/bench/lib/agent.mjs +++ b/bench/lib/agent.mjs @@ -199,6 +199,8 @@ export function summarize(events, allow) { const refs = new Map(); for (const call of calls.values()) collectRefs(call.text, refs); const webmcp = { listed: false, adopted: false, invocations: [] }; + // When each call started, and which WebMCP tool it invoked, for the step timeline. + const timeline = []; for (const call of calls.values()) { const args = call.arguments ?? {}; @@ -208,18 +210,24 @@ export function summarize(events, allow) { if (isList(call)) { webmcp.listed = true; } else if (isInvoke(call)) { + const tool = refs.get(args.tool_ref) ?? args.tool_ref ?? null; webmcp.invocations.push({ path: "mcp", - tool: refs.get(args.tool_ref) ?? args.tool_ref ?? null, + tool, ...invocationResult(call), duration_ms: call.done && call.startedAt !== undefined ? call.endedAt - call.startedAt : null, }); + timeline.push({ start_ms: call.startedAt ?? null, invoke: tool ?? "unknown" }); + continue; } else if (CODE_PATHS[call.name] && typeof args.code === "string") { if (/\blistTools\s*\(/.test(args.code)) webmcp.listed = true; if (/\binvokeTool\s*\(/.test(args.code)) { webmcp.invocations.push({ path: CODE_PATHS[call.name], tool: null, status: null, tool_status: null, error: null, duration_ms: null }); + timeline.push({ start_ms: call.startedAt ?? null, invoke: `unknown (${CODE_PATHS[call.name]})` }); + continue; } } + timeline.push({ start_ms: call.startedAt ?? null, invoke: null }); } webmcp.adopted = webmcp.invocations.length > 0; @@ -240,6 +248,7 @@ export function summarize(events, allow) { cost_usd: sum(u => u.cost?.total), outside_allowlist: [...outsideAllowlist], webmcp, + timeline: timeline.filter(call => call.start_ms !== null), }; } diff --git a/bench/lib/intents.mjs b/bench/lib/intents.mjs index 78160398..9236b918 100644 --- a/bench/lib/intents.mjs +++ b/bench/lib/intents.mjs @@ -1,6 +1,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { checkSchema } from "./schema.mjs"; +import { matchesUrlPattern } from "./tools.mjs"; const STAGES = ["find", "inspect", "compare", "prepare", "gap"]; const RULE_CHECKS = ["equals", "in", "matches", "range", "count", "time", "date", "unique"]; @@ -30,6 +31,15 @@ export function trialPrompt(intent, browserId) { return [fill(intent.prompt, intent.fixture), allowed, intent.instructions, browser].filter(Boolean).join("\n\n"); } +function validUrlPattern(pattern) { + try { + matchesUrlPattern("https://example.com/", pattern); + return typeof pattern === "string"; + } catch { + return false; + } +} + // `requests` becomes a declarativeNetRequest regexFilter, which Chrome compiles with RE2. const RE2_UNSUPPORTED = /\(\? 0, "prompt is required"); need(/^https:\/\//.test(intent.start_url ?? ""), "start_url must be https"); - need(Array.isArray(intent.tools) && intent.tools.length > 0, "tools must list at least one {repo, ref, names}"); + // An intent describes the user's task; `tools` only says which commits to register the domain's + // tools from (main by default), and `names`, when given, are tools it expects to be used. + need(intent.tools === undefined || (Array.isArray(intent.tools) && intent.tools.length > 0), "tools, when given, must list at least one {repo, ref}"); for (const source of intent.tools ?? []) { need(typeof source.repo === "string" && typeof source.ref === "string", "each tools entry needs repo and ref"); // The runner evaluates tool files, so only this repository. need(source.repo === "kernel/xwebmcp", "tools repo must be kernel/xwebmcp"); need(source.sha === undefined || /^[0-9a-f]{40}$/.test(source.sha), "tools sha, when pinned, must be a full commit sha"); - need(Array.isArray(source.names), "each tools entry needs a names array"); + need(source.names === undefined || (Array.isArray(source.names) && source.names.every(name => typeof name === "string")), "tools names, when given, must list tool names"); } const named = (intent.tools ?? []).flatMap(source => source.names ?? []); - need(intent.stage === "gap" ? named.length === 0 : named.length > 0, intent.stage === "gap" ? "gap intents name no tools" : "non-gap intents name the tools a treatment agent would use"); - need(typeof intent.entry_tool === "string", "entry_tool is required"); - need(typeof intent.answer_schema === "string" && existsSync(join(benchDir, intent.answer_schema)), "answer_schema must point at a file under bench/"); + need(intent.stage !== "gap" || named.length === 0, "gap intents name no tools"); + need(intent.entry_tool === undefined || typeof intent.entry_tool === "string", "entry_tool, when given, must be a tool name"); need(intent.actions === undefined, "actions belong in the domain's domain.json"); + need(intent.steps === undefined || (Array.isArray(intent.steps) && intent.steps.length > 0 && intent.steps.every(step => typeof step?.name === "string" && step.name.length > 0 + && Array.isArray(step.url_patterns) && step.url_patterns.length > 0 && step.url_patterns.every(validUrlPattern))), + "steps must list {name, url_patterns} with valid URL patterns"); + need(typeof intent.answer_schema === "string" && existsSync(join(benchDir, intent.answer_schema)), "answer_schema must point at a file under bench/"); // A prepare intent changes site state, so the runner reads where the agent left the browser from // the page itself, never through the tools under test. const { check, allow, guard } = intent; @@ -187,7 +202,8 @@ export function loadIntents(benchDir) { // checkout tools only intents that may go through checkout. const intentActions = intent.stage !== "prepare" ? [] : [...Object.keys(actions), ...(intent.allow?.includes("checkout") ? Object.keys(checkoutActions) : [])]; intents.push({ - ...intent, actions: intentActions, exclude: intentProfile ? exclude : { ...exclude, ...needsSignIn }, profile: intentProfile, + ...intent, tools: intent.tools ?? [{ repo: "kernel/xwebmcp", ref: "main" }], actions: intentActions, + exclude: intentProfile ? exclude : { ...exclude, ...needsSignIn }, profile: intentProfile, signed_in_check: intent.profile ? intent.signed_in_check : signedInCheck, instructions, }); } diff --git a/bench/lib/kernel.mjs b/bench/lib/kernel.mjs index d97c70db..f6a9fc68 100644 --- a/bench/lib/kernel.mjs +++ b/bench/lib/kernel.mjs @@ -57,6 +57,23 @@ export async function cookies(id) { return response.result; } +// Top-frame navigations since `sinceMs` from the browser's telemetry, as {at, url} in epoch ms. +// The API filters each page of events after paging, so `--all` stops at the first page with no +// page event; this follows next_offset to the end instead. +export async function navigations(id, sinceMs) { + const args = ["browsers", "telemetry", "events", id, "--categories", "page", "--since", new Date(sinceMs).toISOString(), "--limit", "100"]; + const found = []; + for (let offset = null, pages = 0; pages < 1000; pages++) { + const response = await kernel(offset ? [...args, "--offset", offset] : args); + for (const { event } of response.events ?? []) { + if (event.type === "page_navigation" && !event.data?.parent_frame_id) found.push({ at: event.ts / 1000, url: event.data.url }); + } + offset = response.next_offset; + if (!offset) break; + } + return found; +} + // Uploads an extension directory under its name unless Kernel already has it. export async function ensureExtension({ name, dir }) { try { @@ -68,7 +85,7 @@ export async function ensureExtension({ name, dir }) { } export async function addCustomTools(id, namespace, sourceFile) { - return (await kernel(["browsers", "webmcp", "custom-tools", "add", id, "--namespace", namespace, "--source-file", sourceFile, "--force-overwrite-namespace"])).tools; + return (await kernel(["browsers", "webmcp", "custom-tools", "add", id, "--namespace", namespace, "--source-file", sourceFile, "--force-overwrite-namespace"])).tools ?? []; } export async function listCustomTools(id) { diff --git a/bench/lib/local.mjs b/bench/lib/local.mjs index 84598507..2631eb54 100644 --- a/bench/lib/local.mjs +++ b/bench/lib/local.mjs @@ -211,7 +211,7 @@ export async function addCustomTools(id, namespace, sourceFile) { // setup then reports the entry tool as not live. const live = async () => (await call(browser, "listTools")).some(t => t.source?.custom?.namespace === namespace); await waitFor("tools", live, 10_000).catch(() => {}); - return result.tools; + return result.tools ?? []; } export async function listCustomTools(id) { diff --git a/bench/lib/page.mjs b/bench/lib/page.mjs index 2f951cbb..a39b185b 100644 --- a/bench/lib/page.mjs +++ b/bench/lib/page.mjs @@ -1,4 +1,6 @@ -// Reading a trial's end state from the page. +// Reading a trial's end state from the page, and splitting its agent time into steps by page. + +import { matchesUrlPattern } from "./tools.mjs"; const TEXT_LIMIT = 200_000; @@ -35,3 +37,42 @@ export function extractValues(text, extract = {}) { } return values; } + +// A step is a page of the task. An intent's `steps` name them by URL pattern; otherwise a step is +// the host and path, with id-like segments (anything with a digit and 6+ characters, or a long +// token) collapsed to *, so every trial's checkout page is the same step. +export function stepName(url, steps = null) { + for (const step of steps ?? []) if (step.url_patterns.some(pattern => matchesUrlPattern(url, pattern))) return step.name; + let parsed; + try { + parsed = new URL(url); + } catch { + return url; + } + const path = parsed.pathname.split("/").map(segment => (/\d/.test(segment) && segment.length >= 6) || /^[\w-]{24,}$/.test(segment) ? "*" : segment).join("/"); + return `${parsed.host}${path.replace(/\/$/, "") || ""}`; +} + +const BROWSER_URL = /^(about|chrome|chrome-error|data|blob):/; + +// Agent time per step: from each top-frame navigation to the next, between the agent's launch and +// the end of its run, with the calls the agent made while on that step. `navigations` are +// [{ms, url}] relative to the launch; the last one before it is the page the agent started on. +export function stepTimeline({ navigations, calls, durationMs, startUrl, steps = null }) { + const pages = navigations.filter(n => !BROWSER_URL.test(n.url)).sort((a, b) => a.ms - b.ms); + const before = pages.filter(n => n.ms <= 0).at(-1); + const segments = [{ start: 0, url: before?.url ?? startUrl }, ...pages.filter(n => n.ms > 0 && n.ms < durationMs).map(n => ({ start: n.ms, url: n.url }))]; + const byStep = new Map(); + for (const [i, segment] of segments.entries()) { + const end = segments[i + 1]?.start ?? durationMs; + const name = stepName(segment.url, steps); + const entry = byStep.get(name) ?? { step: name, ms: 0, calls: 0, invocations: [] }; + entry.ms += end - segment.start; + for (const call of calls.filter(c => c.start_ms >= segment.start && c.start_ms < end)) { + entry.calls++; + if (call.invoke) entry.invocations.push(call.invoke); + } + byStep.set(name, entry); + } + return [...byStep.values()].map(entry => ({ ...entry, ms: Math.round(entry.ms) })); +} diff --git a/bench/lib/schedule.mjs b/bench/lib/schedule.mjs index c3d257a7..7174c5a6 100644 --- a/bench/lib/schedule.mjs +++ b/bench/lib/schedule.mjs @@ -72,13 +72,16 @@ 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"]; + // 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. export function planJobs(intentPlans, adoption, latest, today) { const scored = (intentPlan, key, arm) => isScored(latest.get(rowKey({ intent: intentPlan.intent.id, ...key, arm }))); let adoptionJobs = []; const domains = [...new Set(intentPlans.map(p => p.intent.domain))]; - const candidates = new Map(domains.map(domain => [domain, intentPlans.filter(p => p.intent.domain === domain && p.intent.stage !== "gap")])); + const candidates = new Map(domains.map(domain => [domain, intentPlans.filter(p => p.intent.domain === domain && p.intent.stage !== "gap" && !p.uncovered)])); for (let n = 1; n <= adoption; n++) { for (const domain of domains) { const plans = candidates.get(domain); @@ -92,7 +95,7 @@ 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 || (scored(intentPlan, { pair }, "control") && scored(intentPlan, { pair }, "treatment"))) continue; + if (!order || pairArms(intentPlan, order).every(arm => scored(intentPlan, { pair }, arm))) continue; pairJobs.push({ intentPlan, pair, order }); } } diff --git a/bench/lib/tools.mjs b/bench/lib/tools.mjs index 88d7c8db..65cb5f6c 100644 --- a/bench/lib/tools.mjs +++ b/bench/lib/tools.mjs @@ -75,6 +75,7 @@ function namespaceTools(show, sha, namespace) { // Sources apply in order, so a branch listed after main replaces main's tool of the same name. // A prepare intent's actions are the domain's tools that change site state, registered as well. +// A domain with no tools registers none, and its intents run control-only. export async function registeredTools(intent, shas, show) { const actions = intent.actions ?? []; const byName = new Map(); @@ -83,7 +84,7 @@ export async function registeredTools(intent, shas, show) { for (const [i, source] of intent.tools.entries()) { const all = namespaceTools(show, shas[i], intent.domain); for (const tool of all) known.add(tool.name); - for (const name of source.names) { + for (const name of source.names ?? []) { if (!all.some(tool => tool.name === name)) problems.push(`${name} is not in registry.json at ${source.ref} (${shas[i].slice(0, 7)})`); } for (const tool of all) { @@ -94,7 +95,7 @@ export async function registeredTools(intent, shas, show) { for (const [list, name] of [...Object.keys(intent.exclude).map(name => ["exclude", name]), ...actions.map(name => ["actions", name])]) { if (!known.has(name)) problems.push(`${list} names ${name}, which no source has`); } - for (const name of [...intent.tools.flatMap(s => s.names), intent.entry_tool]) { + for (const name of [...intent.tools.flatMap(s => s.names ?? []), ...(intent.entry_tool ? [intent.entry_tool] : [])]) { if (!byName.has(name) && !problems.some(p => p.startsWith(`${name} `))) problems.push(`${name} is excluded or neither read-only nor an action, so it would not be registered`); } if (problems.length) throw new Error(`${intent.id}: ${problems.join("; ")}`); @@ -102,3 +103,6 @@ export async function registeredTools(intent, shas, show) { } export const matchesUrlPattern = (url, pattern) => globalThis.xw.urlPattern.matchesURLPattern(url, pattern); + +// The repository's default URL patterns for a domain's tools: the domain and its subdomains. +export const domainPatterns = (domain) => [`https://${domain}/*`, `https://*.${domain}/*`]; diff --git a/bench/report.mjs b/bench/report.mjs index 8aa9aeaf..4e7ed3f4 100644 --- a/bench/report.mjs +++ b/bench/report.mjs @@ -141,6 +141,34 @@ export function compare(control, treatment, pairs, stage) { }; } +// Agent time per step (page) per trial, and how many treatment trials invoked a tool on it. A step +// a trial never reached counts as 0s for it, so steps compare across trials. +export function stepCoverage(rows) { + const timed = rows.filter(r => Array.isArray(r.steps)); + const arms = { control: timed.filter(r => r.arm === "control"), treatment: timed.filter(r => r.arm === "treatment") }; + const byStep = new Map(); + for (const row of timed) { + for (const step of row.steps) { + const entry = byStep.get(step.step) ?? { step: step.step, control_ms: 0, treatment_ms: 0, invoked: 0, tools: new Set() }; + entry[`${row.arm}_ms`] += step.ms; + if (row.arm === "treatment" && step.invocations.length) entry.invoked++; + for (const tool of row.arm === "treatment" ? step.invocations : []) entry.tools.add(tool); + byStep.set(step.step, entry); + } + } + const perTrial = (ms, n) => (n ? ms / n : null); + return { + trials: { control: arms.control.length, treatment: arms.treatment.length }, + steps: [...byStep.values()].map(e => ({ + step: e.step, control_ms: perTrial(e.control_ms, arms.control.length), treatment_ms: perTrial(e.treatment_ms, arms.treatment.length), + invoked: e.invoked, tools: [...e.tools].sort(), + })).sort((a, b) => (b.control_ms ?? 0) - (a.control_ms ?? 0) || a.step.localeCompare(b.step)), + }; +} + +// Steps where control spends this long per trial are worth a tool. +const BACKLOG_MIN_MS = 20_000; + // Failed invocations per tool, with their most common errors, over treatment trials. export function toolFailures(rows, domainOf) { const byTool = new Map(); @@ -173,6 +201,7 @@ const ratio = (x) => x === null ? na : `${x.toFixed(1)}×`; const pValue = (p) => p === null ? na : p.toFixed(3); const usd = (x) => x === null ? na : `$${x.toFixed(x < 1 ? 3 : 2)}`; const count = (x) => x === null ? na : String(Math.round(x)); +const seconds = (ms) => ms === null ? na : `${Math.round(ms / 1000)}s`; const clock = (ms) => { if (ms === null) return na; const s = Math.round(ms / 1000); @@ -198,7 +227,9 @@ export function buildReport(run, allRows, smokeRows = []) { const rows = latestRows(allRows); const aborted = rows.filter(r => r.outcome === "aborted"); const pairRows = completePairRows(rows); - const scored = [...rows.filter(r => r.adoption !== null && r.outcome !== "aborted"), ...pairRows]; + // 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 unpaired = rows.length - aborted.length - scored.length; const out = []; out.push(`# A/B screening · ${run.run_id}`); @@ -225,8 +256,10 @@ export function buildReport(run, allRows, smokeRows = []) { const results = []; out.push("", "## Intents"); + const backlog = []; for (const plan of Object.values(run.intents)) { 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 abortedIn = (arm) => aborted.filter(r => r.intent === intent.id && r.pair !== null && r.arm === arm).length; @@ -270,6 +303,35 @@ export function buildReport(run, allRows, smokeRows = []) { const { checked, treatment_worse: tw, control_worse: cw } = pairs.agreement; out.push("", `${intent.agreement.path}, ${intent.agreement.better} is better: in ${checked} both-correct pairs, treatment's was worse in ${tw} and control's in ${cw}.`); } + const coverage = stepCoverage(intentRows); + if (coverage.steps.length) { + out.push("", `steps, agent time per trial (${coverage.trials.control} control, ${coverage.trials.treatment} treatment trials with a timeline):`, ""); + out.push(table(["step", "control", "treatment", "treatment invoked a tool", "tools"], coverage.steps.map(step => [ + step.step, seconds(step.control_ms), seconds(step.treatment_ms), `${step.invoked} of ${coverage.trials.treatment}`, step.tools.join(", ") || "—", + ]))); + for (const step of coverage.steps) { + if (step.control_ms >= BACKLOG_MIN_MS && step.invoked * 2 < coverage.trials.treatment) backlog.push({ intent: intent.id, ...step, treatment_trials: coverage.trials.treatment }); + } + } + } + + 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(table(["intent", "trials", "correct", "outcomes", "median correct", "slowest steps"], uncovered.map(plan => { + const intentRows = uncoveredRows.filter(r => r.intent === plan.intent.id); + 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 }); + return [plan.intent.id, stats.n, stats.correct, outcomeText(stats.outcomes), clock(stats.median), coverage.steps.slice(0, 3).map(step => `${step.step} ${seconds(step.control_ms)}`).join(", ") || na]; + }))); + } + + 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 => [ + entry.intent, entry.step, seconds(entry.control_ms), entry.treatment_trials ? seconds(entry.treatment_ms) : "no tools", entry.treatment_trials ? `${entry.invoked} of ${entry.treatment_trials}` : "—", + ]))); } if (results.length) { diff --git a/bench/run.mjs b/bench/run.mjs index 075d4947..88b09114 100644 --- a/bench/run.mjs +++ b/bench/run.mjs @@ -17,10 +17,10 @@ import { fill, loadIntents, loadSchema, pastDates, resolveFixture, trialPrompt } import * as kernel from "./lib/kernel.mjs"; import * as local from "./lib/local.mjs"; import { latestRows, readRows, rowKey } from "./lib/rows.mjs"; -import { adoptionVerdicts, balancedOrders, holdNeededSec, planJobs, runPairAttempts, runQueue } from "./lib/schedule.mjs"; -import { composeBatch, gitShow, matchesUrlPattern, registeredTools, resolveSource } from "./lib/tools.mjs"; +import { adoptionVerdicts, balancedOrders, holdNeededSec, pairArms, planJobs, runPairAttempts, runQueue } from "./lib/schedule.mjs"; +import { composeBatch, domainPatterns, gitShow, matchesUrlPattern, registeredTools, resolveSource } from "./lib/tools.mjs"; import { blockedAttempts, writeGuardExtension } from "./lib/guard.mjs"; -import { readPage, readPageArg } from "./lib/page.mjs"; +import { readPage, readPageArg, stepTimeline } from "./lib/page.mjs"; import { checkRule, gradeAnswer } from "./grade.mjs"; const benchDir = dirname(fileURLToPath(import.meta.url)); @@ -117,6 +117,7 @@ async function planRun() { 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`); + 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); for (const key of ["chrome_version", "xwebmcp_version"]) { @@ -186,7 +187,8 @@ async function planRun() { const shas = fetched.map(f => f.sha); 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`); - writeFileSync(join(runDir, "sources", `${intent.id}.js`), composeBatch(tools)); + 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`); const pairs = flags.pairs ?? intent.pairs; plan.intents[intent.id] = { intent, @@ -195,7 +197,9 @@ async function planRun() { registered: tools.map(tool => tool.name), drafts: tools.filter(tool => tool.status === "draft").map(tool => tool.name), profile: intent.profile ? { name: intent.profile, id: profiles.get(intent.profile) } : null, - entry_patterns: tools.find(tool => tool.name === intent.entry_tool).urlPatterns, + uncovered: tools.length === 0, + // The start page must be on the entry tool's pages, or on the domain when there is none. + 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), @@ -311,11 +315,12 @@ async function smokeGate(plan, probe, signedIn) { // pi prices a model missing from its table at 0, which would disable the spend cap. if (!(cost_usd > 0)) fail(`pi reported a cost of ${cost_usd} for ${plan.model}, so --max-budget-usd cannot apply`); - // The agent's MCP servers are checked against a real treatment browser, since a local one's - // servers connect to that browser. - const setup = await setUpBrowser(plan, probe, "treatment", "smoke"); + // 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(`treatment setup failed for ${probe.intent.id} after ${setup.attempts} browsers: ${setup.error}`); + if (setup.error) fail(`${probeArm} setup failed for ${probe.intent.id} after ${setup.attempts} browsers: ${setup.error}`); const configured = await runAgent({ args: trialArgs(plan, "Reply with the single word READY. Do not call any tools."), env: agentEnv(join(dir, "configured-agent"), mcpFor(plan, setup.browser)), @@ -448,11 +453,14 @@ async function setUpBrowser(plan, intentPlan, arm, label) { const page = await browsers.waitForLoad(browser, intent.start_url); const loadedAt = performance.now(); // Catches redirects and block pages on another host, in both arms. - if (!intentPlan.entry_patterns.some(pattern => matchesUrlPattern(page.url, pattern))) throw new Error(`start page is ${page.url}, outside ${intent.entry_tool}'s URL patterns`); + if (!intentPlan.start_patterns.some(pattern => matchesUrlPattern(page.url, pattern))) { + throw new Error(`start page is ${page.url}, outside ${intent.entry_tool ? `${intent.entry_tool}'s URL patterns` : intent.domain}`); + } if (arm === "treatment") { - await browsers.addCustomTools(browser, intent.domain, join(runDir, "sources", `${intent.id}.js`)); + const added = await browsers.addCustomTools(browser, intent.domain, join(runDir, "sources", `${intent.id}.js`)); + if (added.length !== intentPlan.registered.length) throw new Error(`registered ${added.length} of ${intentPlan.registered.length} tools`); const live = await browsers.listLiveTools(browser); - const entryLive = live.some(t => t.tool?.name === intent.entry_tool && t.source?.custom?.namespace === intent.domain && t.source?.page_url); + const entryLive = !intent.entry_tool || live.some(t => t.tool?.name === intent.entry_tool && t.source?.custom?.namespace === intent.domain && t.source?.page_url); 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) { @@ -504,7 +512,7 @@ async function runTrial(plan, intentPlan, arm, key) { image_version: null, browser_id: null, setup_attempts: null, start_renavigated: null, start_delay_ms: null, native_tools: null, started_at: new Date().toISOString(), outcome: null, reason: null, duration_ms: null, setup_ms: null, turns: null, tool_calls: null, unknown_tool_errors: null, tokens_in: null, tokens_out: null, cost_usd: null, - registration_changed: null, submit_blocked: null, webmcp: null, grade: null, + registration_changed: null, submit_blocked: null, webmcp: null, grade: null, agent_started_at: null, steps: null, }; const setupStart = performance.now(); let browser = null; @@ -523,6 +531,8 @@ async function runTrial(plan, intentPlan, arm, key) { row.setup_ms = Math.round(performance.now() - setupStart); if (stopping) return null; + const agentStartedAt = Date.now(); + row.agent_started_at = new Date(agentStartedAt).toISOString(); const agent = await runAgent({ args: trialArgs(plan, trialPrompt(intent, plan.backend === "kernel" ? browser : null)), env: agentEnv(join(dir, "agent"), mcpFor(plan, browser)), @@ -559,6 +569,17 @@ async function runTrial(plan, intentPlan, arm, key) { row.reason = grade.problems.slice(0, 3).join("; ") || null; row.grade = { extracted: grade.extracted, shape: grade.shape, rules: grade.rules, end_state: grade.end_state, answer: grade.answer }; } + // Where the agent spent its time, by page; Kernel browsers only, from their telemetry. Event + // times come from Kernel's clock and the agent's start from this machine's, so any skew between + // the two shifts each step boundary by that much. + if (browsers.navigations) { + try { + const navigations = (await browsers.navigations(browser, Date.parse(row.started_at))).map(n => ({ ms: n.at - agentStartedAt, url: n.url })); + row.steps = stepTimeline({ navigations, calls: summary.timeline, durationMs: agent.durationMs, startUrl: setup.page.url, steps: intent.steps }); + } catch (error) { + log(`${intent.id} ${label} ${arm}: could not read page navigations: ${error.message}`); + } + } } catch (error) { row.outcome = "aborted"; row.reason = `runner error: ${error.message}`; @@ -582,7 +603,7 @@ async function runAdoption(plan, intentPlan, adoption) { async function runPair(plan, intentPlan, pair, order) { await runPairAttempts( - order === "AB" ? ["control", "treatment"] : ["treatment", "control"], + pairArms(intentPlan, order), JOB_ATTEMPTS, (arm, attempt) => runTrial(plan, intentPlan, arm, { pair, order, attempt }), () => log(`${intentPlan.intent.id} p${pair}: a trial aborted; running the pair again`), @@ -626,7 +647,7 @@ async function main() { intentPlan, sec: trialSec(intentPlan), profile: intentPlan.profile?.name ?? null, run: () => runAdoption(plan, intentPlan, adoption), })); let pairJobs = planned.pairJobs.map(({ intentPlan, pair, order }) => ({ - intentPlan, sec: 2 * trialSec(intentPlan), profile: intentPlan.profile?.name ?? null, run: () => runPair(plan, intentPlan, pair, order), + intentPlan, sec: pairArms(intentPlan, order).length * trialSec(intentPlan), profile: intentPlan.profile?.name ?? null, run: () => runPair(plan, intentPlan, pair, order), })); // A domain whose treatment-only trials never invoked a tool gets no pairs. @@ -652,7 +673,7 @@ async function main() { for (const intentPlan of pendingPlans.filter(p => p.guard_extension)) await browsers.ensureExtension(intentPlan.guard_extension); // Checked before the smoke gate, which spends money. checkHold(holdNeededSec([adoptionJobs, pairJobs], slots), slots); - await smokeGate(plan, (adoptionJobs[0] ?? pairJobs[0]).intentPlan, signedIn); + await smokeGate(plan, pendingPlans.find(p => !p.uncovered) ?? pendingPlans[0], signedIn); if (stopping) return; writeFileSync(join(runDir, "run.json"), JSON.stringify(plan, null, 2) + "\n"); if (fresh) log(`wrote run.json; rerun with --run-id ${runId} to resume`); diff --git a/bench/tasks/bluebottlecoffee.com/bluebottle-checkout-espresso.json b/bench/tasks/bluebottlecoffee.com/bluebottle-checkout-espresso.json index 0f14b4f4..82c45872 100644 --- a/bench/tasks/bluebottlecoffee.com/bluebottle-checkout-espresso.json +++ b/bench/tasks/bluebottlecoffee.com/bluebottle-checkout-espresso.json @@ -17,18 +17,6 @@ "card_cvc": "500" }, "start_url": "https://bluebottlecoffee.com/", - "tools": [ - { - "repo": "kernel/xwebmcp", - "ref": "main", - "names": [ - "find_product", - "get_product", - "add_to_cart", - "continue_to_checkout" - ] - } - ], "allow": [ "checkout", "payment_details" @@ -41,7 +29,6 @@ "form_submit": true, "requests": "/confirm(?:[/?#]|$)|payment_intents|setup_intents" }, - "entry_tool": "find_product", "answer_schema": "schemas/checkout-order.json", "rules": [ { diff --git a/bench/tasks/delta.com/delta-cart-main-classic.json b/bench/tasks/delta.com/delta-cart-main-classic.json index 11bf8c31..5ca3d2da 100644 --- a/bench/tasks/delta.com/delta-cart-main-classic.json +++ b/bench/tasks/delta.com/delta-cart-main-classic.json @@ -9,8 +9,11 @@ "date": {"days_from_start": 28} }, "start_url": "https://www.delta.com/", - "tools": [{"repo": "kernel/xwebmcp", "ref": "main", "names": ["search_flights", "list_flight_results", "select_fare", "select_fare_experience", "get_trip_summary"]}], - "entry_tool": "search_flights", + "steps": [ + {"name": "search", "url_patterns": ["https://www.delta.com/", "https://www.delta.com/flightsearch/book-a-flight*"]}, + {"name": "results", "url_patterns": ["https://www.delta.com/flightsearch/search-results*"]}, + {"name": "trip summary", "url_patterns": ["https://www.delta.com/*/trip-summary*"]} + ], "answer_schema": "schemas/cart-flight.json", "rules": [{"path": "amount_due", "range": [1, 3000]}], "check": { diff --git a/bench/tasks/delta.com/delta-checkout-signed-in.json b/bench/tasks/delta.com/delta-checkout-signed-in.json index 672fe4ea..d3047404 100644 --- a/bench/tasks/delta.com/delta-checkout-signed-in.json +++ b/bench/tasks/delta.com/delta-checkout-signed-in.json @@ -22,23 +22,6 @@ } }, "start_url": "https://www.delta.com/", - "tools": [ - { - "repo": "kernel/xwebmcp", - "ref": "main", - "names": [ - "search_flights", - "list_flight_results", - "select_fare", - "select_fare_experience", - "get_trip_summary", - "continue_to_review_and_pay", - "continue_checkout", - "get_checkout_step", - "set_trip_protection" - ] - } - ], "allow": [ "checkout" ], @@ -50,7 +33,6 @@ "buttons": "^(complete purchase|purchase|pay now|book now)\\b", "selectors": "[data-cy^=\"complete-purchase\"]" }, - "entry_tool": "search_flights", "answer_schema": "schemas/cart-flight.json", "rules": [ { diff --git a/bench/test/page.test.mjs b/bench/test/page.test.mjs index 8ca4e4b7..9370724c 100644 --- a/bench/test/page.test.mjs +++ b/bench/test/page.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { extractValues } from "../lib/page.mjs"; +import { extractValues, stepName, stepTimeline } from "../lib/page.mjs"; import { gradeAnswer } from "../grade.mjs"; test("extract patterns read values from the page text, amounts as numbers", () => { @@ -11,6 +11,35 @@ test("extract patterns read values from the page text, amounts as numbers", () = assert.deepEqual(extractValues("Amount Due", { amount_due: "Amount Due(?: \\$([\\d.]+))?" }), {}); }); +test("a step is a named URL pattern, or the host and path with ids collapsed", () => { + const steps = [{ name: "trip summary", url_patterns: ["https://www.delta.com/*/trip-summary*"] }]; + assert.equal(stepName("https://www.delta.com/purchase/flights/trip-summary?cartId=1", steps), "trip summary"); + assert.equal(stepName("https://www.delta.com/flight-search/book-a-flight", steps), "www.delta.com/flight-search/book-a-flight"); + assert.equal(stepName("https://checkout.bluebottlecoffee.com/c/pay/cs_live_a1jRC0N9NNoMSufg5sbZzPf7rVUd"), "checkout.bluebottlecoffee.com/c/pay/*"); + assert.equal(stepName("https://www.etsy.com/listing/1234567890/a-mug"), "www.etsy.com/listing/*/a-mug"); + assert.equal(stepName("https://bluebottlecoffee.com/"), "bluebottlecoffee.com"); +}); + +test("agent time splits at each top-frame navigation, with the calls made on each step", () => { + const navigations = [ + { ms: -9_000, url: "https://bluebottlecoffee.com/" }, + { ms: 30_000, url: "https://bluebottlecoffee.com/us/eng/product/opascope" }, + { ms: 31_000, url: "about:blank" }, + { ms: 90_000, url: "https://checkout.bluebottlecoffee.com/c/pay/cs_live_a1jRC0N9NNoMSufg5sbZzPf7rVUd" }, + { ms: 100_000, url: "https://bluebottlecoffee.com/us/eng/product/opascope" }, + { ms: 130_000, url: "https://checkout.bluebottlecoffee.com/c/pay/cs_live_b2kSD1O0OOpNTvgh6tcAaQg8sWVe" }, + { ms: 999_000, url: "https://bluebottlecoffee.com/after" }, + ]; + const calls = [{ start_ms: 1_000, invoke: null }, { start_ms: 5_000, invoke: "find_product" }, { start_ms: 40_000, invoke: "add_to_cart" }, { start_ms: 140_000, invoke: null }]; + assert.deepEqual(stepTimeline({ navigations, calls, durationMs: 150_000, startUrl: "https://bluebottlecoffee.com/" }), [ + { step: "bluebottlecoffee.com", ms: 30_000, calls: 2, invocations: ["find_product"] }, + { step: "bluebottlecoffee.com/us/eng/product/opascope", ms: 90_000, calls: 1, invocations: ["add_to_cart"] }, + { step: "checkout.bluebottlecoffee.com/c/pay/*", ms: 30_000, calls: 1, invocations: [] }, + ]); + // With no navigation at all, the whole run is on the start page. + assert.deepEqual(stepTimeline({ navigations: [], calls: [], durationMs: 5_000, startUrl: "https://example.com/" }), [{ step: "example.com", ms: 5_000, calls: 0, invocations: [] }]); +}); + test("a page check grades the page the agent left, not a tool's reading of it", () => { const intent = { rules: [], fixture: {}, diff --git a/bench/test/report.test.mjs b/bench/test/report.test.mjs index d28e96ee..4f2310a8 100644 --- a/bench/test/report.test.mjs +++ b/bench/test/report.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { adoptedPairStats, answerCheck, armStats, buildReport, compare, pairStats, startDelaySkews, toolFailures } from "../report.mjs"; +import { adoptedPairStats, answerCheck, armStats, buildReport, compare, pairStats, startDelaySkews, stepCoverage, toolFailures } from "../report.mjs"; import { newcombe, signTest, signTestRate, wilson } from "../lib/stats.mjs"; const round = (iv) => iv.map(x => Math.round(x * 100)); @@ -296,3 +296,33 @@ 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("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 => ({ + ...r, + steps: r.arm === "control" ? [step("www.fandango.com", 20), step("www.fandango.com/theater/*", 60)] : [step("www.fandango.com", 5, ["search_theaters"]), step("www.fandango.com/theater/*", 40)], + })); + const { trials, steps } = stepCoverage(rows.filter(r => r.pair <= 9)); + assert.deepEqual(trials, { control: 9, treatment: 9 }); + assert.deepEqual(steps.map(s => [s.step, s.control_ms, s.treatment_ms, s.invoked, s.tools]), [ + ["www.fandango.com/theater/*", 60_000, 40_000, 0, []], + ["www.fandango.com", 20_000, 5_000, 9, ["search_theaters"]], + ]); + const run = { + run_id: "t", model: "m", thinking: null, agent: "pi 0", created_at: "2026-10-07T00:00:00Z", + intents: { + "fandango-theater-showtimes": { intent: { id: "fandango-theater-showtimes", domain: "fandango.com", stage: "inspect" }, sources: [{ ref: "main", sha: "0".repeat(40) }], cap_sec: 600 }, + "newsite-task": { intent: { id: "newsite-task", domain: "newsite.com", stage: "find" }, sources: [{ ref: "main", sha: "0".repeat(40) }], cap_sec: 600, uncovered: true }, + }, + }; + const uncovered = [1, 2].map(pair => ({ intent: "newsite-task", arm: "control", pair, adoption: null, outcome: "correct", duration_ms: 90_000, cost_usd: 0.1, steps: [step("newsite.com/search", 70), step("newsite.com", 20)] })); + const report = buildReport(run, [...rows, ...uncovered]); + assert.match(report, /\| www\.fandango\.com\/theater\/\* \| 60s \| 40s \| 0 of 10 \| — \|/); + assert.match(report, /## Tasks with no tools yet[\s\S]*\| newsite-task \| 2 \| 2 \| 2 correct \| 1:30 \| newsite\.com\/search 70s, newsite\.com 20s \|/); + // The backlog lists the uncovered task's slow step and the step treatment drove without a tool, longest first. + const backlog = report.slice(report.indexOf("## Tool backlog")); + assert.match(backlog, /\| newsite-task \| newsite\.com\/search \| 70s \| no tools \| — \|\n\| fandango-theater-showtimes \| www\.fandango\.com\/theater\/\* \| 60s \| 40s \| 0 of 10 \|/); + assert.doesNotMatch(backlog, /\| www\.fandango\.com \|/); + assert.match(report, /22 trials scored/); +}); diff --git a/bench/test/run.test.mjs b/bench/test/run.test.mjs index b0887736..712e74a7 100644 --- a/bench/test/run.test.mjs +++ b/bench/test/run.test.mjs @@ -31,7 +31,14 @@ const checkoutIntent = { check: { fields: { email: "#email" }, rules: [{ path: "fields.email", equals: "a@example.com" }] }, }; -function workspace({ domain = { profile: "bench-example", signed_in_check: { tool: "lookup" } }, intent = lookupIntent } = {}) { +// A task written without naming tools, as task-first intents are. +const taskIntent = { + id: "example-task", domain: "example.com", stage: "inspect", + prompt: lookupIntent.prompt, start_url: "https://example.com/", answer_schema: "schemas/ip-lookup.json", + rules: [{ path: "asn", equals: "AS15169" }], pairs: 1, trial_timeout_sec: 60, max_turns: 5, +}; + +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 }); @@ -42,8 +49,8 @@ function workspace({ domain = { profile: "bench-example", signed_in_check: { too const source = join(dir, "source"); mkdirSync(join(source, "domains/example.com"), { recursive: true }); - writeFileSync(join(source, "registry.json"), JSON.stringify({ tools: ["lookup", "add"].map(name => ({ namespace: "example.com", file: `domains/example.com/${name}.js` })) })); - for (const [name, readOnly] of [["lookup", true], ["add", false]]) { + writeFileSync(join(source, "registry.json"), JSON.stringify({ tools: sourceTools.map(name => ({ namespace: "example.com", file: `domains/example.com/${name}.js` })) })); + for (const [name, readOnly] of [["lookup", true], ["add", false]].filter(([name]) => sourceTools.includes(name))) { writeFileSync(join(source, `domains/example.com/${name}.js`), `[{ kind: "page", match: { url_patterns: ["https://example.com/*"] }, @@ -211,3 +218,25 @@ test("an opt_in intent runs only when named with --intent", { timeout: 120_000 } const named = run("opt-in-named", {}, ["--intent", "example-lookup"]); assert.equal(named.status, 0, named.stderr + named.stdout); }); + +test("a task that names no tools gets every tool its domain has, and its trials record their steps", { timeout: 120_000 }, () => { + const { run, rows, log } = workspace({ domain: {}, intent: taskIntent }); + const result = run("task"); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.deepEqual(rows("task").map(r => [r.arm, r.outcome]).sort(), [["control", "correct"], ["treatment", "correct"]]); + // Treatment registered the domain's read-only tool; control registered nothing. + const treatment = log().find(entry => entry[0] === "create" && entry[2] === "treatment")[1]; + assert.ok(log().some(entry => entry[0] === "add" && entry[1] === treatment)); + // The stub's browsers navigate to example.com before the agent starts, so all its time is on that step. + for (const row of rows("task")) assert.deepEqual(row.steps.map(s => [s.step, s.calls, s.invocations]), [["example.com", 0, []]]); +}); + +test("a task on a domain with no tools runs control alone", { timeout: 120_000 }, () => { + const { run, rows, log } = workspace({ domain: {}, intent: taskIntent, sourceTools: [] }); + const result = run("uncovered"); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.match(result.stdout, /example\.com has no tools to register, so its trials run control-only/); + assert.deepEqual(rows("uncovered").map(r => [r.arm, r.outcome]), [["control", "correct"]]); + assert.ok(!log().some(entry => entry[0] === "add")); + assert.match(run("uncovered").stdout, /nothing to run/); +}); diff --git a/bench/test/stubs/kernel b/bench/test/stubs/kernel index 5c0f8b48..7bd3a9e5 100755 --- a/bench/test/stubs/kernel +++ b/bench/test/stubs/kernel @@ -35,7 +35,7 @@ if (args[0] === "--version") { } else if (command.startsWith("browsers create")) { const id = `b${++state.next}`; const tags = Object.fromEntries(args.flatMap((arg, i) => (arg === "--tag" ? [args[i + 1].split("=")] : []))); - state.browsers[id] = { tags, tools: [] }; + state.browsers[id] = { tags, tools: [], created: Date.now() }; log(["create", id, tags.arm, args.includes("--extension") ? flag("--extension") : null]); print({ session_id: id }); } else if (command.startsWith("browsers playwright execute")) { @@ -55,7 +55,9 @@ if (args[0] === "--version") { } } else if (command.startsWith("browsers webmcp custom-tools add")) { const browser = state.browsers[args[4]]; - browser.tools = [{ id: `ct_${++state.next}`, namespace: flag("--namespace"), tool: { name: "lookup" } }]; + // One tool per name declared in the batch. + const names = [...readFileSync(flag("--source-file"), "utf8").matchAll(/name: "(\w+)"/g)].map(m => m[1]); + browser.tools = names.map(name => ({ id: `ct_${++state.next}`, namespace: flag("--namespace"), tool: { name } })); log(["add", args[4]]); print({ tools: browser.tools }); } else if (command.startsWith("browsers webmcp custom-tools list")) { @@ -76,6 +78,11 @@ if (args[0] === "--version") { log(["invoke", args[3], arm]); const signedOut = (process.env.STUB_SIGNED_OUT_ARMS ?? "").split(",").includes(arm); print(signedOut ? { status: "error", error_kind: "not_logged_in", error_message: "Log in first." } : { status: "ok", address: "1 Main St" }); +} else if (command.startsWith("browsers telemetry events")) { + // One top-frame navigation to the start page, as the browser is created, in two pages of events. + const created = (state.browsers[args[3]] ?? { created: Date.now() }).created; + const navigation = { event: { ts: (created + 100) * 1000, type: "page_navigation", category: "page", data: { parent_frame_id: "", url: "https://example.com/" } } }; + print(args.includes("--offset") ? { events: [navigation] } : { events: [], next_offset: "100" }); } else if (command.startsWith("browsers delete")) { delete state.browsers[args[2]]; save(); diff --git a/bench/test/tasks.test.mjs b/bench/test/tasks.test.mjs index 75a155cb..edf0e8ee 100644 --- a/bench/test/tasks.test.mjs +++ b/bench/test/tasks.test.mjs @@ -208,14 +208,18 @@ test("a prepare intent's actions are registered, and must exist", async () => { assert.deepEqual((await registeredTools(intent, ["main"], show)).map(t => t.name), ["save", "search"]); await assert.rejects(registeredTools({ ...intent, actions: ["delete"] }, ["main"], show), /actions names delete, which no source has/); await assert.rejects(registeredTools({ ...intent, actions: [] }, ["main"], show), /save is excluded or neither/); + // Without names or an entry tool, every read-only tool is registered, and a domain with none registers none. + assert.deepEqual((await registeredTools({ ...base, exclude: {}, actions: [], entry_tool: undefined, tools: [{ ref: "main" }] }, ["main"], show)).map(t => t.name), ["search"]); + assert.deepEqual(await registeredTools({ ...base, domain: "other.com", exclude: {}, actions: [], entry_tool: undefined, tools: [{ ref: "main" }] }, ["main"], show), []); }); -test("a domain's actions reach its prepare intents, and its checkout actions only those that allow checkout", () => { - const delta = loadIntents(benchDir).filter(i => i.domain === "delta.com"); - const actions = (id) => delta.find(i => i.id === id).actions; +test("a domain's actions reach its prepare intents, its checkout actions only those that allow checkout, and intents default to main", () => { + const intents = loadIntents(benchDir); + const actions = (id) => intents.find(i => i.id === id).actions; assert.deepEqual(actions("delta-cheapest-fare"), []); assert.deepEqual(actions("delta-cart-main-classic"), ["select_fare", "select_fare_experience"]); assert.ok(actions("delta-checkout-signed-in").includes("continue_checkout")); + assert.deepEqual(intents.find(i => i.id === "delta-cart-main-classic").tools, [{ repo: "kernel/xwebmcp", ref: "main" }]); }); test("local browsers load the extension under the id Chrome gives it, and their MCP config fills in per browser", async () => {