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>
This commit is contained in:
cabinw
2026-09-14 14:00:33 +08:00
co-authored by Claude Opus 5
parent bc70dbfb7b
commit c760c23fd1
4 changed files with 783 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
#!/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');
}