Skip to content

Commit f2873cd

Browse files
miguelg719claude
andauthored
feat(evals): wire WebTailBench through verifier (browserbase#2135)
# Why The eval harness needs one reusable path for running an agent, recording its trajectory, and scoring it with the verifier. This PR applies that path to WebTailBench first as the narrowest dataset migration. # What Changed - Added `runWithVerifier` for rubric resolution, trajectory recording, verifier scoring, and result persistence. - Migrated the WebTailBench task path to verifier-backed scoring. - Added validated success-mode conversion from verifier verdicts; invalid or missing values fall back to `outcome`. - Added focused tests for success-mode validation. - Added a focused WebTailBench verifier smoke script. - Kept verifier usage explicit with `backend: "verifier"`. - Removed upstream verifier references from comments. # Tests - `pnpm --filter @browserbasehq/stagehand run typecheck` - `pnpm --filter @browserbasehq/stagehand-evals run typecheck` - `pnpm --dir packages/evals exec vitest run tests/framework/rubricCache.test.ts tests/framework/verifierAdapter.test.ts tests/tui/commandTree.test.ts tests/framework/claudeCodeRunner.test.ts tests/framework/codexRunner.test.ts tests/cli.test.ts` - `git diff --check` --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9971a7b commit f2873cd

51 files changed

Lines changed: 2469 additions & 2296 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/evals/datasets/webtailbench/WebTailBench_data.jsonl

Lines changed: 609 additions & 609 deletions
Large diffs are not rendered by default.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* adHocRubric — synthesize a Rubric from one or more natural-language
3+
* criteria without invoking the LLM-based rubric generator.
4+
*
5+
* Used by migrated custom agent tasks whose original verification was a
6+
* single `V3Evaluator.ask({question})` YES/NO call. Each criterion becomes
7+
* a 1-point rubric item.
8+
*
9+
* For tasks that already have a concrete predicate ("Does the page show
10+
* flights from SF to NY?"), pass the predicate verbatim. For the lazy
11+
* "did the agent complete this task successfully? <instruction>" pattern,
12+
* pass the instruction.
13+
*/
14+
import type { Rubric } from "@browserbasehq/stagehand";
15+
16+
export function adHocRubric(...criteria: string[]): Rubric {
17+
if (criteria.length === 0) {
18+
throw new Error("adHocRubric requires at least one criterion");
19+
}
20+
return {
21+
items: criteria.map((c) => ({
22+
criterion: c,
23+
description: c,
24+
maxPoints: 1,
25+
})),
26+
};
27+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import {
2+
V3Evaluator,
3+
normalizeRubric,
4+
type AgentInstance,
5+
type AgentExecuteOptions,
6+
type AgentResult,
7+
type EvaluationResult,
8+
type Rubric,
9+
type TaskSpec,
10+
type Trajectory,
11+
type V3,
12+
} from "@browserbasehq/stagehand";
13+
14+
import { RubricCache } from "./rubricCache.js";
15+
import { TrajectoryRecorder } from "./trajectoryRecorder.js";
16+
17+
export interface RunWithVerifierOptions {
18+
v3: V3;
19+
agent: AgentInstance;
20+
taskSpec: TaskSpec;
21+
/**
22+
* Dataset name for rubric cache partitioning. Each task lives under
23+
* `.rubric-cache/<dataset>/<task-id>.json`.
24+
*/
25+
dataset: string;
26+
/** Agent execute options. `instruction` is filled from taskSpec.instruction. */
27+
agentOptions?: Omit<AgentExecuteOptions, "instruction">;
28+
/** Override the run id (defaults to ISO timestamp). */
29+
runId?: string;
30+
/** Override trajectory persistence root. */
31+
trajectoryRoot?: string;
32+
}
33+
34+
export interface RunWithVerifierResult {
35+
trajectory: Trajectory;
36+
evaluationResult: EvaluationResult;
37+
agentResult: AgentResult;
38+
/** Resolved rubric (precomputed, cached, or freshly generated). */
39+
rubric: Rubric;
40+
/** Where the trajectory was persisted (or would have been, if disabled). */
41+
trajectoryDir: string;
42+
}
43+
44+
export async function runWithVerifier(
45+
opts: RunWithVerifierOptions,
46+
): Promise<RunWithVerifierResult> {
47+
const { v3, agent, taskSpec, dataset, agentOptions, runId, trajectoryRoot } =
48+
opts;
49+
const evaluator = new V3Evaluator(v3, { backend: "verifier" });
50+
51+
// ── Resolve rubric ──────────────────────────────────────────────────────
52+
let resolvedRubric: Rubric;
53+
if (taskSpec.precomputedRubric) {
54+
resolvedRubric = normalizeRubric(taskSpec.precomputedRubric)!;
55+
} else if (process.env.VERIFIER_DISABLE_RUBRIC_CACHE === "1") {
56+
resolvedRubric = await evaluator.generateRubric(taskSpec);
57+
} else {
58+
const cache = new RubricCache({ dataset });
59+
resolvedRubric = await cache.getOrGenerate(taskSpec, evaluator);
60+
}
61+
62+
// Hand a fully-hydrated TaskSpec to the verifier so it doesn't regenerate.
63+
const hydratedTaskSpec: TaskSpec = {
64+
...taskSpec,
65+
precomputedRubric: resolvedRubric,
66+
};
67+
68+
// ── Record trajectory around agent.execute() ───────────────────────────
69+
const recorder = new TrajectoryRecorder({
70+
taskSpec: hydratedTaskSpec,
71+
runId,
72+
outputRoot: trajectoryRoot,
73+
});
74+
const { callbacks: userCallbacks, ...restAgentOptions } = agentOptions ?? {};
75+
76+
let agentResult: AgentResult;
77+
let recorderStatus: "complete" | "aborted" | "error" = "complete";
78+
try {
79+
agentResult = await agent.execute({
80+
...restAgentOptions,
81+
instruction: taskSpec.instruction,
82+
callbacks: {
83+
...userCallbacks,
84+
onEvidence: async (event) => {
85+
recorder.record(event);
86+
await userCallbacks?.onEvidence?.(event);
87+
},
88+
},
89+
});
90+
} catch (e) {
91+
recorderStatus = "error";
92+
// Re-throw after persisting so the bench task can decide how to report.
93+
const wrapped = e instanceof Error ? e : new Error(String(e));
94+
try {
95+
const trajectory = await recorder.finish({ status: recorderStatus });
96+
Object.assign(wrapped, { trajectoryDir: recorder.directory, trajectory });
97+
} catch {
98+
// Persistence failure must not mask the original agent error.
99+
}
100+
throw wrapped;
101+
}
102+
103+
const trajectory = await recorder.finish({
104+
status: recorderStatus,
105+
finalAnswer: agentResult.message,
106+
usage: agentResult.usage,
107+
});
108+
109+
// ── Verify ──────────────────────────────────────────────────────────────
110+
const evaluationResult = await evaluator.verify(trajectory);
111+
await recorder.persistResult(evaluationResult);
112+
113+
return {
114+
trajectory,
115+
evaluationResult,
116+
agentResult,
117+
rubric: resolvedRubric,
118+
trajectoryDir: recorder.directory,
119+
};
120+
}
121+
122+
/**
123+
* Decide bench task success from an EvaluationResult using the --success flag's
124+
* semantics.
125+
*
126+
* `outcome` (default) — strict binary outcome.
127+
* `process` — rubric process score ≥ threshold (default 0.8).
128+
* `both` — both conditions must hold.
129+
*/
130+
export type EvalSuccessMode = "outcome" | "process" | "both";
131+
132+
export function resolveEvalSuccessMode(mode: unknown): EvalSuccessMode {
133+
if (typeof mode !== "string") return "outcome";
134+
const normalized = mode.trim().toLowerCase();
135+
if (
136+
normalized === "outcome" ||
137+
normalized === "process" ||
138+
normalized === "both"
139+
) {
140+
return normalized;
141+
}
142+
return "outcome";
143+
}
144+
145+
export function evaluationResultToSuccess(
146+
result: EvaluationResult,
147+
mode: unknown = "outcome",
148+
processThreshold = 0.8,
149+
): boolean {
150+
const resolvedMode = resolveEvalSuccessMode(mode);
151+
const outcomeOk = result.outcomeSuccess;
152+
const processOk =
153+
typeof result.processScore === "number" &&
154+
result.processScore >= processThreshold;
155+
switch (resolvedMode) {
156+
case "outcome":
157+
return outcomeOk;
158+
case "process":
159+
return processOk;
160+
case "both":
161+
return outcomeOk && processOk;
162+
}
163+
}

packages/evals/scripts/backfill-webtailbench-rubrics.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const JSONL_PATH = path.join(
2929
"WebTailBench_data.jsonl",
3030
);
3131

32-
interface Rubric {
32+
interface RawRubric {
3333
items: Array<Record<string, unknown>>;
3434
}
3535

@@ -38,7 +38,7 @@ interface LocalRow {
3838
category?: string;
3939
ques: string;
4040
web?: string;
41-
precomputed_rubric?: Rubric;
41+
precomputed_rubric?: RawRubric;
4242
}
4343

4444
/**
@@ -114,12 +114,12 @@ async function main(): Promise<void> {
114114
);
115115
}
116116

117-
const rubricsById = new Map<string, Rubric>();
117+
const rubricsById = new Map<string, RawRubric>();
118118
for (let i = 1; i < rows.length; i++) {
119119
const cols = rows[i];
120120
if (!cols[idIdx]) continue;
121121
try {
122-
const parsed = JSON.parse(cols[rubricIdx]) as Rubric;
122+
const parsed = JSON.parse(cols[rubricIdx]) as RawRubric;
123123
rubricsById.set(cols[idIdx], parsed);
124124
} catch (e) {
125125
console.warn(
@@ -149,7 +149,7 @@ async function main(): Promise<void> {
149149
}
150150

151151
console.log(
152-
` ✓ matched ${matched}/${inLines.length} rows; ${missing} unmatched (will fall back to Step 0a generation)`,
152+
` ✓ matched ${matched}/${inLines.length} rows; ${missing} unmatched (will fall back to generated rubrics)`,
153153
);
154154

155155
await fs.writeFile(JSONL_PATH, out.join("\n") + "\n", "utf8");

packages/evals/suites/onlineMind2Web.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,23 @@ export const buildOnlineMind2WebTestcases = (
5252
}
5353

5454
const candidates = parseJsonlRows(lines, isMind2WebRow);
55-
const rows = applySampling(candidates, sampleCount, maxCases);
55+
56+
// EVAL_ONLINEMIND2WEB_IDS restricts the suite to exactly those task ids,
57+
// preserving the order given and ignoring sampling / limit knobs.
58+
const explicitIds = process.env.EVAL_ONLINEMIND2WEB_IDS
59+
? process.env.EVAL_ONLINEMIND2WEB_IDS.split(",")
60+
.map((s) => s.trim())
61+
.filter(Boolean)
62+
: null;
63+
let rows: Mind2WebRow[];
64+
if (explicitIds && explicitIds.length > 0) {
65+
const byId = new Map(candidates.map((r) => [r.task_id, r]));
66+
rows = explicitIds
67+
.map((id) => byId.get(id))
68+
.filter((r): r is Mind2WebRow => Boolean(r));
69+
} else {
70+
rows = applySampling(candidates, sampleCount, maxCases);
71+
}
5672

5773
const allTestcases: Testcase[] = [];
5874
for (const modelEntry of normalizeAgentModelEntries(models)) {

packages/evals/suites/webtailbench.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Testcase, EvalInput, AgentModelEntry } from "../types/evals.js";
2-
import type { AvailableModel } from "@browserbasehq/stagehand";
2+
import { normalizeRubric, type AvailableModel } from "@browserbasehq/stagehand";
33
import { tasksConfig } from "../taskConfig.js";
44
import { getPackageRootDir } from "../runtimePaths.js";
55
import {
@@ -32,6 +32,12 @@ export const buildWebTailBenchTestcases = (
3232
ques: string;
3333
category?: string;
3434
web?: string;
35+
/**
36+
* Per-task rubric ported from microsoft/WebTailBench-v1-rubrics.tsv
37+
* via packages/evals/scripts/backfill-webtailbench-rubrics.ts.
38+
* When present, the verifier uses these upstream criteria directly.
39+
*/
40+
precomputed_rubric?: unknown;
3541
[key: string]: unknown;
3642
};
3743

@@ -42,7 +48,23 @@ export const buildWebTailBenchTestcases = (
4248
}
4349

4450
const candidates = parseJsonlRows(lines, isWebTailBenchRow);
45-
const rows = applySampling(candidates, sampleCount, maxCases);
51+
52+
// EVAL_WEBTAILBENCH_IDS restricts the suite to exactly those task IDs,
53+
// preserving the order given and ignoring sampling / limit knobs.
54+
const explicitIds = process.env.EVAL_WEBTAILBENCH_IDS
55+
? process.env.EVAL_WEBTAILBENCH_IDS.split(",")
56+
.map((s) => s.trim())
57+
.filter(Boolean)
58+
: null;
59+
let rows: WebTailBenchRow[];
60+
if (explicitIds && explicitIds.length > 0) {
61+
const byId = new Map(candidates.map((r) => [r.id, r]));
62+
rows = explicitIds
63+
.map((id) => byId.get(id))
64+
.filter((r): r is WebTailBenchRow => Boolean(r));
65+
} else {
66+
rows = applySampling(candidates, sampleCount, maxCases);
67+
}
4668

4769
const allTestcases: Testcase[] = [];
4870
for (const modelEntry of normalizeAgentModelEntries(models)) {
@@ -57,6 +79,7 @@ export const buildWebTailBenchTestcases = (
5779
category: row.category,
5880
ques: row.ques,
5981
web: row.web,
82+
precomputed_rubric: normalizeRubric(row.precomputed_rubric),
6083
},
6184
};
6285
const taskCategories =

0 commit comments

Comments
 (0)