agent-trials 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +117 -2
- package/dist/cli.js +144 -0
- package/dist/trials.js +291 -0
- package/package.json +38 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Prince Jain
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,118 @@
|
|
|
1
|
-
#
|
|
1
|
+
# agent-trials
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Interview your coding agent before you ship with it.
|
|
4
|
+
|
|
5
|
+
`agent-trials` runs the same task suite through every coding agent CLI you have installed and prints a scoreboard: who passed, how long they took, what they spent. Each task gets a temporary working copy. Commands still run on your host.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## why
|
|
10
|
+
|
|
11
|
+
Public benchmarks score agents on tasks someone else picked. Your repo is not that benchmark. The question that matters before you let an agent loose on a codebase: does it fix YOUR bugs, in YOUR stack, at a sane price? Write the tasks once, run every candidate, keep the receipts.
|
|
12
|
+
|
|
13
|
+
## execution safety
|
|
14
|
+
|
|
15
|
+
A temporary working copy is not a security sandbox. Agents and suite commands run with your host permissions and environment; built-in Claude and Gemini commands disable approval prompts. Repository dotfiles and secrets may be copied. Use only trusted suites in a disposable VM with no sensitive files or credentials. Do not run this on a workstation containing private data.
|
|
16
|
+
|
|
17
|
+
## install
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npx agent-trials init # writes trials.json in your repo
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Works straight from GitHub too: `npx github:princejain756/agent-trials`.
|
|
24
|
+
|
|
25
|
+
## use
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
agent-trials init # starter suite
|
|
29
|
+
agent-trials doctor # which agent CLIs are installed
|
|
30
|
+
agent-trials run # run the suite, print the board, write TRIALS.md
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## trials.json
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"verify": "npm test",
|
|
38
|
+
"verifyPaths": ["verify"],
|
|
39
|
+
"timeoutSec": 600,
|
|
40
|
+
"tasks": [
|
|
41
|
+
{
|
|
42
|
+
"id": "fix-cache",
|
|
43
|
+
"prompt": "The cache in src/cache.js never expires entries. Add a ttl option. Keep the API.",
|
|
44
|
+
"verify": "node checks/cache-ttl.mjs"
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"agents": {
|
|
48
|
+
"sonnet": { "run": ["claude", "-p", "{prompt}", "--dangerously-skip-permissions", "--model", "sonnet"] }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Each cell (agent x task) works like this:
|
|
54
|
+
|
|
55
|
+
1. your repo, minus `node_modules` and `.git`, is copied to a temporary working copy
|
|
56
|
+
2. optional `setup` command runs once (`npm ci` and friends)
|
|
57
|
+
3. the agent runs headless on the task prompt, using the selected CLI flags, timeout-guarded
|
|
58
|
+
4. checker paths are restored from your repo, so the agent cannot rewrite its own test
|
|
59
|
+
5. your `verify` command runs in the working copy; exit 0 means pass
|
|
60
|
+
|
|
61
|
+
Step 4 restores `verifyPaths` from the suite, or a top-level `verify/` dir when `verifyPaths` is not set. A verify command that reads mutable state outside those paths can still be gamed; put your checkers in `verify/` or list them.
|
|
62
|
+
|
|
63
|
+
Builtin agents: `claude`, `codex`, `gemini`. Custom agents are one argv array away: anything installed, any flags, `{prompt}` gets replaced with the task prompt.
|
|
64
|
+
|
|
65
|
+
## a real run
|
|
66
|
+
|
|
67
|
+
`examples/tinylib` is a 3-function library with planted bugs. Same 3 tasks, 3 model variants of the same agent CLI (`claude --model`):
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
agent-trials · 3 tasks · 3 agents
|
|
71
|
+
|
|
72
|
+
opus sonnet haiku
|
|
73
|
+
fix-clamp pass 141.3s pass 44.2s pass 60.9s
|
|
74
|
+
divide-guard fail 96.1s pass 72.1s pass 118.6s
|
|
75
|
+
slug-test pass 253.4s fail 124.4s pass 50s
|
|
76
|
+
|
|
77
|
+
score 2/3 2/3 3/3
|
|
78
|
+
median 141.3s 72.1s 60.9s
|
|
79
|
+
cost $1.11 $1.10 $2.06
|
|
80
|
+
|
|
81
|
+
winner: haiku (3/3, median 60.9s)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Read this board with caveats. `opus`, `sonnet`, and `haiku` are requested CLI aliases; that run predates model-identity capture (`--json` now records `modelUsed` per cell when the agent CLI reports it). One run of three toy tasks is a smoke test, not a comparative benchmark. Costs are CLI-reported, not invoice costs.
|
|
85
|
+
|
|
86
|
+
What the board caught:
|
|
87
|
+
|
|
88
|
+
- haiku went 3/3 with the fastest median while spending the most: $2.06. Pass rate and price did not move together.
|
|
89
|
+
- in one earlier run, the agent asked a clarifying question ("should -0 count as zero?") into an empty terminal, then exited 0 having changed nothing. One observed example, not a general claim. This showed why exit code alone cannot be the verdict; the verify command is what counts.
|
|
90
|
+
- sonnet failed slug-test.
|
|
91
|
+
|
|
92
|
+
Full results with per-cell costs: [LEADERBOARD.md](LEADERBOARD.md)
|
|
93
|
+
|
|
94
|
+
## limits, honestly
|
|
95
|
+
|
|
96
|
+
- pass/fail is your verify command's exit code. The tool cannot judge taste, only outcomes.
|
|
97
|
+
- runs spend your agent quota and your auth; costs are reported when the agent's JSON output includes them (claude does)
|
|
98
|
+
- agent CLIs must be installed and logged in
|
|
99
|
+
- macOS/Linux; the working copy and verify assume `sh`. Windows untested
|
|
100
|
+
- scoring is binary per task. No partial credit for "almost fixed it"
|
|
101
|
+
|
|
102
|
+
## flags
|
|
103
|
+
|
|
104
|
+
| flag | default | what it does |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `--suite <file>` | `trials.json` | suite file to run |
|
|
107
|
+
| `--agents a,b` | all known | restrict the agent list |
|
|
108
|
+
| `--root <dir>` | `.` | the repo being tested |
|
|
109
|
+
| `--jobs <n>` | `2` | parallel agent x task cells |
|
|
110
|
+
| `--out <file>` | `TRIALS.md` | markdown report path, `none` skips |
|
|
111
|
+
| `--json <file>` | - | raw per-cell results |
|
|
112
|
+
| `--keep` | off | keep working copies under /tmp for debugging |
|
|
113
|
+
|
|
114
|
+
## why the suite format is the point
|
|
115
|
+
|
|
116
|
+
Tasks are plain JSON and `verify` is plain shell, so a suite doubles as a smoke-test pack for humans. Commit `trials.json` next to the code it describes. When you swap agents or bump model versions, rerun and diff the board.
|
|
117
|
+
|
|
118
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { agentInstalled, BUILTIN_AGENTS, loadSuite, renderBoard, renderMarkdown, resolveAgents, runSuite, writeMarkdown, verdictOf, } from "./trials.js";
|
|
5
|
+
const VERSION = "1.0.1";
|
|
6
|
+
const HELP = `agent-trials ${VERSION}
|
|
7
|
+
Interview your coding agent before you ship with it.
|
|
8
|
+
|
|
9
|
+
usage:
|
|
10
|
+
agent-trials init write a starter trials.json
|
|
11
|
+
agent-trials doctor check which agent CLIs are installed
|
|
12
|
+
agent-trials run run the suite and print the scoreboard
|
|
13
|
+
|
|
14
|
+
run flags:
|
|
15
|
+
--suite <file> suite file (default: trials.json)
|
|
16
|
+
--agents a,b only these agents (default: all known + custom)
|
|
17
|
+
--root <dir> repo to run on (default: cwd)
|
|
18
|
+
--jobs <n> parallel cells (default: 2)
|
|
19
|
+
--out <file> markdown report path (default: TRIALS.md, "none" skips)
|
|
20
|
+
--json <file> also write raw results JSON
|
|
21
|
+
--keep keep working copies for debugging
|
|
22
|
+
`;
|
|
23
|
+
const STARTER_SUITE = {
|
|
24
|
+
name: "my repo trials",
|
|
25
|
+
setup: "",
|
|
26
|
+
verify: "npm test",
|
|
27
|
+
timeoutSec: 600,
|
|
28
|
+
tasks: [
|
|
29
|
+
{
|
|
30
|
+
id: "example-task",
|
|
31
|
+
prompt: "Describe what this repo does in one sentence, then make the smallest possible improvement to the README.",
|
|
32
|
+
verify: "git diff --quiet || true",
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
agents: {},
|
|
36
|
+
};
|
|
37
|
+
function parseArgs(argv) {
|
|
38
|
+
const out = {};
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const a = argv[i];
|
|
41
|
+
if (a.startsWith("--")) {
|
|
42
|
+
const key = a.slice(2);
|
|
43
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
|
|
44
|
+
out[key] = argv[++i];
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
out[key] = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
function fail(msg) {
|
|
54
|
+
console.error(`agent-trials: ${msg}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
async function main() {
|
|
58
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
59
|
+
const flags = parseArgs(rest);
|
|
60
|
+
if (!cmd || flags.help || cmd === "help") {
|
|
61
|
+
console.log(HELP);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (cmd === "--version" || cmd === "-v" || flags.version) {
|
|
65
|
+
console.log(VERSION);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (cmd === "init") {
|
|
69
|
+
const file = typeof flags.suite === "string" ? flags.suite : "trials.json";
|
|
70
|
+
if (existsSync(file) && !flags.force) {
|
|
71
|
+
fail(`${file} already exists (use --force to overwrite)`);
|
|
72
|
+
}
|
|
73
|
+
writeFileSync(file, JSON.stringify(STARTER_SUITE, null, 2) + "\n");
|
|
74
|
+
console.log(`wrote ${file}\n\nedit tasks[] with real work for your repo, then:\n agent-trials run`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (cmd === "doctor") {
|
|
78
|
+
const suiteFile = typeof flags.suite === "string" ? flags.suite : "trials.json";
|
|
79
|
+
const suite = existsSync(suiteFile)
|
|
80
|
+
? loadSuite(suiteFile)
|
|
81
|
+
: { tasks: [] };
|
|
82
|
+
const agents = resolveAgents(typeof flags.agents === "string" ? flags.agents.split(",") : undefined, suite);
|
|
83
|
+
console.log("agent-trials doctor\n");
|
|
84
|
+
for (const [name, spec] of Object.entries(agents)) {
|
|
85
|
+
const bin = spec.argv[0];
|
|
86
|
+
const ok = agentInstalled(bin);
|
|
87
|
+
const src = BUILTIN_AGENTS[name] ? "builtin" : "custom";
|
|
88
|
+
console.log(`${ok ? "ok " : "miss"} ${name.padEnd(10)} ${bin}${ok ? "" : " (not on PATH)"} [${src}]`);
|
|
89
|
+
}
|
|
90
|
+
console.log("\nmissing agents are skipped at run time only if you pass --agents");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (cmd === "run") {
|
|
94
|
+
const suiteFile = typeof flags.suite === "string" ? flags.suite : "trials.json";
|
|
95
|
+
if (!existsSync(suiteFile)) {
|
|
96
|
+
fail(`no ${suiteFile}. run \`agent-trials init\` or pass --suite <file>`);
|
|
97
|
+
}
|
|
98
|
+
const suite = loadSuite(suiteFile);
|
|
99
|
+
const root = path.resolve(typeof flags.root === "string" ? flags.root : ".");
|
|
100
|
+
const agentNames = typeof flags.agents === "string" ? flags.agents.split(",") : undefined;
|
|
101
|
+
let agents = resolveAgents(agentNames, suite);
|
|
102
|
+
const missing = Object.entries(agents)
|
|
103
|
+
.filter(([, s]) => !agentInstalled(s.argv[0]))
|
|
104
|
+
.map(([n]) => n);
|
|
105
|
+
if (missing.length && !agentNames) {
|
|
106
|
+
for (const n of missing)
|
|
107
|
+
delete agents[n];
|
|
108
|
+
console.log(`skipping missing agents: ${missing.join(", ")}`);
|
|
109
|
+
}
|
|
110
|
+
const names = Object.keys(agents);
|
|
111
|
+
if (names.length === 0)
|
|
112
|
+
fail("no agents available. install claude/codex/gemini or define a custom agent");
|
|
113
|
+
const jobs = Math.max(1, Number(flags.jobs ?? 2) || 2);
|
|
114
|
+
const keep = Boolean(flags.keep);
|
|
115
|
+
const started = Date.now();
|
|
116
|
+
const results = await runSuite({
|
|
117
|
+
root,
|
|
118
|
+
suite,
|
|
119
|
+
agents,
|
|
120
|
+
jobs,
|
|
121
|
+
keep,
|
|
122
|
+
onCell: (c) => {
|
|
123
|
+
const verdict = verdictOf(c);
|
|
124
|
+
console.log(` [${verdict}] ${c.agent} · ${c.task} · ${c.sec}s`);
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
console.log("");
|
|
128
|
+
const board = renderBoard(suite, results, names);
|
|
129
|
+
console.log(board);
|
|
130
|
+
console.log(`\ntotal wall time ${Math.round((Date.now() - started) / 1000)}s`);
|
|
131
|
+
const out = flags.out === undefined ? "TRIALS.md" : flags.out;
|
|
132
|
+
if (out !== "none" && typeof out === "string") {
|
|
133
|
+
writeMarkdown(out, renderMarkdown(suite, results, names));
|
|
134
|
+
console.log(`wrote ${out}`);
|
|
135
|
+
}
|
|
136
|
+
if (typeof flags.json === "string") {
|
|
137
|
+
writeFileSync(flags.json, JSON.stringify(results, null, 2) + "\n");
|
|
138
|
+
console.log(`wrote ${flags.json}`);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
fail(`unknown command "${cmd}". try agent-trials help`);
|
|
143
|
+
}
|
|
144
|
+
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
package/dist/trials.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export const BUILTIN_AGENTS = {
|
|
6
|
+
claude: {
|
|
7
|
+
argv: ["claude", "-p", "{prompt}", "--dangerously-skip-permissions", "--output-format", "json"],
|
|
8
|
+
resultJson: true,
|
|
9
|
+
},
|
|
10
|
+
codex: { argv: ["codex", "exec", "--full-auto", "{prompt}"] },
|
|
11
|
+
gemini: { argv: ["gemini", "-p", "{prompt}", "--yolo", "-o", "json"], resultJson: true },
|
|
12
|
+
};
|
|
13
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".agent-trials"]);
|
|
14
|
+
export function loadSuite(file) {
|
|
15
|
+
const raw = JSON.parse(readFileSync(file, "utf-8"));
|
|
16
|
+
if (!Array.isArray(raw.tasks) || raw.tasks.length === 0) {
|
|
17
|
+
throw new Error(`${file}: needs a non-empty "tasks" array`);
|
|
18
|
+
}
|
|
19
|
+
for (const t of raw.tasks) {
|
|
20
|
+
if (!t.id || !t.prompt)
|
|
21
|
+
throw new Error(`${file}: every task needs "id" and "prompt"`);
|
|
22
|
+
if (!t.verify && !raw.verify)
|
|
23
|
+
throw new Error(`${file}: task "${t.id}" has no verify and suite has no default`);
|
|
24
|
+
}
|
|
25
|
+
if (raw.verifyPaths !== undefined && !Array.isArray(raw.verifyPaths)) {
|
|
26
|
+
throw new Error(`${file}: "verifyPaths" must be an array of repo-relative paths`);
|
|
27
|
+
}
|
|
28
|
+
return raw;
|
|
29
|
+
}
|
|
30
|
+
export function resolveAgents(names, suite) {
|
|
31
|
+
const custom = suite.agents ?? {};
|
|
32
|
+
const all = { ...BUILTIN_AGENTS };
|
|
33
|
+
for (const [name, def] of Object.entries(custom)) {
|
|
34
|
+
if (!Array.isArray(def.run) || def.run.length === 0) {
|
|
35
|
+
throw new Error(`agent "${name}": "run" must be an argv array, e.g. ["sh","./agent.sh","{prompt}"]`);
|
|
36
|
+
}
|
|
37
|
+
all[name] = { argv: def.run, resultJson: def.resultJson === true };
|
|
38
|
+
}
|
|
39
|
+
if (!names || names.length === 0)
|
|
40
|
+
return all;
|
|
41
|
+
const picked = {};
|
|
42
|
+
for (const n of names) {
|
|
43
|
+
if (!all[n])
|
|
44
|
+
throw new Error(`unknown agent "${n}". known: ${Object.keys(all).join(", ")}`);
|
|
45
|
+
picked[n] = all[n];
|
|
46
|
+
}
|
|
47
|
+
return picked;
|
|
48
|
+
}
|
|
49
|
+
export function copyWorkdir(root, dest) {
|
|
50
|
+
cpSync(root, dest, {
|
|
51
|
+
recursive: true,
|
|
52
|
+
filter: (src) => {
|
|
53
|
+
const rel = path.relative(root, src);
|
|
54
|
+
if (!rel)
|
|
55
|
+
return true;
|
|
56
|
+
const first = rel.split(path.sep)[0];
|
|
57
|
+
return !SKIP_DIRS.has(first);
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function runCmd(argv, cwd, timeoutMs) {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const child = spawn(argv[0], argv.slice(1), { cwd, env: process.env });
|
|
64
|
+
let stdout = "";
|
|
65
|
+
let stderr = "";
|
|
66
|
+
let timedOut = false;
|
|
67
|
+
const cap = (s, chunk) => (s.length < 512_000 ? s + chunk.toString() : s);
|
|
68
|
+
child.stdout.on("data", (c) => (stdout = cap(stdout, c)));
|
|
69
|
+
child.stderr.on("data", (c) => (stderr = cap(stderr, c)));
|
|
70
|
+
const killer = setTimeout(() => {
|
|
71
|
+
timedOut = true;
|
|
72
|
+
child.kill("SIGTERM");
|
|
73
|
+
setTimeout(() => child.kill("SIGKILL"), 5000).unref();
|
|
74
|
+
}, timeoutMs);
|
|
75
|
+
child.on("error", (err) => {
|
|
76
|
+
clearTimeout(killer);
|
|
77
|
+
resolve({ code: null, stdout, stderr: String(err), timedOut });
|
|
78
|
+
});
|
|
79
|
+
child.on("close", (code) => {
|
|
80
|
+
clearTimeout(killer);
|
|
81
|
+
resolve({ code, stdout, stderr, timedOut });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function parseAgentJson(stdout) {
|
|
86
|
+
try {
|
|
87
|
+
const j = JSON.parse(stdout.trim());
|
|
88
|
+
const cost = j.total_cost_usd ?? j.cost_usd ?? j.stats?.total_cost_usd;
|
|
89
|
+
const model = j.model ?? Object.keys(j.modelUsage ?? {})[0];
|
|
90
|
+
const out = {};
|
|
91
|
+
if (typeof cost === "number")
|
|
92
|
+
out.costUsd = cost;
|
|
93
|
+
if (typeof model === "string" && model)
|
|
94
|
+
out.modelUsed = model;
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// The checker files are restored from the source repo after the agent runs,
|
|
102
|
+
// so an agent cannot rewrite its own test. The suite marks them with
|
|
103
|
+
// verifyPaths; a top-level verify/ dir is the convention fallback.
|
|
104
|
+
function protectedPaths(root, suite) {
|
|
105
|
+
const rels = suite.verifyPaths ?? (existsSync(path.join(root, "verify")) ? ["verify"] : []);
|
|
106
|
+
return rels.filter((rel) => !path.isAbsolute(rel) &&
|
|
107
|
+
!rel.split(/[\\/]+/).includes("..") &&
|
|
108
|
+
!SKIP_DIRS.has(rel.split(/[\\/]+/)[0]) &&
|
|
109
|
+
existsSync(path.join(root, rel)));
|
|
110
|
+
}
|
|
111
|
+
export async function runSuite(opts) {
|
|
112
|
+
const { root, suite, agents, jobs, keep, onCell } = opts;
|
|
113
|
+
const timeoutMs = (suite.timeoutSec ?? 600) * 1000;
|
|
114
|
+
const cells = [];
|
|
115
|
+
for (const [agent, spec] of Object.entries(agents)) {
|
|
116
|
+
for (const task of suite.tasks)
|
|
117
|
+
cells.push({ agent, spec, task });
|
|
118
|
+
}
|
|
119
|
+
const results = [];
|
|
120
|
+
let idx = 0;
|
|
121
|
+
async function worker() {
|
|
122
|
+
while (idx < cells.length) {
|
|
123
|
+
const cell = cells[idx++];
|
|
124
|
+
const { agent, spec, task } = cell;
|
|
125
|
+
const sandbox = mkdtempSync(path.join(tmpdir(), `agent-trials-${task.id}-`));
|
|
126
|
+
const t0 = Date.now();
|
|
127
|
+
const out = {
|
|
128
|
+
agent,
|
|
129
|
+
task: task.id,
|
|
130
|
+
pass: false,
|
|
131
|
+
agentExit: null,
|
|
132
|
+
verifyExit: null,
|
|
133
|
+
sec: 0,
|
|
134
|
+
};
|
|
135
|
+
try {
|
|
136
|
+
copyWorkdir(root, sandbox);
|
|
137
|
+
if (suite.setup) {
|
|
138
|
+
const s = await runCmd(["sh", "-c", suite.setup], sandbox, timeoutMs);
|
|
139
|
+
if (s.code !== 0)
|
|
140
|
+
throw new Error(`setup failed (${s.code}): ${s.stderr.slice(-200)}`);
|
|
141
|
+
}
|
|
142
|
+
const argv = spec.argv.map((a) => (a === "{prompt}" ? task.prompt : a));
|
|
143
|
+
if (!spec.argv.includes("{prompt}"))
|
|
144
|
+
argv.push(task.prompt);
|
|
145
|
+
const agentRes = await runCmd(argv, sandbox, timeoutMs);
|
|
146
|
+
out.agentExit = agentRes.code;
|
|
147
|
+
if (spec.resultJson)
|
|
148
|
+
Object.assign(out, parseAgentJson(agentRes.stdout));
|
|
149
|
+
if (agentRes.code === null) {
|
|
150
|
+
out.error = agentRes.timedOut ? "timeout" : `spawn failed: ${agentRes.stderr.slice(-160)}`;
|
|
151
|
+
}
|
|
152
|
+
const verifyCmd = task.verify ?? suite.verify;
|
|
153
|
+
const restored = protectedPaths(root, suite);
|
|
154
|
+
for (const rel of restored) {
|
|
155
|
+
cpSync(path.join(root, rel), path.join(sandbox, rel), { recursive: true });
|
|
156
|
+
}
|
|
157
|
+
if (restored.length)
|
|
158
|
+
out.verifyRestored = restored;
|
|
159
|
+
const v = await runCmd(["sh", "-c", verifyCmd], sandbox, timeoutMs);
|
|
160
|
+
out.verifyExit = v.code;
|
|
161
|
+
out.pass = out.agentExit === 0 && v.code === 0;
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
out.error = err instanceof Error ? err.message : String(err);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
out.sec = Math.round((Date.now() - t0) / 100) / 10;
|
|
168
|
+
if (!keep)
|
|
169
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
170
|
+
results.push(out);
|
|
171
|
+
onCell?.(out);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
await Promise.all(Array.from({ length: Math.max(1, jobs) }, worker));
|
|
176
|
+
return results;
|
|
177
|
+
}
|
|
178
|
+
export function median(nums) {
|
|
179
|
+
if (nums.length === 0)
|
|
180
|
+
return 0;
|
|
181
|
+
const s = [...nums].sort((a, b) => a - b);
|
|
182
|
+
const mid = Math.floor(s.length / 2);
|
|
183
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
184
|
+
}
|
|
185
|
+
export function summarize(results, agentNames) {
|
|
186
|
+
return agentNames.map((agent) => {
|
|
187
|
+
const cells = results.filter((r) => r.agent === agent);
|
|
188
|
+
const passes = cells.filter((r) => r.pass).length;
|
|
189
|
+
const costs = cells.map((r) => r.costUsd).filter((c) => typeof c === "number");
|
|
190
|
+
return {
|
|
191
|
+
agent,
|
|
192
|
+
passes,
|
|
193
|
+
total: cells.length,
|
|
194
|
+
medianSec: median(cells.map((r) => r.sec)),
|
|
195
|
+
totalCost: costs.length ? costs.reduce((a, b) => a + b, 0) : undefined,
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
export function verdictOf(c) {
|
|
200
|
+
if (c.error || c.agentExit !== 0)
|
|
201
|
+
return "err";
|
|
202
|
+
return c.pass ? "pass" : "fail";
|
|
203
|
+
}
|
|
204
|
+
export function renderBoard(suite, results, agentNames) {
|
|
205
|
+
const taskIds = suite.tasks.map((t) => t.id);
|
|
206
|
+
const find = (a, t) => results.find((r) => r.agent === a && r.task === t);
|
|
207
|
+
const colW = agentNames.map((a) => Math.max(a.length, ...taskIds.map((t) => {
|
|
208
|
+
const c = find(a, t);
|
|
209
|
+
return c ? `${verdictOf(c)} ${c.sec}s`.length : 1;
|
|
210
|
+
})) + 2);
|
|
211
|
+
const taskW = Math.max(...taskIds.map((t) => t.length), "median".length) + 2;
|
|
212
|
+
const lines = [];
|
|
213
|
+
lines.push(`agent-trials · ${taskIds.length} task${taskIds.length === 1 ? "" : "s"} · ${agentNames.length} agent${agentNames.length === 1 ? "" : "s"}`);
|
|
214
|
+
lines.push("");
|
|
215
|
+
lines.push("".padEnd(taskW) + agentNames.map((a, i) => a.padStart(colW[i])).join(""));
|
|
216
|
+
for (const tid of taskIds) {
|
|
217
|
+
const row = agentNames
|
|
218
|
+
.map((a, i) => {
|
|
219
|
+
const c = find(a, tid);
|
|
220
|
+
const label = c ? `${verdictOf(c)} ${c.sec}s` : "-";
|
|
221
|
+
return label.padStart(colW[i]);
|
|
222
|
+
})
|
|
223
|
+
.join("");
|
|
224
|
+
lines.push(tid.padEnd(taskW) + row);
|
|
225
|
+
}
|
|
226
|
+
lines.push("");
|
|
227
|
+
const sums = summarize(results, agentNames);
|
|
228
|
+
lines.push("score".padEnd(taskW) +
|
|
229
|
+
sums.map((s, i) => `${s.passes}/${s.total}`.padStart(colW[i])).join(""));
|
|
230
|
+
lines.push("median".padEnd(taskW) +
|
|
231
|
+
sums.map((s, i) => `${s.medianSec}s`.padStart(colW[i])).join(""));
|
|
232
|
+
if (sums.some((s) => s.totalCost !== undefined)) {
|
|
233
|
+
lines.push("cost".padEnd(taskW) +
|
|
234
|
+
sums
|
|
235
|
+
.map((s) => (s.totalCost === undefined ? "-" : `$${s.totalCost.toFixed(2)}`))
|
|
236
|
+
.map((x, i) => x.padStart(colW[i]))
|
|
237
|
+
.join(""));
|
|
238
|
+
}
|
|
239
|
+
const best = [...sums].sort((a, b) => b.passes - a.passes || a.medianSec - b.medianSec)[0];
|
|
240
|
+
if (best && sums.length > 1) {
|
|
241
|
+
lines.push("");
|
|
242
|
+
lines.push(`winner: ${best.agent} (${best.passes}/${best.total}, median ${best.medianSec}s)`);
|
|
243
|
+
}
|
|
244
|
+
return lines.join("\n");
|
|
245
|
+
}
|
|
246
|
+
export function renderMarkdown(suite, results, agentNames) {
|
|
247
|
+
const sums = summarize(results, agentNames);
|
|
248
|
+
const find = (a, t) => results.find((r) => r.agent === a && r.task === t);
|
|
249
|
+
const head = `| task | ${agentNames.join(" | ")} |`;
|
|
250
|
+
const sep = `|${"---|".repeat(agentNames.length + 1)}`;
|
|
251
|
+
const rows = suite.tasks.map((t) => {
|
|
252
|
+
const cells = agentNames
|
|
253
|
+
.map((a) => {
|
|
254
|
+
const c = find(a, t.id);
|
|
255
|
+
if (!c)
|
|
256
|
+
return "-";
|
|
257
|
+
const verdict = verdictOf(c);
|
|
258
|
+
const cost = c.costUsd !== undefined ? ` · $${c.costUsd.toFixed(2)}` : "";
|
|
259
|
+
return `${verdict} · ${c.sec}s${cost}`;
|
|
260
|
+
})
|
|
261
|
+
.join(" | ");
|
|
262
|
+
return `| ${t.id} | ${cells} |`;
|
|
263
|
+
});
|
|
264
|
+
const totals = `| **score** | ${sums.map((s) => `**${s.passes}/${s.total}**`).join(" | ")} |`;
|
|
265
|
+
const med = `| median | ${sums.map((s) => `${s.medianSec}s`).join(" | ")} |`;
|
|
266
|
+
const when = new Date().toISOString().slice(0, 10);
|
|
267
|
+
return [
|
|
268
|
+
`# agent-trials results`,
|
|
269
|
+
``,
|
|
270
|
+
`${suite.name ?? "suite"} · run ${when} · ${suite.tasks.length} tasks × ${agentNames.length} agents`,
|
|
271
|
+
``,
|
|
272
|
+
head,
|
|
273
|
+
sep,
|
|
274
|
+
...rows,
|
|
275
|
+
totals,
|
|
276
|
+
med,
|
|
277
|
+
``,
|
|
278
|
+
`Generated by [agent-trials](https://github.com/princejain756/agent-trials)`,
|
|
279
|
+
].join("\n");
|
|
280
|
+
}
|
|
281
|
+
export function writeMarkdown(file, content) {
|
|
282
|
+
writeFileSync(file, content + "\n");
|
|
283
|
+
}
|
|
284
|
+
export function agentInstalled(argv0) {
|
|
285
|
+
const which = process.platform === "win32" ? "where" : "which";
|
|
286
|
+
const res = spawnSync(which, [argv0], { stdio: "ignore" });
|
|
287
|
+
return res.status === 0;
|
|
288
|
+
}
|
|
289
|
+
export function suiteFileExists(file) {
|
|
290
|
+
return existsSync(file);
|
|
291
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-trials",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Interview your coding agent before you ship with it. Run the same tasks through every agent CLI you have installed and get a scoreboard.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agent-trials": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"prepare": "npm run build",
|
|
15
|
+
"test": "node test/run.mjs"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"ai-agents",
|
|
22
|
+
"evals",
|
|
23
|
+
"benchmark",
|
|
24
|
+
"claude-code",
|
|
25
|
+
"codex",
|
|
26
|
+
"gemini-cli",
|
|
27
|
+
"coding-agents",
|
|
28
|
+
"llm"
|
|
29
|
+
],
|
|
30
|
+
"author": "Prince Jain",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/princejain756/agent-trials.git"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^26.6.5",
|
|
38
|
+
"typescript": "^7.0.2"
|
|
39
|
+
}
|
|
40
|
+
}
|