Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion docs/benchmarks/2026-07-web-thread.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,65 @@ not claim to be: it is, to our knowledge, the first **browser-extension** agent
BYOK, no backend, store-installable — to publish any number on this benchmark at all.
31.0% is the honest floor of that category, with the full per-task evidence public.

## 5. What the benchmarks fixed on the way
## 5. The 31.0% was undercounting us: a measurement bug

The full 300 gave us 31.0% and a failure taxonomy. The obvious next move was to push the
number, so we tried three levers: a finish-and-report prompt ([PR #197](https://github.com/NotASithLord/peerd/pull/197)),
a stronger model (Fable 5), and a higher step cap. On 30–60-task subsets, each looked like
a +5–10pp win.

**Every one washed out at the full 300** (McNemar *p* ≈ 0.3–1.0). The reason is the most
important methodological number in this report: **single-run variance on live sites is
~±20%.** Re-run the *same* config and roughly a fifth of the 300 tasks flip outcome —
purely from sites changing under the agent and the model sampling differently. No lever
smaller than that noise floor is detectable from a single run; the subset wins were noise.

So we stopped chasing the number and diagnosed the failures instead. A pass over all 207
failed trajectories found one root cause dwarfing the rest: in ~40% of failures, peerd's
final answer was a *promissory ack* — "On it, I'll report when the web actor replies" —
recorded as the last word, with the real answer nowhere in the trajectory.

That is peerd's async architecture leaking into the measurement. The orchestrator delegates
web work to an actor that runs in its own session; the actor's reply re-enters on a *later*
turn (§6 describes the quiet-settle window we built to wait for exactly this — score only
once the whole flow goes silent). But the window was 15 seconds, and that wasn't long
enough: after the actor finishes clicking and navigating, it goes silent for one more model
call — synthesizing its reply — for 20–40 seconds on a slow model. The settle window closed
in that gap and captured the orchestrator's intermediate ack.

We proved it directly. Re-running 42 promissory-ack failures with the settle window
widened, **34 of 37 went from an ack to a real, substantive answer** — which run-variance
cannot manufacture (you can't turn zero substantive answers into 34 by luck). Then the full
300, changing *only* the settle window:

| | pass rate | vs. the 15 s baseline |
|---|---|---|
| settle 15 s (the 31.0% run, re-judged) | 33.1% | — |
| **settle, actor-aware** | **41.1%** | **+8pp, McNemar *p* = 0.011** |

That *p*-value is the first statistically significant result in this entire report — a real
effect, above the ±20% noise floor, with the mechanism proven independently. Translating off
the judge's measured leniency, the leaderboard-official (o4-mini) equivalent is roughly
**38–39%** — at parity with browser-use's independently verified ~40%.

The honest framing: **31.0% was not peerd's capability; it was peerd's capability minus a
harness bug.** The agent was getting the answers; the eval was reading them a beat too
early. And the correction is legitimate, not a thumb on the scale — a real user *does* get
the answer, because the reply lands; 15 seconds was simply too short a silence to wait for
an async browser round-trip.

The fix isn't a bigger fixed timeout (that taxes every task, including the fast ones). It
makes the settle *actor-aware*: hold the window open while a web-actor delegation is in
flight, and keep it tight otherwise. Fast tasks settle in 15 s; async tasks wait for the
actor, not a padding constant — reproduced independently of the blanket widening above
(same 34/37-class substance recovery, no per-task tax).

> The corrected numbers here are from the internal (Claude Haiku) judge, which we validated
> at 83% agreement / +2.3pp leniency vs. the official o4-mini judge; the o4-mini re-score of
> the corrected run is the one remaining step. The finding — the mechanism, the +8pp, and
> the significance — does not depend on it.

## 6. What the benchmarks fixed on the way

Running honest benchmarks against a real extension surfaced real product bugs, all fixed:

Expand Down
40 changes: 29 additions & 11 deletions extension/eval/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { sleep } from '/shared/util.js';

/**
* @typedef {{ inputTokens?: number, outputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, cost?: number }} Usage
* @typedef {{ session: any, tools: string[], tokens: number, cost: Usage | null, runner: { inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheWriteTokens: number }, error: string | null, started: boolean, resolveDone: ((value?: any) => void) | null, lastActivityAt: number, runnerUsd: number }} Turn
* @typedef {{ session: any, tools: string[], tokens: number, cost: Usage | null, runner: { inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheWriteTokens: number }, error: string | null, started: boolean, resolveDone: ((value?: any) => void) | null, lastActivityAt: number, runnerUsd: number, outstandingActors: number }} Turn
*/

// The runner's own $ for a task — 'local' is FREE, a cloud runner is priced from
Expand Down Expand Up @@ -80,7 +80,7 @@ let turn = fresh();
// scorecard stays honest (main is low, the actor's spend appears — not "free").
// The bucket keeps the `runner` name for continuity with the runnerModel A/B.
/** @returns {Turn} */
function fresh() { return { session: null, tools: [], tokens: 0, cost: null, runner: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, error: null, started: false, resolveDone: null, lastActivityAt: 0, runnerUsd: 0 }; }
function fresh() { return { session: null, tools: [], tokens: 0, cost: null, runner: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, error: null, started: false, resolveDone: null, lastActivityAt: 0, runnerUsd: 0, outstandingActors: 0 }; }

// 'eval' (not 'sidepanel') so an open home page doesn't think the side panel
// popped out — joins uiPorts for turn/* all the same. See service-worker onConnect.
Expand All @@ -95,20 +95,33 @@ function fresh() { return { session: null, tools: [], tokens: 0, cost: null, run
// only score after SETTLE_MS of total silence across the orchestrator AND its
// actor. Bounded by the task's timeoutMs (runTask races this against a sleep).
// why 15s: a delegated task has real inter-event gaps — the ack-idle before the
// actor spins up, and a snapshot-heavy actor step whose model call is slow. At
// 8s a snapshot-heavy task settled EARLY, mid-flight. 15s clears the largest
// observed gap; a stuck turn still bails on the task's own timeoutMs, and
// durationMs is measured to LAST ACTIVITY so the idle wait doesn't inflate it.
// actor spins up, and a snapshot-heavy actor step whose model call is slow. 15s
// clears the largest event-to-event gap; a stuck turn still bails on the task's
// own timeoutMs, and durationMs is measured to LAST ACTIVITY so the idle wait
// doesn't inflate it. why NOT larger: the ONE gap 15s couldn't cover is the web
// actor's own reply-SYNTHESIS — after its last page op the actor goes silent for
// a full model call (~20-40s on a slow model) before its reply-wake re-enters
// the orchestrator with the real answer. A blanket bump to 60s "fixed" it (score
// 33%→41% at full-300, McNemar p=0.011) but taxed EVERY task +45s. The precise
// fix instead: HOLD the settle while a web-actor delegation is in flight (see
// outstandingActors below) so that synthesis gap can't trigger capture, and keep
// the window tight at 15s once no actor is outstanding. Fast tasks settle in 15s;
// async tasks wait for the actor, not a fixed padding.
const SETTLE_MS = 15_000;
/** @type {ReturnType<typeof setTimeout> | null} */
let settleTimer = null;
// Reset the settle countdown on activity; when it elapses with no new event,
// the delegated round-trip is finished → resolve the task's done promise.
// Reset the settle countdown on activity; when it elapses with no new event AND
// no web-actor delegation is still in flight, the round-trip is finished →
// resolve the task's done promise. If an actor is outstanding (started, not yet
// done) the window elapsing is the synthesis-gap false alarm — reschedule instead
// of resolving. Bounded by the task's own timeoutMs even if an actor never
// reports done (a hung actor).
const bumpSettle = () => {
if (!turn.started || !turn.resolveDone) return; // no active task / not begun
turn.lastActivityAt = Date.now(); // for the work-time duration metric
if (settleTimer) clearTimeout(settleTimer);
settleTimer = setTimeout(() => {
settleTimer = setTimeout(function fire() {
if (turn.outstandingActors > 0) { settleTimer = setTimeout(fire, SETTLE_MS); return; }
settleTimer = null;
if (turn.resolveDone) { const r = turn.resolveDone; turn.resolveDone = null; r(); }
}, SETTLE_MS);
Expand Down Expand Up @@ -158,8 +171,13 @@ port.onMessage.addListener((/** @type {any} */ msg) => {
bumpSettle();
break;
// Actor lifecycle events are ACTIVITY — they must hold the settle window
// open while the delegated work runs.
case 'turn/actor-start': case 'turn/actor-state': case 'turn/actor-done': bumpSettle(); break;
// open while the delegated work runs. start/done also bracket an OUTSTANDING
// delegation: while the count is > 0 the settle-fire is suppressed (see
// bumpSettle), so the actor's silent reply-synthesis gap can't capture the
// orchestrator's intermediate ack. clamp at 0 so a missed start can't wedge.
case 'turn/actor-start': turn.outstandingActors++; bumpSettle(); break;
case 'turn/actor-done': turn.outstandingActors = Math.max(0, turn.outstandingActors - 1); bumpSettle(); break;
case 'turn/actor-state': bumpSettle(); break;
case 'turn/error': turn.error = msg.error; bumpSettle(); break;
// streaming:true = the orchestrator (or a wake) is producing output → keep
// waiting. streaming:false = it went idle → START the settle countdown (a
Expand Down
15 changes: 13 additions & 2 deletions scripts/cdp/run-om2w.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,19 @@ async function main() {
};
log(`SMOKE — wire-fake against ${fixture.url}${ACTOR_SURFACE ? ` (${ACTOR_SURFACE} surface)` : ''}; no dataset, no key, no cost.`);
} else {
const shard = ALL.slice(OFFSET, OFFSET + COUNT);
log(`OM2W ${revision.slice(0, 8)} — tasks ${OFFSET}..${OFFSET + shard.length - 1} of ${ALL.length}, model=${MODEL || PROVIDER} → ${OUT}`);
// --task-ids=<file>: run an ARBITRARY set of task_ids (one per line), not a
// contiguous offset/count shard. Used to re-run a specific failure cluster
// (e.g. the promissory-ack tasks) under a changed harness knob.
const idsFile = flag('task-ids', false) ? String(flag('task-ids', '')) : '';
const shard = idsFile
? (() => {
const wanted = new Set(readFileSync(idsFile, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean));
return ALL.filter((t) => wanted.has(t.task_id));
})()
: ALL.slice(OFFSET, OFFSET + COUNT);
log(idsFile
? `OM2W ${revision.slice(0, 8)} — ${shard.length} task(s) from ${idsFile}, model=${MODEL || PROVIDER} → ${OUT}`
: `OM2W ${revision.slice(0, 8)} — tasks ${OFFSET}..${OFFSET + shard.length - 1} of ${ALL.length}, model=${MODEL || PROVIDER} → ${OUT}`);
todo = shard.filter((t) => !existsSync(join(OUT, t.task_id, 'result.json'))); // resumable
log(`${shard.length - todo.length} already exported; ${todo.length} to run`);
if (!todo.length) { log('nothing to do'); process.exit(0); }
Expand Down