@tangle-network/agent-bench 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/package.json +35 -0
- package/src/adapters.ts +60 -0
- package/src/aec-gate.mts +217 -0
- package/src/atom-humaneval.mts +197 -0
- package/src/atom-mcp-e2e.mts +223 -0
- package/src/benchmarks/_harness.ts +206 -0
- package/src/benchmarks/aec-bench.test.mts +53 -0
- package/src/benchmarks/aec-bench.ts +319 -0
- package/src/benchmarks/appworld.test.mts +45 -0
- package/src/benchmarks/appworld.ts +426 -0
- package/src/benchmarks/cad-design.ts +429 -0
- package/src/benchmarks/cadbench.ts +135 -0
- package/src/benchmarks/cadgenbench.ts +121 -0
- package/src/benchmarks/commit0.test.mts +71 -0
- package/src/benchmarks/commit0.ts +266 -0
- package/src/benchmarks/enterpriseops-gym.test.mts +77 -0
- package/src/benchmarks/enterpriseops-gym.ts +343 -0
- package/src/benchmarks/finsearchcomp.ts +371 -0
- package/src/benchmarks/frames.ts +520 -0
- package/src/benchmarks/hotpotqa.ts +320 -0
- package/src/benchmarks/humaneval.ts +251 -0
- package/src/benchmarks/mind2web.ts +311 -0
- package/src/benchmarks/programbench.test.mts +60 -0
- package/src/benchmarks/programbench.ts +210 -0
- package/src/benchmarks/simpleqa.ts +390 -0
- package/src/benchmarks/swe-bench.ts +152 -0
- package/src/benchmarks/terminal-bench.ts +182 -0
- package/src/benchmarks/trata-hedge.ts +496 -0
- package/src/benchmarks/types.ts +58 -0
- package/src/browser/adapters/bad-design-audit.ts +153 -0
- package/src/browser/adapters/bad-design-audit.verify.ts +85 -0
- package/src/browser/adapters/bad.ts +165 -0
- package/src/browser/agent-adapter.ts +145 -0
- package/src/browser/process-adapter.ts +146 -0
- package/src/browser/run-to-spans.ts +105 -0
- package/src/browser/run-to-spans.verify.ts +89 -0
- package/src/browser/ui-reviewer.ts +200 -0
- package/src/browser/ui-reviewer.verify.ts +94 -0
- package/src/browser/verify.ts +96 -0
- package/src/clbench-codebase-gate.mts +314 -0
- package/src/clbench-context-gate.mts +305 -0
- package/src/cloud-loop.mts +138 -0
- package/src/coding-skills/minimal-diff.md +9 -0
- package/src/coding-skills/read-before-edit.md +9 -0
- package/src/coding-skills/reproduce-first.md +10 -0
- package/src/coding-skills/run-tests-after-edit.md +9 -0
- package/src/coding-skills/trace-the-failure.md +9 -0
- package/src/commit0-env-run.mts +59 -0
- package/src/commit0-env.ts +173 -0
- package/src/commit0-gate.mts +529 -0
- package/src/commit0-prereqs.sh +48 -0
- package/src/corpus-replay.mts +300 -0
- package/src/corpus-report.mts +504 -0
- package/src/corpus.test.mts +273 -0
- package/src/corpus.ts +325 -0
- package/src/decoder-live.mts +133 -0
- package/src/directives.ts +84 -0
- package/src/diverse-gate.mjs +112 -0
- package/src/egress-probe.mts +26 -0
- package/src/eops-skills/address-every-subtask.md +5 -0
- package/src/eops-skills/exact-tools-and-args.md +5 -0
- package/src/eops-skills/full-sequence-to-goal.md +5 -0
- package/src/eops-skills/ground-every-value.md +5 -0
- package/src/eops-skills/honor-the-policies.md +5 -0
- package/src/examples/README.md +58 -0
- package/src/examples/math-demo.mts +110 -0
- package/src/examples/strategy-demo.mts +119 -0
- package/src/fleet.mts +121 -0
- package/src/gate-cli.mts +101 -0
- package/src/gate.test.mts +129 -0
- package/src/gate.ts +460 -0
- package/src/generate-eval/certify.ts +178 -0
- package/src/generate-eval/schema.ts +78 -0
- package/src/humaneval-gate.mts +204 -0
- package/src/humaneval-repair-gate.mts +143 -0
- package/src/index.ts +19 -0
- package/src/mcp-mount-probe.mts +126 -0
- package/src/profile-coordinates.ts +134 -0
- package/src/profiles.ts +128 -0
- package/src/refine-loop.test.mts +106 -0
- package/src/refine-loop.ts +106 -0
- package/src/research-gate.mts +132 -0
- package/src/research-shot.ts +134 -0
- package/src/resolve-client.ts +58 -0
- package/src/router-executor.ts +51 -0
- package/src/run-pool.ts +48 -0
- package/src/runtime-hook-recorder.ts +137 -0
- package/src/sandbox-run.ts +125 -0
- package/src/search-bench/bridge.ts +124 -0
- package/src/search-bench/export.mts +0 -0
- package/src/search-bench/parametric-check.mts +63 -0
- package/src/search-bench/profiles.ts +98 -0
- package/src/search-bench/run.mts +287 -0
- package/src/search-bench/tasks-fresh.ts +688 -0
- package/src/search-bench/tasks.ts +129 -0
- package/src/search-tool.ts +95 -0
- package/src/selector.test.mts +189 -0
- package/src/selector.ts +366 -0
- package/src/skill-sandbox-smoke.mts +100 -0
- package/src/stats.mts +90 -0
- package/src/terminal-compare.ts +519 -0
- package/src/trajectory-assemble.mjs +130 -0
- package/src/trata-gate.mts +243 -0
- package/src/trata-gepa.mts +434 -0
- package/src/worker-blender.ts +230 -0
- package/src/worker-browser.ts +102 -0
- package/src/worker-build123d.ts +143 -0
- package/src/worker-cad.ts +451 -0
- package/src/worker.ts +136 -0
- package/src/workspace-loop.mts +133 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language-agnostic browser-agent adapter over a process boundary.
|
|
3
|
+
*
|
|
4
|
+
* This is how we avoid overfitting to our own driver: ANY browser agent becomes a
|
|
5
|
+
* `BrowserAgentAdapter` by speaking one JSON protocol over stdio — no shared SDK,
|
|
6
|
+
* no TypeScript requirement. A Python `browser-use` runner, our TS `bad` CLI, or a
|
|
7
|
+
* hosted service all plug in identically.
|
|
8
|
+
*
|
|
9
|
+
* Protocol (stdio):
|
|
10
|
+
* - The harness spawns `cmd ...args`, writes a BrowserTask as one JSON line to
|
|
11
|
+
* the child's stdin, and closes stdin.
|
|
12
|
+
* - The child runs its agent and writes EXACTLY one JSON object — a BrowserRun —
|
|
13
|
+
* to stdout (anything on stderr is logged, ignored for parsing).
|
|
14
|
+
* - Non-zero exit, no JSON, or a malformed run → throw (fail loud; a driver that
|
|
15
|
+
* can't conform is not silently scored zero).
|
|
16
|
+
*
|
|
17
|
+
* The child is responsible only for producing a faithful trajectory + final state.
|
|
18
|
+
* The VERDICT is never the child's to give — `judgeBrowserRun` derives it from the
|
|
19
|
+
* benchmark's deterministic SuccessSpec against the returned final state.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn } from 'node:child_process'
|
|
23
|
+
import type { BrowserAgentAdapter, BrowserRun, BrowserStep, BrowserTask } from './agent-adapter'
|
|
24
|
+
|
|
25
|
+
export interface ProcessAdapterOptions {
|
|
26
|
+
/** Attribution id for head-to-head (e.g. 'browser-use', 'bad'). */
|
|
27
|
+
id: string
|
|
28
|
+
/** Executable to spawn (e.g. 'python', 'node', 'bad'). */
|
|
29
|
+
cmd: string
|
|
30
|
+
/** Fixed args before the per-task protocol (e.g. ['runners/browser_use_runner.py']). */
|
|
31
|
+
args?: string[]
|
|
32
|
+
/** Extra env for the child (model keys, etc.). Merged over process.env. */
|
|
33
|
+
env?: Record<string, string>
|
|
34
|
+
/** Hard ceiling per task in ms (default 300_000). */
|
|
35
|
+
timeoutMs?: number
|
|
36
|
+
/** Working directory for the child. */
|
|
37
|
+
cwd?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Build a BrowserAgentAdapter that delegates to an external process speaking the
|
|
41
|
+
* JSON protocol above. */
|
|
42
|
+
export function processBrowserAdapter(opts: ProcessAdapterOptions): BrowserAgentAdapter {
|
|
43
|
+
const timeoutMs = opts.timeoutMs ?? 300_000
|
|
44
|
+
return {
|
|
45
|
+
id: opts.id,
|
|
46
|
+
async run(task: BrowserTask): Promise<BrowserRun> {
|
|
47
|
+
const stdout = await runChild(opts, JSON.stringify(task), timeoutMs)
|
|
48
|
+
const run = parseRun(stdout, task, opts.id)
|
|
49
|
+
return run
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function runChild(opts: ProcessAdapterOptions, input: string, timeoutMs: number): Promise<string> {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const child = spawn(opts.cmd, opts.args ?? [], {
|
|
57
|
+
cwd: opts.cwd,
|
|
58
|
+
env: { ...process.env, ...opts.env },
|
|
59
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
60
|
+
})
|
|
61
|
+
let out = ''
|
|
62
|
+
let err = ''
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
child.kill('SIGKILL')
|
|
65
|
+
reject(new Error(`browser adapter '${opts.id}' timed out after ${timeoutMs}ms`))
|
|
66
|
+
}, timeoutMs)
|
|
67
|
+
child.stdout.on('data', (d) => (out += d.toString()))
|
|
68
|
+
child.stderr.on('data', (d) => (err += d.toString()))
|
|
69
|
+
child.on('error', (e) => {
|
|
70
|
+
clearTimeout(timer)
|
|
71
|
+
reject(new Error(`browser adapter '${opts.id}' failed to spawn ${opts.cmd}: ${e.message}`))
|
|
72
|
+
})
|
|
73
|
+
child.on('close', (code) => {
|
|
74
|
+
clearTimeout(timer)
|
|
75
|
+
if (code !== 0) {
|
|
76
|
+
reject(new Error(`browser adapter '${opts.id}' exited ${code}: ${err.slice(-400) || out.slice(-400)}`))
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
resolve(out)
|
|
80
|
+
})
|
|
81
|
+
child.stdin.write(input)
|
|
82
|
+
child.stdin.end()
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Parse + validate the child's BrowserRun. The last complete JSON object on stdout
|
|
87
|
+
* is the result (drivers may print progress lines before it). Fail loud on a shape
|
|
88
|
+
* that the judge cannot trust. */
|
|
89
|
+
function parseRun(stdout: string, task: BrowserTask, driverId: string): BrowserRun {
|
|
90
|
+
const obj = lastJsonObject(stdout)
|
|
91
|
+
if (!obj) throw new Error(`browser adapter '${driverId}' produced no JSON BrowserRun for task ${task.id}`)
|
|
92
|
+
if (typeof obj.finalUrl !== 'string' || !Array.isArray(obj.steps)) {
|
|
93
|
+
throw new Error(`browser adapter '${driverId}' BrowserRun missing finalUrl/steps for task ${task.id}`)
|
|
94
|
+
}
|
|
95
|
+
const steps: BrowserStep[] = (obj.steps as unknown[]).map((s, i) => {
|
|
96
|
+
const o = (s ?? {}) as Record<string, unknown>
|
|
97
|
+
return {
|
|
98
|
+
index: typeof o.index === 'number' ? o.index : i,
|
|
99
|
+
url: typeof o.url === 'string' ? o.url : '',
|
|
100
|
+
action: typeof o.action === 'string' ? o.action : 'step',
|
|
101
|
+
target: typeof o.target === 'string' ? o.target : undefined,
|
|
102
|
+
reasoning: typeof o.reasoning === 'string' ? o.reasoning : undefined,
|
|
103
|
+
screenshotPath: typeof o.screenshotPath === 'string' ? o.screenshotPath : undefined,
|
|
104
|
+
actionBounds: isBounds(o.actionBounds) ? (o.actionBounds as BrowserStep['actionBounds']) : undefined,
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
const usage =
|
|
108
|
+
obj.usage && typeof obj.usage === 'object'
|
|
109
|
+
? {
|
|
110
|
+
input: Number((obj.usage as Record<string, unknown>).input) || 0,
|
|
111
|
+
output: Number((obj.usage as Record<string, unknown>).output) || 0,
|
|
112
|
+
}
|
|
113
|
+
: undefined
|
|
114
|
+
return {
|
|
115
|
+
taskId: task.id,
|
|
116
|
+
steps,
|
|
117
|
+
finalUrl: obj.finalUrl as string,
|
|
118
|
+
finalDom: typeof obj.finalDom === 'string' ? obj.finalDom : undefined,
|
|
119
|
+
answer: typeof obj.answer === 'string' ? obj.answer : undefined,
|
|
120
|
+
selfReportedSuccess: typeof obj.selfReportedSuccess === 'boolean' ? obj.selfReportedSuccess : undefined,
|
|
121
|
+
usage,
|
|
122
|
+
costUsd: typeof obj.costUsd === 'number' ? obj.costUsd : undefined,
|
|
123
|
+
driverId,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isBounds(v: unknown): boolean {
|
|
128
|
+
if (!v || typeof v !== 'object') return false
|
|
129
|
+
const o = v as Record<string, unknown>
|
|
130
|
+
return ['x', 'y', 'width', 'height'].every((k) => typeof o[k] === 'number')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Find the last top-level JSON object in mixed stdout (progress lines + result). */
|
|
134
|
+
function lastJsonObject(s: string): Record<string, unknown> | null {
|
|
135
|
+
for (let i = s.lastIndexOf('{'); i >= 0; i = s.lastIndexOf('{', i - 1)) {
|
|
136
|
+
const slice = s.slice(i)
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(slice)
|
|
139
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as Record<string, unknown>
|
|
140
|
+
} catch {
|
|
141
|
+
// keep scanning earlier '{'
|
|
142
|
+
}
|
|
143
|
+
if (i === 0) break
|
|
144
|
+
}
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure converter: a neutral `BrowserRun` (from any `BrowserAgentAdapter`) → a
|
|
3
|
+
* `Span[]` trace run-capsule renders into a navigation film. One `kind:'tool'`
|
|
4
|
+
* span per `BrowserStep` (`browser.<action>`); when a step carries a
|
|
5
|
+
* `screenshotPath`, the file is read and inlined as a base64 `data:` URI on
|
|
6
|
+
* `attributes.screenshot` — run-capsule renders from the inline blob, NOT a path,
|
|
7
|
+
* so a raw path would silently render nothing.
|
|
8
|
+
*
|
|
9
|
+
* Kept pure + spawn-free (the adapter already ran the browser) so the mapping is
|
|
10
|
+
* unit-testable against a fixture without launching Chromium. Timestamps are
|
|
11
|
+
* synthesized monotonically from `opts.startTs` (default 0) — deterministic for
|
|
12
|
+
* the verify; the live caller passes `Date.now()`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile } from 'node:fs/promises'
|
|
16
|
+
import type { Span } from '@tangle-network/agent-eval'
|
|
17
|
+
import type { BrowserRun, BrowserStep } from './agent-adapter'
|
|
18
|
+
|
|
19
|
+
export interface BrowserRunToSpansOptions {
|
|
20
|
+
/** Base timestamp; each emitted span advances by 1ms. Default 0 (deterministic). */
|
|
21
|
+
startTs?: number
|
|
22
|
+
/** Cap on inlined screenshot bytes before base64 (skip oversized frames). Default 8 MiB. */
|
|
23
|
+
maxScreenshotBytes?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47])
|
|
27
|
+
const JPG = Buffer.from([0xff, 0xd8, 0xff])
|
|
28
|
+
|
|
29
|
+
/** Sniff the on-disk image mime from magic bytes; default png. */
|
|
30
|
+
function imageMime(buf: Buffer): string {
|
|
31
|
+
if (buf.subarray(0, 4).equals(PNG)) return 'image/png'
|
|
32
|
+
if (buf.subarray(0, 3).equals(JPG)) return 'image/jpeg'
|
|
33
|
+
return 'image/png'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolve a step's frame to an inline `data:` URI for the film. Prefers an
|
|
37
|
+
* already-inline `step.screenshot` (e.g. bad's base64, whose sink is reaped),
|
|
38
|
+
* else reads `step.screenshotPath` off disk. Fail-soft: an unset / unreadable /
|
|
39
|
+
* oversized frame drops the image, never the span — the navigation step still
|
|
40
|
+
* belongs in the film. */
|
|
41
|
+
async function resolveFrame(step: BrowserStep, maxBytes: number): Promise<string | undefined> {
|
|
42
|
+
if (step.screenshot) {
|
|
43
|
+
return step.screenshot.length <= maxBytes * 2 ? step.screenshot : undefined
|
|
44
|
+
}
|
|
45
|
+
if (!step.screenshotPath) return undefined
|
|
46
|
+
const buf = await readFile(step.screenshotPath).catch(() => undefined)
|
|
47
|
+
if (!buf || buf.length === 0 || buf.length > maxBytes) return undefined
|
|
48
|
+
return `data:${imageMime(buf)};base64,${buf.toString('base64')}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Map a `BrowserRun` to a run-capsule-ready `Span[]`. Async because it reads the
|
|
53
|
+
* step screenshots off disk. The returned trace is ordered by step index; the
|
|
54
|
+
* agent's final answer (extraction tasks) becomes a trailing generic span so the
|
|
55
|
+
* film closes on the result.
|
|
56
|
+
*/
|
|
57
|
+
export async function browserRunToSpans(
|
|
58
|
+
run: BrowserRun,
|
|
59
|
+
opts: BrowserRunToSpansOptions = {},
|
|
60
|
+
): Promise<Span[]> {
|
|
61
|
+
const maxBytes = opts.maxScreenshotBytes ?? 8 * 1024 * 1024
|
|
62
|
+
let ts = opts.startTs ?? 0
|
|
63
|
+
const tick = (): number => (ts += 1)
|
|
64
|
+
const runId = `browser-${run.taskId}`
|
|
65
|
+
|
|
66
|
+
const spans: Span[] = []
|
|
67
|
+
for (const step of run.steps) {
|
|
68
|
+
const action = step.action || 'navigate'
|
|
69
|
+
const screenshot = await resolveFrame(step, maxBytes)
|
|
70
|
+
const startedAt = tick()
|
|
71
|
+
spans.push({
|
|
72
|
+
spanId: `s-${step.index}`,
|
|
73
|
+
runId,
|
|
74
|
+
kind: 'tool',
|
|
75
|
+
name: `browser ${action}`,
|
|
76
|
+
toolName: `browser.${action}`,
|
|
77
|
+
args: { url: step.url, target: step.target, reasoning: step.reasoning },
|
|
78
|
+
startedAt,
|
|
79
|
+
endedAt: tick(),
|
|
80
|
+
status: 'ok',
|
|
81
|
+
// attributes is the run-capsule contract: inline base64 screenshot + the
|
|
82
|
+
// action box for a replay cursor overlay. Only set keys that are present.
|
|
83
|
+
attributes: {
|
|
84
|
+
...(screenshot ? { screenshot } : {}),
|
|
85
|
+
...(step.actionBounds ? { actionBounds: step.actionBounds } : {}),
|
|
86
|
+
},
|
|
87
|
+
} as Span)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (run.answer) {
|
|
91
|
+
const startedAt = tick()
|
|
92
|
+
spans.push({
|
|
93
|
+
spanId: 's-answer',
|
|
94
|
+
runId,
|
|
95
|
+
kind: 'custom',
|
|
96
|
+
name: 'final answer',
|
|
97
|
+
startedAt,
|
|
98
|
+
endedAt: tick(),
|
|
99
|
+
status: 'ok',
|
|
100
|
+
attributes: { answer: run.answer, finalUrl: run.finalUrl },
|
|
101
|
+
} as Span)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return spans
|
|
105
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic self-check for browserRunToSpans —
|
|
3
|
+
* `tsx src/browser/run-to-spans.verify.ts`.
|
|
4
|
+
*
|
|
5
|
+
* Proves the run-capsule film contract: each BrowserStep becomes a kind:'tool'
|
|
6
|
+
* `browser.<action>` span; a step's screenshotPath is READ and inlined as a
|
|
7
|
+
* base64 `data:` URI on attributes.screenshot (NEVER passed through as a raw
|
|
8
|
+
* path — the regression that renders an empty film); a missing screenshot drops
|
|
9
|
+
* the image but keeps the step's span.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import type { BrowserRun } from './agent-adapter'
|
|
16
|
+
import { browserRunToSpans } from './run-to-spans'
|
|
17
|
+
|
|
18
|
+
let failures = 0
|
|
19
|
+
function check(name: string, cond: boolean, detail = ''): void {
|
|
20
|
+
if (cond) console.log(` ✓ ${name}`)
|
|
21
|
+
else {
|
|
22
|
+
failures += 1
|
|
23
|
+
console.log(` ✗ ${name} ${detail}`)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), 'run-to-spans-'))
|
|
28
|
+
// A 1x1 PNG on disk — the magic bytes make imageMime resolve to image/png.
|
|
29
|
+
const pngBytes = Buffer.from(
|
|
30
|
+
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000154a24f7d0000000049454e44ae426082',
|
|
31
|
+
'hex',
|
|
32
|
+
)
|
|
33
|
+
const shotPath = join(dir, 'step-1.png')
|
|
34
|
+
writeFileSync(shotPath, pngBytes)
|
|
35
|
+
|
|
36
|
+
const run: BrowserRun = {
|
|
37
|
+
taskId: 'usps-track',
|
|
38
|
+
driverId: 'bad',
|
|
39
|
+
steps: [
|
|
40
|
+
{ index: 1, url: 'https://tools.usps.com/', action: 'navigate', target: 'tracking input', screenshotPath: shotPath },
|
|
41
|
+
{ index: 2, url: 'https://tools.usps.com/go/TrackConfirmAction', action: 'click', target: 'Track button', actionBounds: { x: 10, y: 20, width: 80, height: 30 } },
|
|
42
|
+
],
|
|
43
|
+
finalUrl: 'https://tools.usps.com/go/TrackConfirmAction',
|
|
44
|
+
answer: 'Delivered',
|
|
45
|
+
selfReportedSuccess: true,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.log('browserRunToSpans verify:')
|
|
49
|
+
|
|
50
|
+
const spans = await browserRunToSpans(run, { startTs: 1000 })
|
|
51
|
+
|
|
52
|
+
check('one tool span per step + a trailing answer span', spans.length === 3, `got ${spans.length}`)
|
|
53
|
+
const s1 = spans[0]
|
|
54
|
+
const s2 = spans[1]
|
|
55
|
+
const answer = spans[2]
|
|
56
|
+
check('step 1 is a kind:tool browser.navigate span', s1?.kind === 'tool' && (s1 as { toolName?: string }).toolName === 'browser.navigate')
|
|
57
|
+
check('step 1 args carry the url + target', (s1 as { args?: { url?: string; target?: string } }).args?.url === 'https://tools.usps.com/' && (s1 as { args?: { target?: string } }).args?.target === 'tracking input')
|
|
58
|
+
|
|
59
|
+
const shot = (s1?.attributes?.screenshot ?? '') as string
|
|
60
|
+
check('screenshotPath was READ + inlined as a base64 data URI (not a path)', shot.startsWith('data:image/png;base64,') && shot.length > 60, shot.slice(0, 40))
|
|
61
|
+
check('the raw on-disk path is NEVER present in the span', !JSON.stringify(s1).includes(shotPath))
|
|
62
|
+
|
|
63
|
+
check('step 2 has no screenshot (none provided) but still emits a span', s2?.kind === 'tool' && s2?.attributes?.screenshot === undefined)
|
|
64
|
+
check('step 2 carries the actionBounds overlay', JSON.stringify(s2?.attributes?.actionBounds) === JSON.stringify({ x: 10, y: 20, width: 80, height: 30 }))
|
|
65
|
+
|
|
66
|
+
check('the answer span closes the film', answer?.kind === 'custom' && answer?.name === 'final answer' && (answer?.attributes as { answer?: string })?.answer === 'Delivered')
|
|
67
|
+
check('timestamps are monotonic from startTs', (s1?.startedAt ?? 0) >= 1000 && (answer?.startedAt ?? 0) > (s1?.startedAt ?? 0))
|
|
68
|
+
|
|
69
|
+
// Inline base64 screenshot (the real `bad` case — state.screenshot, no disk path):
|
|
70
|
+
// used directly, no file read.
|
|
71
|
+
const inlineRun = await browserRunToSpans({
|
|
72
|
+
taskId: 't', driverId: 'bad', finalUrl: 'https://x.test',
|
|
73
|
+
steps: [{ index: 1, url: 'https://x.test', action: 'click', screenshot: 'data:image/jpeg;base64,/9j/INLINE' }],
|
|
74
|
+
})
|
|
75
|
+
check('inline step.screenshot is carried through directly (no disk read)', (inlineRun[0]?.attributes?.screenshot as string) === 'data:image/jpeg;base64,/9j/INLINE')
|
|
76
|
+
|
|
77
|
+
// A run whose only screenshot path is unreadable: span stays, image drops (fail-soft).
|
|
78
|
+
const ghost = await browserRunToSpans({
|
|
79
|
+
taskId: 't', driverId: 'bad', finalUrl: '', steps: [{ index: 1, url: 'about:blank', action: 'navigate', screenshotPath: '/no/such/frame.png' }],
|
|
80
|
+
})
|
|
81
|
+
check('unreadable screenshot drops the image but keeps the step span', ghost.length === 1 && ghost[0]?.attributes?.screenshot === undefined)
|
|
82
|
+
|
|
83
|
+
rmSync(dir, { recursive: true, force: true })
|
|
84
|
+
|
|
85
|
+
if (failures > 0) {
|
|
86
|
+
console.log(`\n❌ ${failures} check(s) failed`)
|
|
87
|
+
process.exit(1)
|
|
88
|
+
}
|
|
89
|
+
console.log('\n✅ browserRunToSpans verified — steps → tool spans, screenshots inlined as base64, no raw paths')
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Driver-agnostic UI-REVIEWER PANEL — the design-review analogue of the
|
|
3
|
+
* browser-agent harness (./agent-adapter.ts).
|
|
4
|
+
*
|
|
5
|
+
* "More reviewers, the better": run a PANEL of UI reviewers over the same target
|
|
6
|
+
* and union their findings — #114's runLoop ui-auditor, browser-agent-driver's
|
|
7
|
+
* `bad design-audit`, a future reviewer — each a pluggable `UiReviewerAdapter`,
|
|
8
|
+
* none of them owning the verdict.
|
|
9
|
+
*
|
|
10
|
+
* Attestability is the spine, exactly as in agent-adapter.ts: a UI design verdict
|
|
11
|
+
* is SUBJECTIVE if it comes from an LLM/heuristic, so the panel NEVER lets a
|
|
12
|
+
* reviewer's own findings or headline score (e.g. a `healthScore`) be the verdict.
|
|
13
|
+
* The attestable verdict is derived ONLY from the DETERMINISTIC floor a reviewer
|
|
14
|
+
* MEASURES — axe a11y violations + WCAG AA contrast — re-applied by `judgeUiFloor`.
|
|
15
|
+
* Subjective findings are surfaced (deduped, attributed) but never gate.
|
|
16
|
+
*
|
|
17
|
+
* A reviewer that contributes no deterministic measurements adds findings to the
|
|
18
|
+
* panel but cannot, by itself, produce an attestable verdict — that is fail-loud,
|
|
19
|
+
* not a silent pass.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** What to review: a site + the routes to visit. */
|
|
23
|
+
export interface UiReviewTarget {
|
|
24
|
+
url: string
|
|
25
|
+
/** Route labels/paths to audit; defaults to a single 'home' at `url`. */
|
|
26
|
+
routes?: { label: string; url: string }[]
|
|
27
|
+
viewport?: { width: number; height: number }
|
|
28
|
+
/** Playwright storageState for authenticated review. */
|
|
29
|
+
storageState?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A SUBJECTIVE finding (LLM/heuristic). Surfaced + deduped, never the verdict. */
|
|
33
|
+
export interface UiFinding {
|
|
34
|
+
reviewerId: string
|
|
35
|
+
/** Lens/category (consistency, hierarchy, accessibility, contrast, ux-flow, …). */
|
|
36
|
+
lens: string
|
|
37
|
+
severity: 'critical' | 'major' | 'minor'
|
|
38
|
+
route: string
|
|
39
|
+
title: string
|
|
40
|
+
observation: string
|
|
41
|
+
impact?: string
|
|
42
|
+
suggestedFix?: string
|
|
43
|
+
/** CSS/Playwright selector, when the reviewer pins one — used for dedup. */
|
|
44
|
+
selector?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The DETERMINISTIC floor — MEASURED, not judged. This is the attestable layer
|
|
48
|
+
* (mirrors browser-agent-driver's `measurements`: axe + WCAG contrast). */
|
|
49
|
+
export interface DesignMeasurements {
|
|
50
|
+
/** axe a11y violations with rule id + impact. */
|
|
51
|
+
a11yViolations: { id: string; impact?: string }[]
|
|
52
|
+
/** WCAG AA contrast pass rate in [0,1] over `contrastTotalChecked` text elements. */
|
|
53
|
+
contrastAaPassRate: number
|
|
54
|
+
contrastAaFailures: number
|
|
55
|
+
contrastTotalChecked: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One reviewer's result for one route. */
|
|
59
|
+
export interface UiReviewRun {
|
|
60
|
+
reviewerId: string
|
|
61
|
+
route: string
|
|
62
|
+
findings: UiFinding[]
|
|
63
|
+
/** Present only if the reviewer measured the deterministic floor. */
|
|
64
|
+
measurements?: DesignMeasurements
|
|
65
|
+
/** The reviewer's OWN headline score (e.g. healthScore 0-100). Recorded for
|
|
66
|
+
* comparison, NEVER the verdict. */
|
|
67
|
+
selfReportedScore?: number
|
|
68
|
+
usage?: { input: number; output: number }
|
|
69
|
+
costUsd?: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The general seam every UI reviewer implements — `bad design-audit`, the #114
|
|
73
|
+
* ui-auditor, a hosted service. `review` returns one run per route. */
|
|
74
|
+
export interface UiReviewerAdapter {
|
|
75
|
+
readonly id: string
|
|
76
|
+
review(target: UiReviewTarget): Promise<UiReviewRun[]>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Thresholds for the deterministic blocking gate (defaults mirror the
|
|
80
|
+
* browser-agent-driver design-audit floor: 5+ critical a11y OR >25% contrast fail). */
|
|
81
|
+
export interface UiFloorGateConfig {
|
|
82
|
+
maxCriticalA11y?: number
|
|
83
|
+
maxContrastFailRate?: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const A11Y_CRITICAL = new Set(['critical', 'serious'])
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The ATTESTABLE verdict — derived ONLY from the deterministic floor across the
|
|
90
|
+
* runs that measured it. Ignores subjective findings AND selfReportedScore.
|
|
91
|
+
* resolved = no blocking deterministic issues; score = deterministic design-health
|
|
92
|
+
* in [0,1] (contrast pass rate × a11y cleanliness). Fail-loud if NO run measured
|
|
93
|
+
* the floor — a panel of purely-subjective reviewers cannot attest a verdict.
|
|
94
|
+
*/
|
|
95
|
+
export function judgeUiFloor(
|
|
96
|
+
runs: ReadonlyArray<UiReviewRun>,
|
|
97
|
+
cfg: UiFloorGateConfig = {},
|
|
98
|
+
): { resolved: boolean; score: number; detail: string } {
|
|
99
|
+
const maxCriticalA11y = cfg.maxCriticalA11y ?? 5
|
|
100
|
+
const maxContrastFailRate = cfg.maxContrastFailRate ?? 0.25
|
|
101
|
+
const measured = runs.filter((r) => r.measurements)
|
|
102
|
+
if (measured.length === 0) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
'judgeUiFloor: no reviewer produced deterministic measurements (axe/contrast) — ' +
|
|
105
|
+
'subjective findings are not attestable; add a reviewer with a measured floor',
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
let criticalA11y = 0
|
|
109
|
+
let worstContrastFailRate = 0
|
|
110
|
+
let passRateSum = 0
|
|
111
|
+
for (const r of measured) {
|
|
112
|
+
const m = r.measurements as DesignMeasurements
|
|
113
|
+
criticalA11y += m.a11yViolations.filter((v) => v.impact != null && A11Y_CRITICAL.has(v.impact)).length
|
|
114
|
+
const failRate = 1 - m.contrastAaPassRate
|
|
115
|
+
if (failRate > worstContrastFailRate) worstContrastFailRate = failRate
|
|
116
|
+
passRateSum += m.contrastAaPassRate
|
|
117
|
+
}
|
|
118
|
+
const blocking = criticalA11y >= maxCriticalA11y || worstContrastFailRate > maxContrastFailRate
|
|
119
|
+
const avgContrastPass = passRateSum / measured.length
|
|
120
|
+
// a11y cleanliness: 1 when no critical issues, decaying toward 0 as they approach the cap.
|
|
121
|
+
const a11yHealth = Math.max(0, 1 - criticalA11y / maxCriticalA11y)
|
|
122
|
+
const score = Number((0.6 * avgContrastPass + 0.4 * a11yHealth).toFixed(4))
|
|
123
|
+
return {
|
|
124
|
+
resolved: !blocking,
|
|
125
|
+
score,
|
|
126
|
+
detail: JSON.stringify({
|
|
127
|
+
criticalA11y,
|
|
128
|
+
worstContrastFailRate: Number(worstContrastFailRate.toFixed(4)),
|
|
129
|
+
avgContrastPass: Number(avgContrastPass.toFixed(4)),
|
|
130
|
+
measuredReviewers: measured.map((r) => r.reviewerId),
|
|
131
|
+
blocking,
|
|
132
|
+
}),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Dedup key for a finding across reviewers: same lens + route + (selector or a
|
|
137
|
+
* normalized title) is one issue, regardless of how many reviewers flagged it. */
|
|
138
|
+
function findingKey(f: UiFinding): string {
|
|
139
|
+
const anchor = f.selector?.trim() || f.title.toLowerCase().replace(/\s+/g, ' ').trim()
|
|
140
|
+
return `${f.lens.toLowerCase()}|${f.route}|${anchor}`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface UiPanelFinding extends UiFinding {
|
|
144
|
+
/** All reviewers that independently flagged this issue (consensus signal). */
|
|
145
|
+
flaggedBy: string[]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface UiPanelResult {
|
|
149
|
+
perReviewer: Record<string, UiReviewRun[]>
|
|
150
|
+
/** Deduped union of findings across reviewers, each tagged with who flagged it. */
|
|
151
|
+
findings: UiPanelFinding[]
|
|
152
|
+
/** The attestable deterministic-floor verdict (NOT a vote over findings). */
|
|
153
|
+
verdict: { resolved: boolean; score: number; detail: string }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Run a PANEL of reviewers over one target, union+dedup their subjective findings
|
|
158
|
+
* (attributed, with a consensus count), and attest the deterministic floor. A
|
|
159
|
+
* reviewer that throws is recorded as empty (its absence is visible in
|
|
160
|
+
* perReviewer), never silently merged as a pass.
|
|
161
|
+
*/
|
|
162
|
+
export async function runUiReviewerPanel(
|
|
163
|
+
target: UiReviewTarget,
|
|
164
|
+
reviewers: ReadonlyArray<UiReviewerAdapter>,
|
|
165
|
+
cfg: UiFloorGateConfig = {},
|
|
166
|
+
): Promise<UiPanelResult> {
|
|
167
|
+
if (reviewers.length === 0) throw new Error('runUiReviewerPanel: no reviewers given')
|
|
168
|
+
const settled = await Promise.all(
|
|
169
|
+
reviewers.map(async (rev) => {
|
|
170
|
+
try {
|
|
171
|
+
return { id: rev.id, runs: await rev.review(target) }
|
|
172
|
+
} catch (err) {
|
|
173
|
+
return { id: rev.id, runs: [] as UiReviewRun[], error: err instanceof Error ? err.message : String(err) }
|
|
174
|
+
}
|
|
175
|
+
}),
|
|
176
|
+
)
|
|
177
|
+
const perReviewer: Record<string, UiReviewRun[]> = {}
|
|
178
|
+
const allRuns: UiReviewRun[] = []
|
|
179
|
+
for (const s of settled) {
|
|
180
|
+
perReviewer[s.id] = s.runs
|
|
181
|
+
allRuns.push(...s.runs)
|
|
182
|
+
}
|
|
183
|
+
const byKey = new Map<string, UiPanelFinding>()
|
|
184
|
+
for (const run of allRuns) {
|
|
185
|
+
for (const f of run.findings) {
|
|
186
|
+
const key = findingKey(f)
|
|
187
|
+
const existing = byKey.get(key)
|
|
188
|
+
if (existing) {
|
|
189
|
+
if (!existing.flaggedBy.includes(f.reviewerId)) existing.flaggedBy.push(f.reviewerId)
|
|
190
|
+
} else {
|
|
191
|
+
byKey.set(key, { ...f, flaggedBy: [f.reviewerId] })
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
perReviewer,
|
|
197
|
+
findings: [...byKey.values()],
|
|
198
|
+
verdict: judgeUiFloor(allRuns, cfg),
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic self-check for the UI-reviewer panel — `tsx src/browser/ui-reviewer.verify.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Defends the same property as the browser harness: the verdict is ATTESTABLE —
|
|
5
|
+
* it comes from the measured deterministic floor (axe + contrast), never a
|
|
6
|
+
* reviewer's subjective findings or self-reported headline score.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
judgeUiFloor,
|
|
11
|
+
runUiReviewerPanel,
|
|
12
|
+
type UiReviewRun,
|
|
13
|
+
type UiReviewerAdapter,
|
|
14
|
+
type UiReviewTarget,
|
|
15
|
+
} from './ui-reviewer'
|
|
16
|
+
|
|
17
|
+
let failures = 0
|
|
18
|
+
function check(name: string, cond: boolean, detail = '') {
|
|
19
|
+
if (cond) console.log(` ✓ ${name}`)
|
|
20
|
+
else {
|
|
21
|
+
failures += 1
|
|
22
|
+
console.log(` ✗ ${name} ${detail}`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const clean: UiReviewRun = {
|
|
27
|
+
reviewerId: 'bad',
|
|
28
|
+
route: 'home',
|
|
29
|
+
findings: [],
|
|
30
|
+
measurements: { a11yViolations: [], contrastAaPassRate: 0.99, contrastAaFailures: 1, contrastTotalChecked: 120 },
|
|
31
|
+
selfReportedScore: 92,
|
|
32
|
+
}
|
|
33
|
+
// A reviewer SCREAMING success (healthScore 100) while the measured floor is blocking.
|
|
34
|
+
const liar: UiReviewRun = {
|
|
35
|
+
reviewerId: 'bad',
|
|
36
|
+
route: 'home',
|
|
37
|
+
findings: [],
|
|
38
|
+
selfReportedScore: 100,
|
|
39
|
+
measurements: {
|
|
40
|
+
a11yViolations: [
|
|
41
|
+
{ id: 'color-contrast', impact: 'serious' },
|
|
42
|
+
{ id: 'aria-required', impact: 'critical' },
|
|
43
|
+
{ id: 'label', impact: 'critical' },
|
|
44
|
+
{ id: 'button-name', impact: 'critical' },
|
|
45
|
+
{ id: 'link-name', impact: 'serious' },
|
|
46
|
+
],
|
|
47
|
+
contrastAaPassRate: 0.5,
|
|
48
|
+
contrastAaFailures: 60,
|
|
49
|
+
contrastTotalChecked: 120,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log('ui-reviewer panel verify:')
|
|
54
|
+
|
|
55
|
+
const okV = judgeUiFloor([clean])
|
|
56
|
+
check('clean floor → resolved=true, high score', okV.resolved === true && okV.score > 0.9, okV.detail)
|
|
57
|
+
|
|
58
|
+
const liarV = judgeUiFloor([liar])
|
|
59
|
+
check('self-reported 100 IGNORED — measured floor blocks → resolved=false', liarV.resolved === false, liarV.detail)
|
|
60
|
+
check('blocking score is low', liarV.score < 0.6, liarV.detail)
|
|
61
|
+
|
|
62
|
+
// fail-loud: a purely-subjective panel (no measurements) cannot attest.
|
|
63
|
+
let threw = false
|
|
64
|
+
try {
|
|
65
|
+
judgeUiFloor([{ reviewerId: 'ui-auditor', route: 'home', findings: [], selfReportedScore: 80 }])
|
|
66
|
+
} catch {
|
|
67
|
+
threw = true
|
|
68
|
+
}
|
|
69
|
+
check('no measured floor → throws (subjective findings are not attestable)', threw)
|
|
70
|
+
|
|
71
|
+
// panel dedup + consensus across two reviewers flagging the same issue.
|
|
72
|
+
const finding = (reviewerId: string) => ({
|
|
73
|
+
reviewerId,
|
|
74
|
+
lens: 'contrast',
|
|
75
|
+
severity: 'major' as const,
|
|
76
|
+
route: 'home',
|
|
77
|
+
title: 'Low contrast on primary CTA',
|
|
78
|
+
observation: 'x',
|
|
79
|
+
selector: 'button.cta',
|
|
80
|
+
})
|
|
81
|
+
const revA: UiReviewerAdapter = { id: 'bad', async review() { return [{ ...clean, reviewerId: 'bad', findings: [finding('bad')] }] } }
|
|
82
|
+
const revB: UiReviewerAdapter = { id: 'ui-auditor', async review() { return [{ reviewerId: 'ui-auditor', route: 'home', findings: [finding('ui-auditor')] }] } }
|
|
83
|
+
const target: UiReviewTarget = { url: 'https://example.com' }
|
|
84
|
+
const panel = await runUiReviewerPanel(target, [revA, revB])
|
|
85
|
+
check('panel dedups the same issue across reviewers to ONE finding', panel.findings.length === 1, `got ${panel.findings.length}`)
|
|
86
|
+
check('deduped finding records BOTH reviewers (consensus)', (panel.findings[0]?.flaggedBy ?? []).length === 2, JSON.stringify(panel.findings[0]?.flaggedBy))
|
|
87
|
+
check('panel verdict comes from the measured reviewer (bad), resolved=true', panel.verdict.resolved === true, panel.verdict.detail)
|
|
88
|
+
check('perReviewer records both reviewers', Object.keys(panel.perReviewer).length === 2)
|
|
89
|
+
|
|
90
|
+
if (failures > 0) {
|
|
91
|
+
console.log(`\n❌ ${failures} check(s) failed`)
|
|
92
|
+
process.exit(1)
|
|
93
|
+
}
|
|
94
|
+
console.log('\n✅ UI-reviewer panel verified — verdict is the measured floor, self-report ignored, findings deduped across reviewers')
|