@hackerrank/astra-cli 0.1.0 → 0.1.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/README.md +36 -2
- package/package.json +1 -1
- package/src/agent.js +9 -0
- package/src/bench.js +478 -0
- package/src/cli.js +322 -33
- package/src/config.js +78 -0
- package/src/model.js +9 -0
- package/src/models.js +31 -0
- package/src/prompts.js +7 -3
- package/src/repl.js +213 -16
- package/src/report.js +459 -0
package/README.md
CHANGED
|
@@ -100,6 +100,32 @@ astra \
|
|
|
100
100
|
-o ./runs/run-01.traj.json
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
+
### Multi-model bench matrix
|
|
104
|
+
|
|
105
|
+
Pass a **comma-separated** model list (and/or `--repeat`, and/or a comma-separated
|
|
106
|
+
`-r` reasoning list) to run a matrix. Each cell runs sequentially in its own
|
|
107
|
+
isolated workspace under `bench/<model-reasoning>/run-NN/`, and per-run metrics
|
|
108
|
+
are appended to a rolled-up `bench/metrics.csv` index. A leaderboard prints at
|
|
109
|
+
the end.
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
astra \
|
|
113
|
+
-m claude-sonnet-5,gpt-5.6-sol,gemini-3.7-flash \
|
|
114
|
+
-p tasks/dummy-slugify \
|
|
115
|
+
--repeat 3 \
|
|
116
|
+
--tar # write a bench/ tarball locally
|
|
117
|
+
|
|
118
|
+
# push results to S3 (tars bench/ then `aws s3 cp` to the URI)
|
|
119
|
+
astra -m m1,m2 -p tasks/dummy-slugify --repeat 3 \
|
|
120
|
+
--push s3://hackerrank-astra-bench-results-dev/runs/2025-09-02/
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Leaderboard columns: `solved` (pass rate), average `steps` / `tokens`, and
|
|
124
|
+
`cost`. Cost is per-run summed; a model whose route reports no cost and has no
|
|
125
|
+
price entry shows **`n/a`** (never `$0`), and estimated costs (native routes) are
|
|
126
|
+
marked `~`. `--push` requires an `s3://` URI and the `aws` CLI on PATH; if the
|
|
127
|
+
upload fails the local tarball is kept.
|
|
128
|
+
|
|
103
129
|
### Sessions
|
|
104
130
|
|
|
105
131
|
Every run (either mode) is saved under `~/.astra/sessions/<id>.json`.
|
|
@@ -118,9 +144,15 @@ credits — that's a quota issue, not a bug.
|
|
|
118
144
|
### Options
|
|
119
145
|
|
|
120
146
|
```
|
|
121
|
-
-m, --model <id> Model id
|
|
147
|
+
-m, --model <id> Model id (comma-separated -> multi-model matrix)
|
|
148
|
+
-r, --reasoning <lvl> Reasoning effort (comma-separated -> sweep levels)
|
|
122
149
|
-t, --task <text> Task text -> autonomous mode
|
|
123
150
|
-f, --task-file <path> Read task text from a file -> autonomous mode
|
|
151
|
+
-p, --path <dir> Task directory -> isolated bench workspace
|
|
152
|
+
--repeat <n> Attempts per (model,reasoning) in a matrix (default: 1)
|
|
153
|
+
--bench-root <dir> Root folder for bench runs (default: ./bench)
|
|
154
|
+
--tar After a matrix, write a bench/ tarball locally
|
|
155
|
+
--push <s3-uri> After a matrix, tar bench/ and `aws s3 cp` to the URI
|
|
124
156
|
-C, --cwd <path> Working directory for commands (default: cwd)
|
|
125
157
|
-o, --output <path> Also write trajectory JSON here (autonomous mode)
|
|
126
158
|
-s, --steps <n> Step limit (default: 40)
|
|
@@ -202,4 +234,6 @@ Tokens are exact (from each response's `usage`). Cost is **hybrid**, tracking-on
|
|
|
202
234
|
A run that mixes both is tagged `source: "mixed"` with the split preserved in
|
|
203
235
|
`reported_usd` / `estimated_usd`.
|
|
204
236
|
|
|
205
|
-
|
|
237
|
+
Multi-model benchmarking runs in this CLI (`-m a,b,c --repeat N`, with `--tar` /
|
|
238
|
+
`--push s3://...` to archive results). The sibling repo `astra-bench` only hosts
|
|
239
|
+
the Docker image + Jenkins job that invoke this CLI.
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -50,6 +50,11 @@ export class Agent {
|
|
|
50
50
|
this.messages = [];
|
|
51
51
|
this.nSteps = 0;
|
|
52
52
|
this.formatErrorStreak = 0;
|
|
53
|
+
// Counters surfaced as benchmark metrics.
|
|
54
|
+
this.nCommands = 0; // commands actually executed
|
|
55
|
+
this.nFailedCommands = 0; // executed commands with non-zero return code
|
|
56
|
+
this.nFormatErrors = 0; // unparseable model replies
|
|
57
|
+
this.nDeclined = 0; // commands rejected by the approval gate
|
|
53
58
|
this.startTime = Date.now();
|
|
54
59
|
// Exact context size (prompt tokens) reported by the most recent call.
|
|
55
60
|
this.lastContextTokens = 0;
|
|
@@ -153,6 +158,7 @@ export class Agent {
|
|
|
153
158
|
return { kind: "chat", content };
|
|
154
159
|
}
|
|
155
160
|
this.formatErrorStreak++;
|
|
161
|
+
this.nFormatErrors++;
|
|
156
162
|
if (
|
|
157
163
|
this.maxConsecutiveFormatErrors > 0 &&
|
|
158
164
|
this.formatErrorStreak >= this.maxConsecutiveFormatErrors
|
|
@@ -166,6 +172,7 @@ export class Agent {
|
|
|
166
172
|
// --- 3. Approval gate ---
|
|
167
173
|
const allowed = await this.confirm(command);
|
|
168
174
|
if (!allowed) {
|
|
175
|
+
this.nDeclined++;
|
|
169
176
|
this.add("user", "The user declined to run that command. Suggest an alternative or ask what to do instead.", {
|
|
170
177
|
command,
|
|
171
178
|
declined: true,
|
|
@@ -175,6 +182,8 @@ export class Agent {
|
|
|
175
182
|
|
|
176
183
|
// --- 4. Execute ---
|
|
177
184
|
const output = await this.env.execute(command);
|
|
185
|
+
this.nCommands++;
|
|
186
|
+
if (output.returncode !== 0) this.nFailedCommands++;
|
|
178
187
|
|
|
179
188
|
// --- 5. Submission sentinel ---
|
|
180
189
|
const submitted = checkSubmitted(output);
|
package/src/bench.js
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Benchmark run management.
|
|
3
|
+
*
|
|
4
|
+
* A "bench" run executes a task autonomously in an isolated workspace and
|
|
5
|
+
* records metrics for later comparison across models / reasoning levels.
|
|
6
|
+
*
|
|
7
|
+
* Layout (created under the invocation cwd unless --bench-root overrides it):
|
|
8
|
+
*
|
|
9
|
+
* bench/<model-name-reasoning>/run-NN/
|
|
10
|
+
* ├── workspace/ copy of the task files; the agent works here
|
|
11
|
+
* ├── trajectory.json full astra-1 trajectory for the run
|
|
12
|
+
* ├── metrics.csv one-row metrics summary (also appended to the
|
|
13
|
+
* │ rolled-up bench/metrics.csv index)
|
|
14
|
+
* └── task.md the task text the agent was given
|
|
15
|
+
*
|
|
16
|
+
* The per-run metrics are also appended to a top-level `bench/metrics.csv`
|
|
17
|
+
* index so every run across every model lands in one comparable table.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import { GatewayModel } from "./model.js";
|
|
24
|
+
import { LocalEnvironment } from "./environment.js";
|
|
25
|
+
import { Agent } from "./agent.js";
|
|
26
|
+
|
|
27
|
+
/** CSV columns, in order, for the metrics table. */
|
|
28
|
+
export const METRIC_COLUMNS = [
|
|
29
|
+
"timestamp",
|
|
30
|
+
"model",
|
|
31
|
+
"reasoning",
|
|
32
|
+
"exit_status",
|
|
33
|
+
"resolved",
|
|
34
|
+
"steps",
|
|
35
|
+
"n_commands",
|
|
36
|
+
"n_failed_commands",
|
|
37
|
+
"n_format_errors",
|
|
38
|
+
"n_retries",
|
|
39
|
+
"n_calls",
|
|
40
|
+
"elapsed_seconds",
|
|
41
|
+
"prompt_tokens",
|
|
42
|
+
"cached_tokens",
|
|
43
|
+
"cache_write_tokens",
|
|
44
|
+
"completion_tokens",
|
|
45
|
+
"reasoning_tokens",
|
|
46
|
+
"total_tokens",
|
|
47
|
+
"last_context_tokens",
|
|
48
|
+
"cost_usd",
|
|
49
|
+
"cost_source",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Turn a model id + reasoning level into a filesystem-safe folder name like
|
|
54
|
+
* `claude-sonnet-5-high` or `grok-4.6-none`.
|
|
55
|
+
*/
|
|
56
|
+
export function benchSlug(model, reasoning) {
|
|
57
|
+
const clean = (s) =>
|
|
58
|
+
String(s ?? "")
|
|
59
|
+
.trim()
|
|
60
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
61
|
+
.replace(/^-+|-+$/g, "");
|
|
62
|
+
const r = reasoning ? clean(reasoning) : "none";
|
|
63
|
+
return `${clean(model)}-${r}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Absolute path to the bench root (default: <cwd>/bench). */
|
|
67
|
+
export function benchRoot(root) {
|
|
68
|
+
return path.resolve(root || path.join(process.cwd(), "bench"));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Allocate the next `run-NN` directory for a (model, reasoning) slug, creating
|
|
73
|
+
* parent folders. Returns { dir, run, slug, workspace, root }.
|
|
74
|
+
*/
|
|
75
|
+
export function allocateRun({ model, reasoning, root } = {}) {
|
|
76
|
+
const rootDir = benchRoot(root);
|
|
77
|
+
const slug = benchSlug(model, reasoning);
|
|
78
|
+
const slugDir = path.join(rootDir, slug);
|
|
79
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
80
|
+
|
|
81
|
+
// Find the next run index by scanning existing run-NN folders.
|
|
82
|
+
let max = 0;
|
|
83
|
+
for (const name of safeReaddir(slugDir)) {
|
|
84
|
+
const m = /^run-(\d+)$/.exec(name);
|
|
85
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
86
|
+
}
|
|
87
|
+
const run = max + 1;
|
|
88
|
+
const runId = `run-${String(run).padStart(2, "0")}`;
|
|
89
|
+
const dir = path.join(slugDir, runId);
|
|
90
|
+
const workspace = path.join(dir, "workspace");
|
|
91
|
+
fs.mkdirSync(workspace, { recursive: true });
|
|
92
|
+
|
|
93
|
+
return { dir, run, runId, slug, workspace, root: rootDir };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Seed a run workspace from a task source.
|
|
98
|
+
* @param {string} workspace destination directory (already created)
|
|
99
|
+
* @param {object} src { taskPath?, taskFile?, taskText? }
|
|
100
|
+
* - taskPath: a directory whose contents are copied into the workspace
|
|
101
|
+
* - taskFile: a single instruction file; its dir contents are copied too
|
|
102
|
+
* - taskText: raw task text (no files to copy)
|
|
103
|
+
* Returns the resolved task text.
|
|
104
|
+
*/
|
|
105
|
+
export function seedWorkspace(workspace, { taskPath, taskFile, taskText } = {}) {
|
|
106
|
+
let text = taskText ?? "";
|
|
107
|
+
let copyFrom = null;
|
|
108
|
+
let instructionName = null;
|
|
109
|
+
|
|
110
|
+
if (taskPath) {
|
|
111
|
+
const resolved = path.resolve(taskPath);
|
|
112
|
+
const stat = fs.statSync(resolved);
|
|
113
|
+
if (stat.isDirectory()) {
|
|
114
|
+
copyFrom = resolved;
|
|
115
|
+
// Prefer a conventional instruction file for the task text.
|
|
116
|
+
for (const name of ["instruction.md", "INSTRUCTION.md", "task.md", "README.md"]) {
|
|
117
|
+
if (fs.existsSync(path.join(resolved, name))) {
|
|
118
|
+
text = fs.readFileSync(path.join(resolved, name), "utf8");
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
copyFrom = path.dirname(resolved);
|
|
124
|
+
instructionName = path.basename(resolved);
|
|
125
|
+
text = fs.readFileSync(resolved, "utf8");
|
|
126
|
+
}
|
|
127
|
+
} else if (taskFile) {
|
|
128
|
+
const resolved = path.resolve(taskFile);
|
|
129
|
+
copyFrom = path.dirname(resolved);
|
|
130
|
+
instructionName = path.basename(resolved);
|
|
131
|
+
text = fs.readFileSync(resolved, "utf8");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (copyFrom) copyDir(copyFrom, workspace);
|
|
135
|
+
return { taskText: text, instructionName };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Directory / file names never copied into a fresh workspace (kept clean so
|
|
139
|
+
* result tarballs don't carry build cruft). */
|
|
140
|
+
const COPY_SKIP = new Set([
|
|
141
|
+
"bench",
|
|
142
|
+
".git",
|
|
143
|
+
"node_modules",
|
|
144
|
+
"__pycache__",
|
|
145
|
+
".pytest_cache",
|
|
146
|
+
".mypy_cache",
|
|
147
|
+
".ruff_cache",
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
/** Recursively copy a directory (skips build cruft — see COPY_SKIP). */
|
|
151
|
+
function copyDir(from, to) {
|
|
152
|
+
fs.mkdirSync(to, { recursive: true });
|
|
153
|
+
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
|
154
|
+
if (COPY_SKIP.has(entry.name)) continue;
|
|
155
|
+
const s = path.join(from, entry.name);
|
|
156
|
+
const d = path.join(to, entry.name);
|
|
157
|
+
if (entry.isDirectory()) copyDir(s, d);
|
|
158
|
+
else if (entry.isFile()) fs.copyFileSync(s, d);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Build a metrics row object from a finished agent + run info. */
|
|
163
|
+
export function collectMetrics({ agent, model, reasoning }) {
|
|
164
|
+
const info = agent.serialize().info;
|
|
165
|
+
const resolved = info.exit_status === "Submitted" ? 1 : 0;
|
|
166
|
+
return {
|
|
167
|
+
timestamp: new Date().toISOString(),
|
|
168
|
+
model: model.model,
|
|
169
|
+
reasoning: reasoning || "none",
|
|
170
|
+
exit_status: info.exit_status || "",
|
|
171
|
+
resolved,
|
|
172
|
+
steps: info.n_steps ?? 0,
|
|
173
|
+
n_commands: agent.nCommands ?? 0,
|
|
174
|
+
n_failed_commands: agent.nFailedCommands ?? 0,
|
|
175
|
+
n_format_errors: agent.nFormatErrors ?? 0,
|
|
176
|
+
n_retries: model.nRetries ?? 0,
|
|
177
|
+
n_calls: info.n_calls ?? 0,
|
|
178
|
+
elapsed_seconds: info.elapsed_seconds ?? 0,
|
|
179
|
+
prompt_tokens: info.tokens?.prompt ?? 0,
|
|
180
|
+
cached_tokens: model.totalCachedTokens ?? 0,
|
|
181
|
+
cache_write_tokens: model.totalCacheWriteTokens ?? 0,
|
|
182
|
+
completion_tokens: info.tokens?.completion ?? 0,
|
|
183
|
+
reasoning_tokens: model.totalReasoningTokens ?? 0,
|
|
184
|
+
total_tokens: info.tokens?.total ?? 0,
|
|
185
|
+
last_context_tokens: info.tokens?.last_context ?? 0,
|
|
186
|
+
cost_usd: round(info.cost?.usd ?? 0, 6),
|
|
187
|
+
cost_source: info.cost?.source ?? "",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Serialize a metrics row to a single CSV line (columns in METRIC_COLUMNS order). */
|
|
192
|
+
export function metricsRow(m) {
|
|
193
|
+
return METRIC_COLUMNS.map((c) => csvCell(m[c])).join(",");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The CSV header line. */
|
|
197
|
+
export function metricsHeader() {
|
|
198
|
+
return METRIC_COLUMNS.join(",");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Write a per-run metrics.csv (header + single row) and append the same row to
|
|
203
|
+
* the top-level rolled-up index at <root>/metrics.csv.
|
|
204
|
+
*/
|
|
205
|
+
export function writeMetrics({ runDir, root, metrics }) {
|
|
206
|
+
const header = metricsHeader();
|
|
207
|
+
const row = metricsRow(metrics);
|
|
208
|
+
|
|
209
|
+
// Per-run file.
|
|
210
|
+
fs.writeFileSync(path.join(runDir, "metrics.csv"), header + "\n" + row + "\n");
|
|
211
|
+
|
|
212
|
+
// Rolled-up index (create header once).
|
|
213
|
+
const indexPath = path.join(benchRoot(root), "metrics.csv");
|
|
214
|
+
if (!fs.existsSync(indexPath)) {
|
|
215
|
+
fs.writeFileSync(indexPath, header + "\n" + row + "\n");
|
|
216
|
+
} else {
|
|
217
|
+
fs.appendFileSync(indexPath, row + "\n");
|
|
218
|
+
}
|
|
219
|
+
return { runMetrics: path.join(runDir, "metrics.csv"), index: indexPath };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function csvCell(v) {
|
|
223
|
+
const s = v == null ? "" : String(v);
|
|
224
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function round(n, dp) {
|
|
228
|
+
const f = 10 ** dp;
|
|
229
|
+
return Math.round((Number(n) || 0) * f) / f;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function safeReaddir(dir) {
|
|
233
|
+
try {
|
|
234
|
+
return fs.readdirSync(dir);
|
|
235
|
+
} catch {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ============================================================================
|
|
241
|
+
// Multi-model matrix runner
|
|
242
|
+
// ============================================================================
|
|
243
|
+
|
|
244
|
+
/** Turn a reasoning level into gateway modelKwargs. Empty for off/none/unset. */
|
|
245
|
+
function reasoningKwargs(level) {
|
|
246
|
+
if (!level) return {};
|
|
247
|
+
const l = String(level).toLowerCase();
|
|
248
|
+
if (l === "off" || l === "none" || l === "disabled") return {};
|
|
249
|
+
return { reasoning_effort: l };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Run one bench cell: allocate an isolated workspace, seed it, run the agent
|
|
254
|
+
* autonomously to completion, and record metrics. Never throws — a crashed run
|
|
255
|
+
* is captured as a failed cell so one bad run can't abort the whole matrix.
|
|
256
|
+
*
|
|
257
|
+
* @returns {object} the metrics row (plus `error` when the run threw).
|
|
258
|
+
*/
|
|
259
|
+
export async function runCell({
|
|
260
|
+
model,
|
|
261
|
+
reasoning,
|
|
262
|
+
apiKey,
|
|
263
|
+
baseUrl,
|
|
264
|
+
task,
|
|
265
|
+
taskPath,
|
|
266
|
+
taskFile,
|
|
267
|
+
root,
|
|
268
|
+
steps = 40,
|
|
269
|
+
wall = 0,
|
|
270
|
+
timeout = 60,
|
|
271
|
+
maxOutputChars = 16000,
|
|
272
|
+
onStart = () => {},
|
|
273
|
+
onDone = () => {},
|
|
274
|
+
} = {}) {
|
|
275
|
+
const alloc = allocateRun({ model, reasoning, root });
|
|
276
|
+
onStart({ ...alloc, model, reasoning });
|
|
277
|
+
|
|
278
|
+
const seeded = seedWorkspace(alloc.workspace, { taskPath, taskFile, taskText: task });
|
|
279
|
+
const taskText = seeded.taskText || task || "";
|
|
280
|
+
fs.writeFileSync(path.join(alloc.dir, "task.md"), taskText);
|
|
281
|
+
|
|
282
|
+
const gw = new GatewayModel({
|
|
283
|
+
model,
|
|
284
|
+
baseUrl,
|
|
285
|
+
apiKey,
|
|
286
|
+
modelKwargs: reasoningKwargs(reasoning),
|
|
287
|
+
});
|
|
288
|
+
const env = new LocalEnvironment({ cwd: alloc.workspace, timeout, maxOutputChars });
|
|
289
|
+
const agent = new Agent(gw, env, {
|
|
290
|
+
mode: "autonomous",
|
|
291
|
+
stepLimit: steps,
|
|
292
|
+
wallTimeLimitSeconds: wall,
|
|
293
|
+
outputPath: path.join(alloc.dir, "trajectory.json"),
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
let error = null;
|
|
297
|
+
try {
|
|
298
|
+
await agent.run(taskText);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
error = String(err?.message || err);
|
|
301
|
+
if (!agent.exitStatus) agent.exit("Error", "");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const metrics = collectMetrics({ agent, model: gw, reasoning });
|
|
305
|
+
if (error) metrics.exit_status = metrics.exit_status || "Error";
|
|
306
|
+
const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
|
|
307
|
+
|
|
308
|
+
const result = { ...metrics, error, slug: alloc.slug, runId: alloc.runId, dir: alloc.dir, paths };
|
|
309
|
+
onDone(result);
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Run a full matrix of models × reasoning levels × repeats against one task.
|
|
315
|
+
* Sequential (clean streamed output; avoids gateway rate-limit storms).
|
|
316
|
+
*
|
|
317
|
+
* @returns {object[]} one metrics row per cell.
|
|
318
|
+
*/
|
|
319
|
+
export async function runMatrix({
|
|
320
|
+
models = [],
|
|
321
|
+
reasonings = [""],
|
|
322
|
+
repeat = 1,
|
|
323
|
+
...cellOpts
|
|
324
|
+
} = {}) {
|
|
325
|
+
const rows = [];
|
|
326
|
+
for (const model of models) {
|
|
327
|
+
for (const reasoning of reasonings) {
|
|
328
|
+
for (let attempt = 1; attempt <= repeat; attempt++) {
|
|
329
|
+
const row = await runCell({ ...cellOpts, model, reasoning });
|
|
330
|
+
rows.push(row);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return rows;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ============================================================================
|
|
338
|
+
// Aggregation / leaderboard
|
|
339
|
+
// ============================================================================
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Aggregate metrics rows into a per-(model,reasoning) leaderboard.
|
|
343
|
+
* `resolved` counts as a solve. Cost is summed only over runs that reported a
|
|
344
|
+
* cost; a group with no cost data is reported as null (rendered "n/a"), never $0.
|
|
345
|
+
*/
|
|
346
|
+
export function aggregate(rows) {
|
|
347
|
+
const groups = new Map();
|
|
348
|
+
for (const r of rows) {
|
|
349
|
+
const key = `${r.model}\u0000${r.reasoning || "none"}`;
|
|
350
|
+
if (!groups.has(key)) {
|
|
351
|
+
groups.set(key, {
|
|
352
|
+
model: r.model,
|
|
353
|
+
reasoning: r.reasoning || "none",
|
|
354
|
+
runs: 0,
|
|
355
|
+
solved: 0,
|
|
356
|
+
steps: 0,
|
|
357
|
+
totalTokens: 0,
|
|
358
|
+
costUsd: 0,
|
|
359
|
+
costRuns: 0,
|
|
360
|
+
costSources: new Set(),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
const g = groups.get(key);
|
|
364
|
+
g.runs++;
|
|
365
|
+
g.solved += Number(r.resolved) ? 1 : 0;
|
|
366
|
+
g.steps += Number(r.steps) || 0;
|
|
367
|
+
g.totalTokens += Number(r.total_tokens) || 0;
|
|
368
|
+
if (r.cost_source && r.cost_source !== "") {
|
|
369
|
+
g.costUsd += Number(r.cost_usd) || 0;
|
|
370
|
+
g.costRuns++;
|
|
371
|
+
g.costSources.add(r.cost_source);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return [...groups.values()].map((g) => ({
|
|
375
|
+
model: g.model,
|
|
376
|
+
reasoning: g.reasoning,
|
|
377
|
+
runs: g.runs,
|
|
378
|
+
solved: g.solved,
|
|
379
|
+
solved_rate: g.runs ? g.solved / g.runs : 0,
|
|
380
|
+
avg_steps: g.runs ? g.steps / g.runs : 0,
|
|
381
|
+
avg_tokens: g.runs ? g.totalTokens / g.runs : 0,
|
|
382
|
+
cost_usd: g.costRuns ? g.costUsd : null,
|
|
383
|
+
cost_source:
|
|
384
|
+
g.costSources.size === 0
|
|
385
|
+
? "unknown"
|
|
386
|
+
: g.costSources.size === 1
|
|
387
|
+
? [...g.costSources][0]
|
|
388
|
+
: "mixed",
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ============================================================================
|
|
393
|
+
// Packaging: tarball the bench folder and (optionally) push to S3
|
|
394
|
+
// ============================================================================
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Create a gzipped tarball of the bench root. Returns the tarball path.
|
|
398
|
+
* Excludes build cruft. Uses the system `tar` (zero npm deps).
|
|
399
|
+
*/
|
|
400
|
+
export function packBench({ root, out } = {}) {
|
|
401
|
+
const rootDir = benchRoot(root);
|
|
402
|
+
if (!fs.existsSync(rootDir)) throw new Error(`bench root not found: ${rootDir}`);
|
|
403
|
+
const parent = path.dirname(rootDir);
|
|
404
|
+
const base = path.basename(rootDir);
|
|
405
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
406
|
+
const tarball = path.resolve(out || path.join(parent, `${base}-${stamp}.tgz`));
|
|
407
|
+
|
|
408
|
+
const res = spawnSync(
|
|
409
|
+
"tar",
|
|
410
|
+
[
|
|
411
|
+
"--exclude=__pycache__",
|
|
412
|
+
"--exclude=.pytest_cache",
|
|
413
|
+
"--exclude=.git",
|
|
414
|
+
"-czf",
|
|
415
|
+
tarball,
|
|
416
|
+
"-C",
|
|
417
|
+
parent,
|
|
418
|
+
base,
|
|
419
|
+
],
|
|
420
|
+
{ stdio: ["ignore", "ignore", "pipe"] }
|
|
421
|
+
);
|
|
422
|
+
if (res.status !== 0) {
|
|
423
|
+
throw new Error(`tar failed: ${res.stderr?.toString() || res.error?.message || "unknown"}`);
|
|
424
|
+
}
|
|
425
|
+
return tarball;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** The default S3 bucket for bench result tarballs. */
|
|
429
|
+
export const DEFAULT_BENCH_BUCKET = "astra-bench-results";
|
|
430
|
+
|
|
431
|
+
/** A filesystem/S3-safe timestamp prefix like 20250902-142530. */
|
|
432
|
+
export function benchTimestamp(d = new Date()) {
|
|
433
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
434
|
+
return (
|
|
435
|
+
`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-` +
|
|
436
|
+
`${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Default push destination: s3://astra-bench-results/<datetimestamp>/. */
|
|
441
|
+
export function defaultPushUri(stamp = benchTimestamp()) {
|
|
442
|
+
return `s3://${DEFAULT_BENCH_BUCKET}/${stamp}/`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Parse an s3://bucket/key URI into { bucket, key }. */
|
|
446
|
+
function parseS3Uri(uri) {
|
|
447
|
+
const m = /^s3:\/\/([^/]+)\/?(.*)$/.exec(uri);
|
|
448
|
+
if (!m) throw new Error(`invalid s3 uri: ${uri}`);
|
|
449
|
+
return { bucket: m[1], key: m[2] };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Build a browsable https console URL for an s3://bucket/key object. */
|
|
453
|
+
function s3ConsoleUrl(s3Uri) {
|
|
454
|
+
const { bucket, key } = parseS3Uri(s3Uri);
|
|
455
|
+
return `https://s3.console.aws.amazon.com/s3/object/${bucket}?prefix=${encodeURIComponent(key)}`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Upload a file to S3 via the AWS CLI. Returns { uri, url } where `uri` is the
|
|
460
|
+
* final s3:// destination and `url` is a browsable https console link.
|
|
461
|
+
* Fails gracefully (throws a descriptive error) when the AWS CLI is missing or
|
|
462
|
+
* the copy fails; callers should keep the local tarball on failure.
|
|
463
|
+
*/
|
|
464
|
+
export function pushToS3(file, s3Uri) {
|
|
465
|
+
if (!/^s3:\/\//.test(s3Uri)) throw new Error(`--push destination must be an s3:// URI, got: ${s3Uri}`);
|
|
466
|
+
// If the URI ends with "/", upload into it under the file's basename.
|
|
467
|
+
const dest = s3Uri.endsWith("/") ? s3Uri + path.basename(file) : s3Uri;
|
|
468
|
+
const res = spawnSync("aws", ["s3", "cp", file, dest], {
|
|
469
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
470
|
+
});
|
|
471
|
+
if (res.error && res.error.code === "ENOENT") {
|
|
472
|
+
throw new Error("aws CLI not found on PATH — install it or push the tarball manually.");
|
|
473
|
+
}
|
|
474
|
+
if (res.status !== 0) {
|
|
475
|
+
throw new Error(`aws s3 cp failed: ${res.stderr?.toString() || "unknown"}`);
|
|
476
|
+
}
|
|
477
|
+
return { uri: dest, url: s3ConsoleUrl(dest) };
|
|
478
|
+
}
|