Files
gitea-axi/bench/run.ts
alexion 0b0186674e feat(bench): add --skill to override the gitea-axi arm's bundled skill
bench:run gains a --skill <path> flag, threaded into the existing
BuildArmOptions.skillPath, so a skill variant can be A/B'd against the
shipped SKILL.md with the same binary and harness — the mechanism used to
validate the find-then-act rewrite — without mutating the shipped file.
2026-07-19 09:46:27 -04:00

285 lines
11 KiB
TypeScript

// The maintainer-facing run-loop command: run one chosen benchmark cell on demand.
//
// This is the entry point the maintainer invokes to spend the token budget
// available at a given moment on exactly one `(arm, task)` cell. It parses the
// selection, resolves live host access through gitea-axi's own credential path,
// resolves the scored suite against the host's self-review support, and drives the
// run loop (run-loop.ts) — which in turn drives the single-cell runner and the
// append-only sample store built in earlier slices. No orchestration is
// reimplemented here.
//
// The command is bench-internal (bench/ is excluded from the published package)
// and is executed with a TypeScript-aware runner; see `npm run bench:run`.
//
// The argument parser (`parseRunArgs`) is the pure, unit-tested seam. The live
// wiring in `runBenchCommand` is a live boundary — it resolves real credentials
// and drives the real host and Agent SDK — so, like the seed and runner smoke
// tiers, it is validated by running it rather than by mocked unit tests.
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import type { CliDeps } from "../src/deps.js";
import { liveBenchHost } from "./host.js";
import type { Arm } from "./result.js";
import { DEFAULT_TRIALS, REPORTING_FLOOR, runCells, type RunCellsResult } from "./run-loop.js";
import { resolveBenchAccess } from "./seed.js";
import { detectSelfReviewSupport } from "./self-review.js";
import { sdkAgentDriver } from "./sdk-driver.js";
import { createSampleStore, DEFAULT_STORE_ROOT } from "./store.js";
import { buildScoredSuite } from "./task-suite.js";
// Re-exported so this command's existing importers keep resolving it from here;
// its single source of truth is now the store, which owns the default root.
export { DEFAULT_STORE_ROOT };
/** The default turn cap a run is bounded by when not overridden. */
export const DEFAULT_TURN_CAP = 40;
/** The default wall-clock backstop (ms) a run is bounded by when not overridden. */
export const DEFAULT_WALL_CLOCK_MS = 300_000;
/** Environment variable naming the tea login the benchmark authenticates through. */
export const LOGIN_ENV = "GITEA_AXI_BENCH_LOGIN";
/** The four arms a cell may select. */
export const ARMS: readonly Arm[] = ["gitea-axi", "tea", "gitea-mcp", "raw-api"];
/** A fully-resolved cell selection and run configuration. */
export interface RunArgs {
arm: Arm;
taskId: string;
/** Trials to run this sitting; defaults to {@link DEFAULT_TRIALS}. */
trials: number;
/** The tea login the benchmark authenticates through. */
login: string;
turnCap: number;
wallClockMs: number;
storeRoot: string;
/** Optional bundled-skill override for the gitea-axi arm; defaults to the shipped SKILL.md. */
skillPath?: string;
}
/** The parse outcome: a request for help, or a resolved configuration to run. */
export type ParsedRunArgs = { help: true } | ({ help: false } & RunArgs);
/** The value-taking flags the command understands; anything else is rejected. */
const KNOWN_FLAGS = new Set([
"arm",
"task",
"trials",
"login",
"turn-cap",
"wall-clock-ms",
"store",
"skill",
]);
/** A usage error, surfaced to the maintainer with the offending detail. */
function usage(detail: string): Error {
return new Error(`${detail}\n\nUsage: bench:run --arm <arm> --task <task-id> [--login <name>] [--trials <n>]`);
}
/** Parse a flag's value as a positive integer, rejecting anything else. */
function positiveInt(value: string, flag: string): number {
if (!/^\d+$/.test(value) || Number(value) < 1) {
throw usage(`--${flag} must be a positive integer, got "${value}"`);
}
return Number(value);
}
/**
* Parse the run-loop command's argv into a resolved configuration, applying
* defaults ({@link DEFAULT_TRIALS} trials, {@link DEFAULT_TURN_CAP} turn cap,
* {@link DEFAULT_WALL_CLOCK_MS} backstop, {@link DEFAULT_STORE_ROOT} store, and the
* login from {@link LOGIN_ENV}). Throws a usage error when a required selection is
* missing or a value is malformed.
*/
export function parseRunArgs(
argv: string[],
env: Record<string, string | undefined>,
): ParsedRunArgs {
const flags = new Map<string, string>();
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index] as string;
if (token === "--help" || token === "-h") {
return { help: true };
}
if (!token.startsWith("--")) {
throw usage(`unexpected argument "${token}"`);
}
const equals = token.indexOf("=");
const name = equals === -1 ? token.slice(2) : token.slice(2, equals);
if (!KNOWN_FLAGS.has(name)) {
throw usage(`unknown flag "--${name}"`);
}
let value: string | undefined;
if (equals === -1) {
value = argv[index + 1];
if (value === undefined || value.startsWith("--")) {
throw usage(`flag --${name} needs a value`);
}
index += 1;
} else {
value = token.slice(equals + 1);
}
flags.set(name, value);
}
const arm = flags.get("arm");
if (arm === undefined) {
throw usage("--arm <arm> is required");
}
if (!ARMS.includes(arm as Arm)) {
throw usage(`--arm must be one of ${ARMS.join(", ")}, got "${arm}"`);
}
const taskId = flags.get("task");
if (taskId === undefined) {
throw usage("--task <task-id> is required");
}
const login = flags.get("login") ?? env[LOGIN_ENV];
if (login === undefined || login.length === 0) {
throw usage(`--login <name> is required (or set ${LOGIN_ENV})`);
}
const trials = flags.has("trials") ? positiveInt(flags.get("trials") as string, "trials") : DEFAULT_TRIALS;
const turnCap = flags.has("turn-cap")
? positiveInt(flags.get("turn-cap") as string, "turn-cap")
: DEFAULT_TURN_CAP;
const wallClockMs = flags.has("wall-clock-ms")
? positiveInt(flags.get("wall-clock-ms") as string, "wall-clock-ms")
: DEFAULT_WALL_CLOCK_MS;
return {
help: false,
arm: arm as Arm,
taskId,
trials,
login,
turnCap,
wallClockMs,
storeRoot: flags.get("store") ?? DEFAULT_STORE_ROOT,
...(flags.has("skill") ? { skillPath: flags.get("skill") } : {}),
};
}
/** The help text printed for `--help` / `-h`. */
const HELP_TEXT = `bench:run — run one benchmark cell on demand.
Runs a single (arm, task) cell for a batch of trials against the live Gitea host,
appending each scored sample to the store. Re-running a cell deepens it: new trials
append rather than overwrite, so a cell's sample size can be grown across sittings.
Usage:
npm run bench:run -- --arm <arm> --task <task-id> [options]
Required:
--arm <arm> One of: ${ARMS.join(", ")}
--task <task-id> A scored-suite task id (an unknown id prints the available ids)
Options:
--login <name> tea login to authenticate through (default: $${LOGIN_ENV})
--trials <n> Trials to run this sitting (default: ${DEFAULT_TRIALS})
--turn-cap <n> Per-run turn cap (default: ${DEFAULT_TURN_CAP})
--wall-clock-ms <n> Per-run wall-clock backstop in ms (default: ${DEFAULT_WALL_CLOCK_MS})
--store <dir> Sample store root (default: ${DEFAULT_STORE_ROOT})
--skill <path> Override the gitea-axi arm's bundled skill (default: shipped SKILL.md)
-h, --help Show this help`;
/** Render the run-loop tally into the lines printed after a sitting. */
function summarize(result: RunCellsResult, storeRoot: string): string[] {
const floorNote = result.meetsFloor
? `meets the reporting floor of ${REPORTING_FLOOR}`
: `below the reporting floor of ${REPORTING_FLOOR} — deepen this cell before reporting`;
return [
`Cell (${result.arm}, ${result.taskId}): ${result.recorded} recorded, ${result.invalid} invalid this sitting.`,
`Samples: ${result.priorSamples}${result.totalSamples} (${floorNote}).`,
`Store: ${storeRoot}`,
];
}
/**
* Run one chosen cell on demand: resolve live host access, resolve the scored
* suite against the host's self-review support, select the task, and drive the run
* loop. This is the command's live boundary — it authenticates and drives the real
* host and Agent SDK — so it is validated by running it, not by mocked unit tests
* (the pure `parseRunArgs` seam is the unit-tested part). Returns a process exit
* code and prints progress and the final tally through `out`.
*/
export async function runBenchCommand(
argv: string[],
deps: CliDeps,
out: (line: string) => void,
): Promise<number> {
const parsed = parseRunArgs(argv, deps.env);
if (parsed.help) {
out(HELP_TEXT);
return 0;
}
const access = await resolveBenchAccess(deps, parsed.login);
// The two review tasks are approve/request-changes or comment reviews depending
// on what the host permits, so the suite is resolved against a live probe once
// before selecting the task (see task-suite.ts and self-review.ts).
out(`Probing self-review support on ${new URL(access.apiUrl).host}`);
const selfReviewPermitted = await detectSelfReviewSupport(access);
const suite = buildScoredSuite({ selfReviewPermitted });
const task = suite.find((candidate) => candidate.id === parsed.taskId);
if (task === undefined) {
out(`No scored task with id "${parsed.taskId}". Available task ids:`);
for (const candidate of suite) {
out(` ${candidate.id} (${candidate.tier})`);
}
return 1;
}
const store = createSampleStore(parsed.storeRoot);
const binRoot = mkdtempSync(join(tmpdir(), "bench-run-bin-"));
out(`Running ${parsed.trials} trial(s) of cell (${parsed.arm}, ${task.id})…`);
try {
const result = await runCells({
arm: parsed.arm,
task,
trials: parsed.trials,
access,
host: liveBenchHost(access),
// Every arm runs on the driver's single fixed model per the spec, so the
// comparison measures the tool rather than the model; the command exposes
// no per-cell model override that could break that invariant.
driver: sdkAgentDriver(),
store,
bounds: { turnCap: parsed.turnCap, wallClockMs: parsed.wallClockMs },
build: { binRoot, ...(parsed.skillPath !== undefined ? { skillPath: parsed.skillPath } : {}) },
});
for (const line of summarize(result, parsed.storeRoot)) {
out(line);
}
return 0;
} finally {
rmSync(binRoot, { recursive: true, force: true });
}
}
/** Entry point: parse argv, run the command, and set the process exit code. */
export async function main(): Promise<void> {
const deps: CliDeps = { env: process.env, cwd: process.cwd(), globals: {} };
try {
process.exitCode = await runBenchCommand(
process.argv.slice(2),
deps,
(line) => process.stdout.write(`${line}\n`),
);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
}
// Run only when executed directly (e.g. `tsx bench/run.ts`), not when imported by
// a test. Under a TypeScript runner argv[1] is this file's own path.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}