Files
HexTrails/scripts/bench.mjs
cabinwandClaude Opus 5 c760c23fd1 feat(bench): agent bench session core and CLI
Let any agent with a shell play a level the way a person does: look at the
board, decide what to tap or which way to turn, read the feedback, decide
again. Every step carries the agent's own reason, so a human can watch and
judge the level from how it was played.

observe() returns what the eye sees — each piece's drawn route, which cells it
crosses, who is sitting on it, and who it brushes past — never "which pieces
can leave". act() goes through the real Game rules; metrics() scores the run
afterwards with the solver.

Pieces are wide, so a route with nobody standing on it can still be blocked by
a neighbour the curve hugs. That is invisible in a cell listing, so every
obstacle is reported, on-route and grazing alike: reporting only the leading
one hid the rest, and in 113 of 581 such cases clearing the blocker sprang a
collision the agent had never been shown.

AGENT.md is the single file an agent reads — rules, board reading, commands,
rating scale, worked example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:00:33 +08:00

174 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Agent bench CLI. One command per step so any agent with a shell can play.
// Agents are given labs/bench/AGENT.md and need nothing else.
// node scripts/bench.mjs list [--set=levels/prototypes/d5.json]
// node scripts/bench.mjs start --model=NAME [--agent='运行环境 · 模式'] [--level=N] [--run=NAME] [--persona=…] [--set=…] [--peek] [--budget=80] [--no-rules]
// node scripts/bench.mjs observe --run=NAME [--format=json|full]
// node scripts/bench.mjs act --run=NAME tap t3, tap t5, rotate right [--note=…] [--quiet]
// node scripts/bench.mjs rate --run=NAME --json='{"clarity":3,…}'
// node scripts/bench.mjs status|metrics --run=NAME [--json]
// node scripts/bench.mjs open [--run=NAME] # 起服务、打印/打开观察页
// node scripts/bench.mjs rules [--peek]
// Runs live in bench/runs/NAME.json; the observer page polls that file.
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
import { createConnection } from 'node:net';
import { resolve } from 'node:path';
import { createRun, observe, actMany, rate, metrics, rulesText, RATING_QUESTIONS, describeAction, describeResult } from '../src/bench.js';
const RUNS = resolve('bench/runs');
const argv = process.argv.slice(2);
const command = argv[0];
const flags = {}, positional = [];
for (const a of argv.slice(1)) {
if (a.startsWith('--')) { const [k, ...v] = a.slice(2).split('='); flags[k] = v.length ? v.join('=') : true; }
else positional.push(a);
}
const fail = message => { console.error(message); process.exit(1); };
// text = routes only (default, what a person sees); full = also the per-cell rail table; json = machine.
const fmt = () => flags.format === 'json' ? 'json' : flags.format === 'full' || flags.full ? 'full' : 'text';
const loadSet = path => {
const raw = JSON.parse(readFileSync(resolve(path), 'utf8'));
return Array.isArray(raw) ? raw : Array.isArray(raw.levels) ? raw.levels : Object.values(raw).filter(l => l && l.cells);
};
const runPath = name => resolve(RUNS, `${name}.json`);
const PORT = Number(process.env.PORT || 4173);
const slug = s => String(s).replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'agent';
// Name the run so the agent never has to invent one: model-L3-tapper, then -2, -3…
function autoRunName(model, level, persona) {
const base = `${slug(model)}-L${level}-${slug(persona)}`;
if (!existsSync(runPath(base))) return base;
for (let n = 2; n < 99; n++) if (!existsSync(runPath(`${base}-${n}`))) return `${base}-${n}`;
return `${base}-${Date.now()}`;
}
const portOpen = () => new Promise(done => {
const socket = createConnection({ port: PORT, host: '127.0.0.1' });
const finish = ok => { socket.destroy(); done(ok); };
socket.setTimeout(400);
socket.on('connect', () => finish(true));
socket.on('error', () => finish(false));
socket.on('timeout', () => finish(false));
});
// Bring the observer page within reach: start the static server if it is not
// already up, then print (and on macOS open) the URL for this run. Returns
// null in a portable pack, which ships no page and no server — a URL there
// would point at whatever else happens to hold the port.
const OBSERVER = resolve('labs/bench/index.html'), SERVER = resolve('scripts/serve.mjs');
async function observerUrl(run, { open = false } = {}) {
if (!existsSync(OBSERVER) || !existsSync(SERVER)) return null;
const url = `http://localhost:${PORT}/labs/bench/index.html${run ? `?run=${encodeURIComponent(run)}&live=1` : ''}`;
if (!(await portOpen())) {
const server = spawn(process.execPath, [SERVER], { detached: true, stdio: 'ignore' });
server.unref();
for (let i = 0; i < 20 && !(await portOpen()); i++) await new Promise(r => setTimeout(r, 150));
}
if (open && process.platform === 'darwin') spawnSync('open', [url], { stdio: 'ignore' });
return url;
}
const loadRun = () => {
if (!flags.run) fail('需要 --run=NAME');
if (!existsSync(runPath(flags.run))) fail(`没有这局:${flags.run}(先 start)`);
return JSON.parse(readFileSync(runPath(flags.run), 'utf8'));
};
const saveRun = runObj => {
mkdirSync(RUNS, { recursive: true });
runObj.updatedAt = new Date().toISOString();
writeFileSync(runPath(runObj.run), JSON.stringify(runObj));
const indexPath = resolve(RUNS, 'index.json');
const index = existsSync(indexPath) ? JSON.parse(readFileSync(indexPath, 'utf8')) : [];
const entry = { run: runObj.run, level: runObj.level.id, persona: runObj.persona, model: runObj.model, peek: runObj.peek, status: runObj.status, steps: runObj.steps.length, updatedAt: runObj.updatedAt };
const at = index.findIndex(e => e.run === runObj.run);
if (at >= 0) index[at] = entry; else index.push(entry);
writeFileSync(indexPath, JSON.stringify(index, null, 1));
};
if (command === 'list') {
const set = flags.set || 'levels/prototypes/d5.json';
const levels = loadSet(set);
console.log(`${set} · ${levels.length} 关`);
levels.forEach((l, i) => console.log(` ${String(i + 1).padStart(3)} ${l.id} ${l.cells.length} 格 · ${l.tiles.length} 枚${l.prototype ? ` · 锁住 ${Math.round(l.prototype.stats.lockedShare * 100)}% · 最少 ${l.prototype.stats.rotations} 转` : ''}`));
} else if (command === 'rules') {
console.log(rulesText(!!flags.peek));
} else if (command === 'start') {
const levels = loadSet(flags.set || 'levels/prototypes/d5.json');
const wanted = flags.level ?? 1;
const level = levels.find(l => l.id === wanted) || levels[Number(wanted) - 1];
if (!level) fail(`找不到关卡 ${wanted}(本组共 ${levels.length} 关,用 list 查看)`);
const persona = flags.persona || 'tapper';
const model = flags.model || process.env.BENCH_MODEL || 'unknown';
if (model === 'unknown') console.warn('提示:没有 --model,本局记为 unknown;填上你的模型标识才能按模型比较。');
const name = flags.run ? String(flags.run) : autoRunName(model, levels.indexOf(level) + 1, persona);
if (!/^[\w.-]+$/.test(name)) fail('run 名只能用字母、数字、_ - .');
if (existsSync(runPath(name)) && !flags.force) fail(`${name} 已存在;换个名字或加 --force`);
const agent = flags.agent ? String(flags.agent) : '';
const runObj = createRun({ level, run: name, persona, model, agent, peek: !!flags.peek, budget: Number(flags.budget || 80), startedAt: new Date().toISOString() });
saveRun(runObj);
const url = await observerUrl(name, { open: !!flags.open });
console.log(`开始 ${runObj.run} · 关卡 ${level.id} · 人设 ${runObj.persona} · 模型 ${runObj.model}${runObj.agent ? ` · ${runObj.agent}` : ''} · 预算 ${runObj.budget} 步 · peek ${runObj.peek ? '开' : '关'}`);
if (!runObj.agent) console.warn('提示:没有 --agent,观察页横幅只会显示模型名。填上运行环境和模式,例如 --agent="Claude Code · ultracode"。');
if (url) console.log(`观察页(人类实时观看):${url}`);
console.log(`后续命令都用 --run=${runObj.run}`);
console.log('');
// --no-rules: an agent that already read AGENT.md skips the rules block.
if (!flags['no-rules']) { console.log(rulesText(runObj.peek)); console.log(''); }
console.log(observe(runObj, { format: fmt() }));
} else if (command === 'open') {
const url = await observerUrl(flags.run ? String(flags.run) : null, { open: flags.open !== 'false' });
console.log(url || '这个副本没有观察页(便携包);把 runs/ 交回原仓库用 bench-import.mjs 导入后再看。');
} else if (command === 'observe') {
const runObj = loadRun();
const view = observe(runObj, { format: fmt() });
console.log(typeof view === 'string' ? view : JSON.stringify(view, null, 1));
} else if (command === 'act') {
const runObj = loadRun();
// One action, or several separated by commas: `tap t4, tap t5, rotate right`.
// A chain stops at the first tap that fails and after any turn.
const parse = words => {
const [kind, ...rest] = words;
if (kind === 'tap' || kind === 'peek') return { type: kind, id: rest[0] };
if (kind === 'rotate') return { type: 'rotate', direction: ['right', 'r', '1', '+1', '右'].includes(rest[0]) ? 1 : ['left', 'l', '-1', '左'].includes(rest[0]) ? -1 : 0 };
if (kind === 'undo') return { type: 'undo' };
if (kind === 'giveup') return { type: 'giveup', reason: rest.join(' ') };
fail('act 需要:tap ID | rotate left|right | undo | peek ID | giveup 原因;多个动作用逗号隔开');
};
const groups = positional.join(' ').split(',').map(s => s.trim()).filter(Boolean).map(s => parse(s.split(/\s+/)));
if (!groups.length) fail('act 需要一个动作');
if (groups.length > 8) fail('一次最多连做 8 个动作');
const { outcomes, stopped } = actMany(runObj, groups, { note: flags.note ? String(flags.note) : '' });
saveRun(runObj);
for (const out of outcomes) console.log(`${describeAction(out.step?.action || out.rejected?.action)} → ${out.feedback}`);
if (stopped) console.log(`(${stopped})`);
if (!flags.quiet && runObj.status === 'playing') { console.log(''); console.log(observe(runObj, { format: fmt() === 'json' ? 'text' : fmt() })); }
if (runObj.status !== 'playing') console.log(`\n本局结束:${runObj.status}。请用 rate 提交评分。`);
if (flags.json) console.log(JSON.stringify({ outcomes, stopped }));
} else if (command === 'rate') {
const runObj = loadRun();
let answers;
try { answers = JSON.parse(flags.json || '{}'); } catch { fail('--json 需要合法 JSON'); }
const rating = rate(runObj, answers);
saveRun(runObj);
console.log('已记录评分:' + JSON.stringify(rating));
const missing = RATING_QUESTIONS.filter(q => rating[q.key] === null).map(q => q.key);
if (missing.length) console.log('未填:' + missing.join(', '));
// Close the loop for the agent: the numbers it should report, and the link.
const m = metrics(runObj);
const secs = ms => ms == null ? '—' : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${ms} ms`;
console.log(`本局:${m.status} · ${m.steps} 步 · ${m.decisions} 次决策 · 空点 ${m.deadTaps}(擦碰 ${m.grazeDeadTaps})· 旋转 ${m.rotations}/${m.minimumRotations ?? '?'} · 耗时 ${secs(m.timing.totalMs)}`);
const replayUrl = await observerUrl(runObj.run);
if (replayUrl) console.log(`回放:${replayUrl}`);
} else if (command === 'status') {
const runObj = loadRun();
console.log(`${runObj.run} · ${runObj.level.id} · ${runObj.status} · ${runObj.steps.length}/${runObj.budget} 步 · 人设 ${runObj.persona} · 模型 ${runObj.model}`);
for (const s of runObj.steps) console.log(` ${String(s.n).padStart(3)} ${describeAction(s.action).padEnd(10)} → ${describeResult(s.result)}${s.note ? ` 「${s.note}」` : ''}`);
} else if (command === 'metrics') {
const runObj = loadRun();
const m = metrics(runObj);
if (flags.json) console.log(JSON.stringify(m, null, 1));
else console.log(Object.entries(m).filter(([k]) => k !== 'rating').map(([k, v]) => `${k}: ${typeof v === 'number' ? Number(v.toFixed(2)) : v}`).join('\n') + (m.rating ? '\nrating: ' + JSON.stringify(m.rating) : ''));
} else if (command === 'runs') {
const dir = existsSync(RUNS) ? readdirSync(RUNS).filter(f => f.endsWith('.json') && f !== 'index.json') : [];
for (const f of dir) { const r = JSON.parse(readFileSync(resolve(RUNS, f), 'utf8')); console.log(`${r.run.padEnd(28)} ${r.level.id.padEnd(34)} ${r.status.padEnd(9)} ${String(r.steps.length).padStart(3)} 步 ${r.persona}/${r.model}`); }
} else {
fail('用法:node scripts/bench.mjs list|start|act|observe|rate|status|metrics|runs|open|rules …\n完整说明(给 Agent 读的那一份):labs/bench/AGENT.md');
}