@hackerrank/astra-cli 0.1.6 → 0.1.7
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 +30 -9
- package/package.json +1 -1
- package/src/agent.js +8 -6
- package/src/bench.js +54 -6
- package/src/cli.js +48 -1
- package/src/ledger.js +68 -0
- package/src/model.js +8 -1
- package/src/models.js +1 -0
- package/src/project-bench.js +154 -0
- package/src/project.js +89 -0
- package/src/prompts.js +2 -0
- package/src/report.html +77 -30
- package/src/report.js +105 -0
- package/src/result-contract.js +58 -0
- package/src/verifier-runner.js +159 -0
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ Every run is a resumable session, with exact token accounting and USD cost track
|
|
|
29
29
|
| `src/model.js` | Gateway client (OpenAI-compatible `/chat/completions`). Handles retries, the `max_tokens` vs `max_completion_tokens` difference, and cost accounting. |
|
|
30
30
|
| `src/environment.js` | Runs one command per action in a fresh subshell, with a per-command timeout that kills the whole process group and output truncation. |
|
|
31
31
|
| `src/prompts.js` | System / instance / format-error / observation templates + a tiny `{{var}}` renderer, split into interactive vs autonomous rules. |
|
|
32
|
-
| `src/agent.js` | The turn-based engine: query → parse
|
|
32
|
+
| `src/agent.js` | The turn-based engine: query → parse shell blocks → execute → observe → repeat. Autonomous runs can combine related blocks; interactive runs remain strict. |
|
|
33
33
|
| `src/repl.js` | The interactive assistant loop: prompts, command approval, slash commands. |
|
|
34
34
|
| `src/session.js` | Resumable sessions stored under `~/.astra/sessions/`. |
|
|
35
35
|
| `src/config.js` | Dedicated `~/.astra/config.json`, credential resolution, interactive key prompt. |
|
|
@@ -141,19 +141,26 @@ it through the existing bench persona:
|
|
|
141
141
|
astra bench --project ./task
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
-
`./task/.astra/project.toml` selects the
|
|
145
|
-
models, limits, output root, and
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
144
|
+
`./task/.astra/project.toml` selects the task type (`brownfield` or
|
|
145
|
+
`greenfield`), public workspace, instruction, models, limits, output root, and
|
|
146
|
+
optional context extensions. A task-owned `verifier/run_verifier.py` is
|
|
147
|
+
discovered automatically; an explicit `[verification]` entry may override its
|
|
148
|
+
command and lifecycle settings. CLI flags still override bench limits and
|
|
149
|
+
output paths.
|
|
150
|
+
|
|
151
|
+
A project-configured run copies only the public workspace, freezes each
|
|
152
|
+
candidate, invokes the task-owned verifier outside that workspace, reads its
|
|
153
|
+
versioned JSON report, and writes a merged result. Astra contains no task
|
|
154
|
+
business logic. If no verifier exists, the run is still recorded with
|
|
155
|
+
`solved_score = "NA"`; if a verifier is configured but fails to produce a
|
|
156
|
+
valid report, the result is an infrastructure failure. `model_error`,
|
|
157
|
+
`candidate`, `verifier`, and `infrastructure` failures remain distinct.
|
|
152
158
|
|
|
153
159
|
```toml
|
|
154
160
|
[project]
|
|
155
161
|
id = "peoplecore-scim"
|
|
156
162
|
version = 1
|
|
163
|
+
type = "brownfield"
|
|
157
164
|
instruction = "instruction.md"
|
|
158
165
|
workspace = "public/codebase"
|
|
159
166
|
|
|
@@ -165,12 +172,26 @@ steps = 60
|
|
|
165
172
|
wall_seconds = 3600
|
|
166
173
|
command_timeout_seconds = 120
|
|
167
174
|
|
|
175
|
+
[verification]
|
|
176
|
+
command = "python3 verifier/run_verifier.py --base-url $SCIM_BASE_URL --token $SCIM_BEARER_TOKEN --report $ASTRA_VERIFIER_REPORT"
|
|
177
|
+
base_url = "http://127.0.0.1:3000/api/v1/scim"
|
|
178
|
+
readiness_url = "http://127.0.0.1:3000/health"
|
|
179
|
+
timeout_seconds = 3600
|
|
180
|
+
report = "verifier-report.json"
|
|
181
|
+
token_env = "SCIM_BEARER_TOKEN"
|
|
182
|
+
|
|
168
183
|
[templates]
|
|
169
184
|
bench = ".astra/templates/bench.md"
|
|
170
185
|
[extensions]
|
|
171
186
|
directories = [".astra/extensions"]
|
|
172
187
|
```
|
|
173
188
|
|
|
189
|
+
Project runs are appendable. The durable cell key is task/version/model/
|
|
190
|
+
reasoning/repeat. The report display key is `model-reasoning`, so adding a
|
|
191
|
+
model, reasoning effort, or a higher per-cell repeat count preserves completed
|
|
192
|
+
cells and processes only missing cells. If a process stops, rerunning the same
|
|
193
|
+
project resumes the cell/session or verifier stage from the ledger.
|
|
194
|
+
|
|
174
195
|
### HTML report
|
|
175
196
|
|
|
176
197
|
Every matrix run (and every single `-p`/`-t`/`-f` bench run) rebuilds
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -150,7 +150,7 @@ export class Agent {
|
|
|
150
150
|
// --- 2. Parse a command (may be absent) ---
|
|
151
151
|
let command;
|
|
152
152
|
try {
|
|
153
|
-
command = parseCommand(content);
|
|
153
|
+
command = parseCommand(content, { allowMultiple: this.mode === "autonomous" });
|
|
154
154
|
this.formatErrorStreak = 0;
|
|
155
155
|
} catch (err) {
|
|
156
156
|
// In interactive mode, "no command" is a normal chat reply that yields
|
|
@@ -320,9 +320,11 @@ export class Agent {
|
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
/** Extract exactly one command from a fenced bash block. */
|
|
323
|
-
export function parseCommand(text) {
|
|
324
|
-
// Accept
|
|
325
|
-
|
|
323
|
+
export function parseCommand(text, { allowMultiple = false } = {}) {
|
|
324
|
+
// Accept common shell fence labels. Autonomous mode can combine several
|
|
325
|
+
// fenced blocks into one script; interactive mode retains the strict one
|
|
326
|
+
// command contract.
|
|
327
|
+
const re = /```(?:bash|sh|shell|zsh)?[ \t]*\r?\n([\s\S]*?)```/gi;
|
|
326
328
|
const blocks = [];
|
|
327
329
|
let m;
|
|
328
330
|
while ((m = re.exec(text)) !== null) blocks.push(m[1].trim());
|
|
@@ -332,12 +334,12 @@ export function parseCommand(text) {
|
|
|
332
334
|
err.code = "NO_COMMAND";
|
|
333
335
|
throw err;
|
|
334
336
|
}
|
|
335
|
-
if (blocks.length > 1) {
|
|
337
|
+
if (blocks.length > 1 && !allowMultiple) {
|
|
336
338
|
const err = new Error(`Found ${blocks.length} code blocks; provide exactly one.`);
|
|
337
339
|
err.code = "MULTI_COMMAND";
|
|
338
340
|
throw err;
|
|
339
341
|
}
|
|
340
|
-
const cmd = blocks
|
|
342
|
+
const cmd = blocks.join("\n").trim();
|
|
341
343
|
if (!cmd) {
|
|
342
344
|
const err = new Error("The bash code block was empty.");
|
|
343
345
|
err.code = "EMPTY_COMMAND";
|
package/src/bench.js
CHANGED
|
@@ -23,6 +23,7 @@ import { spawnSync } from "node:child_process";
|
|
|
23
23
|
import { GatewayModel } from "./model.js";
|
|
24
24
|
import { LocalEnvironment } from "./environment.js";
|
|
25
25
|
import { Agent } from "./agent.js";
|
|
26
|
+
import { loadSession, newSessionId, saveSession } from "./session.js";
|
|
26
27
|
|
|
27
28
|
/** CSV columns, in order, for the metrics table. */
|
|
28
29
|
export const METRIC_COLUMNS = [
|
|
@@ -49,6 +50,11 @@ export const METRIC_COLUMNS = [
|
|
|
49
50
|
"last_context_tokens",
|
|
50
51
|
"cost_usd",
|
|
51
52
|
"cost_source",
|
|
53
|
+
"verification_status",
|
|
54
|
+
"verification_score",
|
|
55
|
+
"solved_score",
|
|
56
|
+
"verification_hard_pass",
|
|
57
|
+
"failure_owner",
|
|
52
58
|
];
|
|
53
59
|
|
|
54
60
|
/**
|
|
@@ -295,15 +301,33 @@ export async function runCell({
|
|
|
295
301
|
wall = 0,
|
|
296
302
|
timeout = 60,
|
|
297
303
|
maxOutputChars = 16000,
|
|
304
|
+
runDir: existingRunDir = null,
|
|
305
|
+
sessionId: existingSessionId = null,
|
|
306
|
+
resume = false,
|
|
307
|
+
cell = null,
|
|
298
308
|
onStart = () => {},
|
|
299
309
|
onDone = () => {},
|
|
300
310
|
} = {}) {
|
|
301
|
-
const alloc =
|
|
311
|
+
const alloc = existingRunDir
|
|
312
|
+
? {
|
|
313
|
+
dir: path.resolve(existingRunDir),
|
|
314
|
+
workspace: path.join(path.resolve(existingRunDir), "workspace"),
|
|
315
|
+
root: benchRoot(root),
|
|
316
|
+
slug: benchSlug(model, reasoning),
|
|
317
|
+
runId: path.basename(path.resolve(existingRunDir)),
|
|
318
|
+
}
|
|
319
|
+
: allocateRun({ model, reasoning, root });
|
|
320
|
+
fs.mkdirSync(alloc.workspace, { recursive: true });
|
|
302
321
|
onStart({ ...alloc, model, reasoning });
|
|
303
322
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
323
|
+
let taskText = task || "";
|
|
324
|
+
if (!resume) {
|
|
325
|
+
const seeded = seedWorkspace(alloc.workspace, { taskPath, taskFile, taskText: task });
|
|
326
|
+
taskText = seeded.taskText || task || "";
|
|
327
|
+
fs.writeFileSync(path.join(alloc.dir, "task.md"), taskText);
|
|
328
|
+
} else if (!taskText) {
|
|
329
|
+
taskText = fs.existsSync(path.join(alloc.dir, "task.md")) ? fs.readFileSync(path.join(alloc.dir, "task.md"), "utf8") : "";
|
|
330
|
+
}
|
|
307
331
|
writeProjectMetadata(alloc.dir, projectMetadata);
|
|
308
332
|
|
|
309
333
|
const gw = new GatewayModel({
|
|
@@ -313,16 +337,40 @@ export async function runCell({
|
|
|
313
337
|
modelKwargs: reasoningKwargs(reasoning),
|
|
314
338
|
});
|
|
315
339
|
const env = new LocalEnvironment({ cwd: alloc.workspace, timeout, maxOutputChars });
|
|
340
|
+
const sessionId = existingSessionId || newSessionId();
|
|
316
341
|
const agent = new Agent(gw, env, {
|
|
317
342
|
mode: "autonomous",
|
|
318
343
|
stepLimit: steps,
|
|
319
344
|
wallTimeLimitSeconds: wall,
|
|
320
345
|
outputPath: path.join(alloc.dir, "trajectory.json"),
|
|
346
|
+
sessionId,
|
|
347
|
+
saveSession,
|
|
348
|
+
task: taskText,
|
|
349
|
+
bench: { dir: alloc.dir, workspace: alloc.workspace, root: alloc.root, task: taskText, taskPath, model, reasoning, steps, wall, timeout, segments: [] },
|
|
321
350
|
});
|
|
322
351
|
|
|
352
|
+
if (resume && existingSessionId) {
|
|
353
|
+
const saved = loadSession(existingSessionId);
|
|
354
|
+
agent.restore(saved);
|
|
355
|
+
agent.exitStatus = null;
|
|
356
|
+
if (Number(steps) > 0) agent.stepLimit = agent.nSteps + Number(steps);
|
|
357
|
+
if (Number(wall) > 0) agent.wallTimeLimitSeconds = Math.ceil((Date.now() - agent.startTime) / 1000) + Number(wall);
|
|
358
|
+
}
|
|
359
|
+
|
|
323
360
|
let error = null;
|
|
324
361
|
try {
|
|
325
|
-
|
|
362
|
+
if (resume && existingSessionId) {
|
|
363
|
+
while (true) {
|
|
364
|
+
if (agent.stepLimit > 0 && agent.nSteps >= agent.stepLimit) {
|
|
365
|
+
agent.exit("LimitsExceeded", "");
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
const turn = await agent.runTurn();
|
|
369
|
+
if (turn.kind === "exit") break;
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
await agent.run(taskText);
|
|
373
|
+
}
|
|
326
374
|
} catch (err) {
|
|
327
375
|
error = String(err?.message || err);
|
|
328
376
|
if (!agent.exitStatus) agent.exit("Error", "");
|
|
@@ -332,7 +380,7 @@ export async function runCell({
|
|
|
332
380
|
if (error) metrics.exit_status = metrics.exit_status || "Error";
|
|
333
381
|
const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
|
|
334
382
|
|
|
335
|
-
const result = { ...metrics, error, slug: alloc.slug, runId: alloc.runId, dir: alloc.dir, paths };
|
|
383
|
+
const result = { ...metrics, error, slug: alloc.slug, runId: alloc.runId, dir: alloc.dir, sessionId, cell, paths };
|
|
336
384
|
onDone(result);
|
|
337
385
|
return result;
|
|
338
386
|
}
|
package/src/cli.js
CHANGED
|
@@ -104,6 +104,8 @@ import {
|
|
|
104
104
|
benchRoot,
|
|
105
105
|
} from "./bench.js";
|
|
106
106
|
import { refreshReport } from "./report.js";
|
|
107
|
+
import { runProjectBench } from "./project-bench.js";
|
|
108
|
+
import { loadLedger } from "./ledger.js";
|
|
107
109
|
|
|
108
110
|
/** Map a reasoning level to gateway modelKwargs. Empty for off/none/unset. */
|
|
109
111
|
function reasoningKwargs(level) {
|
|
@@ -187,7 +189,7 @@ async function main() {
|
|
|
187
189
|
|
|
188
190
|
// Load a session to resume (if any) to infer defaults.
|
|
189
191
|
let resumeDoc = null;
|
|
190
|
-
if (args.resume) {
|
|
192
|
+
if (args.resume && !args.project) {
|
|
191
193
|
if (!sessionExists(args.resume)) {
|
|
192
194
|
console.error(`\x1b[31m[astra] no such session: ${args.resume}\x1b[0m`);
|
|
193
195
|
process.exit(2);
|
|
@@ -263,6 +265,51 @@ async function main() {
|
|
|
263
265
|
}
|
|
264
266
|
}
|
|
265
267
|
|
|
268
|
+
if (project) {
|
|
269
|
+
try {
|
|
270
|
+
let resumeCell = null;
|
|
271
|
+
if (args.resume) {
|
|
272
|
+
const ledgerPath = path.join(project.outputRoot, "benchmark.json");
|
|
273
|
+
if (fs.existsSync(ledgerPath)) {
|
|
274
|
+
const ledger = loadLedger(ledgerPath);
|
|
275
|
+
resumeCell = Object.values(ledger.cells).find((cell) => cell.sessionId === args.resume) || null;
|
|
276
|
+
}
|
|
277
|
+
if (!resumeCell) {
|
|
278
|
+
throw new Error(`no project benchmark cell found for resume session ${args.resume}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const effectiveProject = {
|
|
282
|
+
...project,
|
|
283
|
+
outputRoot: args._provided.has("bench-root") ? path.resolve(args["bench-root"]) : project.outputRoot,
|
|
284
|
+
bench: {
|
|
285
|
+
...project.bench,
|
|
286
|
+
models: args._provided.has("model") ? String(args.model).split(",").map((value) => value.trim()).filter(Boolean) : resumeCell ? [resumeCell.model] : project.bench.models,
|
|
287
|
+
reasoning: args._provided.has("reasoning") ? String(args.reasoning).split(",").map((value) => value.trim()).filter(Boolean) : resumeCell ? [resumeCell.reasoning] : project.bench.reasoning,
|
|
288
|
+
repeat: args._provided.has("repeat") ? Math.max(1, Number(args.repeat) || 1) : resumeCell ? resumeCell.repeatIndex : project.bench.repeat,
|
|
289
|
+
steps: args._provided.has("steps") ? Number(args.steps) : project.bench.steps,
|
|
290
|
+
wall: args._provided.has("wall") ? Number(args.wall) : project.bench.wall,
|
|
291
|
+
timeout: args._provided.has("timeout") ? Number(args.timeout) : project.bench.timeout,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
const results = await runProjectBench({
|
|
295
|
+
project: effectiveProject,
|
|
296
|
+
apiKey,
|
|
297
|
+
baseUrl: args["base-url"],
|
|
298
|
+
resume: Boolean(args.resume),
|
|
299
|
+
resumeSessionId: args.resume || null,
|
|
300
|
+
quiet: !!args.quiet,
|
|
301
|
+
onCell: ({ phase, cell }) => {
|
|
302
|
+
if (!args.quiet) console.error(`\x1b[2m[astra] ${phase} · ${cell.displayKey} repeat=${cell.repeatIndex}\x1b[0m`);
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
console.error(`\x1b[1m[astra] project bench complete · ${results.length} cell(s) processed\x1b[0m`);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
console.error(`\x1b[31m[astra] project bench failed: ${error.message}\x1b[0m`);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
process.exit(0);
|
|
311
|
+
}
|
|
312
|
+
|
|
266
313
|
// -------------------- MODEL + REASONING RESOLUTION --------------------
|
|
267
314
|
// Bench (autonomous) mode: an explicit --model is required so runs are
|
|
268
315
|
// reproducible and never silently pick a cached/default model.
|
package/src/ledger.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const LEDGER_VERSION = 1;
|
|
5
|
+
const STALE_AFTER_MS = 10 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
function slug(value) {
|
|
8
|
+
return String(value ?? "none").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "none";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function atomicWrite(file, value) {
|
|
12
|
+
const temp = `${file}.tmp-${process.pid}`;
|
|
13
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
14
|
+
fs.writeFileSync(temp, JSON.stringify(value, null, 2) + "\n");
|
|
15
|
+
fs.renameSync(temp, file);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function loadLedger(file) {
|
|
19
|
+
if (!fs.existsSync(file)) return { version: LEDGER_VERSION, cells: {}, path: file };
|
|
20
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
21
|
+
if (value.version !== LEDGER_VERSION || !value.cells || typeof value.cells !== "object") throw new Error("invalid benchmark ledger");
|
|
22
|
+
return { ...value, path: file };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function nextRunPath(ledger, cell) {
|
|
26
|
+
const prefix = `${slug(cell.displayKey)}/run-`;
|
|
27
|
+
const used = Object.values(ledger.cells).filter((item) => item.runPath?.startsWith(prefix));
|
|
28
|
+
const next = used.reduce((max, item) => Math.max(max, Number(item.runPath.slice(prefix.length)) || 0), 0) + 1;
|
|
29
|
+
return `${prefix}${String(next).padStart(2, "0")}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function claimCell(ledger, cell) {
|
|
33
|
+
const existing = ledger.cells[cell.cellKey];
|
|
34
|
+
if (existing) return existing;
|
|
35
|
+
const record = {
|
|
36
|
+
...cell,
|
|
37
|
+
status: "pending",
|
|
38
|
+
runPath: nextRunPath(ledger, cell),
|
|
39
|
+
createdAt: new Date().toISOString(),
|
|
40
|
+
leaseAt: null,
|
|
41
|
+
};
|
|
42
|
+
ledger.cells[cell.cellKey] = record;
|
|
43
|
+
atomicWrite(ledger.path, { version: LEDGER_VERSION, cells: ledger.cells });
|
|
44
|
+
return record;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function transitionCell(ledger, cellKey, status, patch = {}) {
|
|
48
|
+
const cell = ledger.cells[cellKey];
|
|
49
|
+
if (!cell) throw new Error(`unknown benchmark cell: ${cellKey}`);
|
|
50
|
+
const next = { ...cell, ...patch, status, updatedAt: new Date().toISOString() };
|
|
51
|
+
if (["generating", "verifying"].includes(status) && patch.leaseAt === undefined) next.leaseAt = next.updatedAt;
|
|
52
|
+
ledger.cells[cellKey] = next;
|
|
53
|
+
atomicWrite(ledger.path, { version: LEDGER_VERSION, cells: ledger.cells });
|
|
54
|
+
return next;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function selectWork(ledger, desiredCells, now = Date.now(), { force = false, sessionId = null } = {}) {
|
|
58
|
+
return desiredCells.filter((cell) => {
|
|
59
|
+
const existing = ledger.cells[cell.cellKey];
|
|
60
|
+
if (!existing) return true;
|
|
61
|
+
if (["completed", "failed"].includes(existing.status)) return false;
|
|
62
|
+
if (sessionId && existing.sessionId !== sessionId) return false;
|
|
63
|
+
if (["generating", "verifying"].includes(existing.status)) {
|
|
64
|
+
return force || !existing.leaseAt || now - Date.parse(existing.leaseAt) > STALE_AFTER_MS;
|
|
65
|
+
}
|
|
66
|
+
return !sessionId || existing.sessionId === sessionId;
|
|
67
|
+
});
|
|
68
|
+
}
|
package/src/model.js
CHANGED
|
@@ -89,9 +89,9 @@ export class GatewayModel {
|
|
|
89
89
|
const body = {
|
|
90
90
|
model: this.model,
|
|
91
91
|
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
92
|
-
[this._tokenParam]: this.maxTokens,
|
|
93
92
|
...this.modelKwargs,
|
|
94
93
|
};
|
|
94
|
+
if (this._tokenParam) body[this._tokenParam] = this.maxTokens;
|
|
95
95
|
|
|
96
96
|
let lastErr;
|
|
97
97
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
@@ -226,6 +226,13 @@ export class GatewayModel {
|
|
|
226
226
|
this._tokenParam = "max_tokens";
|
|
227
227
|
return true;
|
|
228
228
|
}
|
|
229
|
+
// Some gateway routes reject either token-cap parameter. Retry once
|
|
230
|
+
// without one rather than making the model unavailable for benchmarking.
|
|
231
|
+
if (/max_tokens/.test(errText) && /not supported|unsupported/.test(errText) && "max_tokens" in body) {
|
|
232
|
+
delete body.max_tokens;
|
|
233
|
+
this._tokenParam = null;
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
229
236
|
return false;
|
|
230
237
|
}
|
|
231
238
|
}
|
package/src/models.js
CHANGED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { matrixCells } from "./project.js";
|
|
5
|
+
import { runCell as defaultRunCell, writeMetrics } from "./bench.js";
|
|
6
|
+
import { candidateTreeHash, mergeRunResult } from "./result-contract.js";
|
|
7
|
+
import { loadLedger, claimCell, selectWork, transitionCell } from "./ledger.js";
|
|
8
|
+
import { runVerifier as defaultRunVerifier } from "./verifier-runner.js";
|
|
9
|
+
import { newSessionId } from "./session.js";
|
|
10
|
+
import { refreshReport } from "./report.js";
|
|
11
|
+
|
|
12
|
+
function writeJson(file, value) {
|
|
13
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
14
|
+
fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function runProjectBench({ project, apiKey, baseUrl, quiet = false, resume = false, resumeSessionId = null, runCellFn = defaultRunCell, verifierFn = defaultRunVerifier, onCell = () => {} } = {}) {
|
|
18
|
+
const root = project.outputRoot;
|
|
19
|
+
const ledger = loadLedger(path.join(root, "benchmark.json"));
|
|
20
|
+
const desired = matrixCells({ project });
|
|
21
|
+
const work = selectWork(ledger, desired, Date.now(), { force: resume, sessionId: resumeSessionId });
|
|
22
|
+
const results = [];
|
|
23
|
+
|
|
24
|
+
for (const cell of work) {
|
|
25
|
+
const record = claimCell(ledger, cell);
|
|
26
|
+
const runDir = path.join(root, record.runPath);
|
|
27
|
+
const candidateDir = path.join(runDir, "workspace");
|
|
28
|
+
const reuseFrozen = ["candidate-frozen", "verifying"].includes(record.status);
|
|
29
|
+
const isResume = Boolean(record.sessionId && ["generating", "interrupted"].includes(record.status));
|
|
30
|
+
const sessionId = record.sessionId || newSessionId();
|
|
31
|
+
|
|
32
|
+
let generation;
|
|
33
|
+
if (reuseFrozen) {
|
|
34
|
+
const generationPath = path.join(runDir, "generation.json");
|
|
35
|
+
try {
|
|
36
|
+
generation = JSON.parse(fs.readFileSync(generationPath, "utf8"));
|
|
37
|
+
if (!fs.existsSync(candidateDir)) throw new Error("frozen candidate workspace is missing");
|
|
38
|
+
const actualHash = candidateTreeHash(candidateDir);
|
|
39
|
+
if (record.candidateSha256 && actualHash !== record.candidateSha256) {
|
|
40
|
+
throw new Error("frozen candidate workspace hash does not match the ledger");
|
|
41
|
+
}
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const failure = { status: "error", failureOwner: "infrastructure", error: String(error?.message || error) };
|
|
44
|
+
transitionCell(ledger, cell.cellKey, "failed", { failureOwner: "infrastructure", error: failure.error });
|
|
45
|
+
results.push({ cell, generation: { status: "error" }, verification: failure });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
onCell({ phase: "candidate-frozen", cell, record });
|
|
49
|
+
} else {
|
|
50
|
+
transitionCell(ledger, cell.cellKey, "generating", { sessionId, leaseAt: new Date().toISOString() });
|
|
51
|
+
onCell({ phase: "generating", cell, record });
|
|
52
|
+
try {
|
|
53
|
+
generation = await runCellFn({
|
|
54
|
+
model: cell.model,
|
|
55
|
+
reasoning: cell.reasoning,
|
|
56
|
+
apiKey,
|
|
57
|
+
baseUrl,
|
|
58
|
+
task: project.taskText,
|
|
59
|
+
taskPath: project.workspace,
|
|
60
|
+
projectMetadata: project.metadata,
|
|
61
|
+
root,
|
|
62
|
+
runDir,
|
|
63
|
+
sessionId,
|
|
64
|
+
resume: isResume,
|
|
65
|
+
cell,
|
|
66
|
+
steps: project.bench.steps,
|
|
67
|
+
wall: project.bench.wall,
|
|
68
|
+
timeout: project.bench.timeout,
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const failure = { status: "error", failureOwner: "infrastructure", error: String(error?.message || error) };
|
|
72
|
+
transitionCell(ledger, cell.cellKey, "failed", { failureOwner: "infrastructure", error: failure.error });
|
|
73
|
+
results.push({ cell, generation: { status: "error" }, verification: failure });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const candidateSha256 = candidateTreeHash(candidateDir);
|
|
78
|
+
writeJson(path.join(runDir, "generation.json"), generation);
|
|
79
|
+
transitionCell(ledger, cell.cellKey, "candidate-frozen", { candidateSha256, sessionId });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const candidateSha256 = record.candidateSha256 || candidateTreeHash(candidateDir);
|
|
83
|
+
|
|
84
|
+
let verification = {
|
|
85
|
+
status: "not_configured",
|
|
86
|
+
failureOwner: null,
|
|
87
|
+
score: null,
|
|
88
|
+
solvedScore: "NA",
|
|
89
|
+
reason: "task verifier is not present or not configured",
|
|
90
|
+
};
|
|
91
|
+
if (project.verification && generation.status === "completed") {
|
|
92
|
+
transitionCell(ledger, cell.cellKey, "verifying", { candidateSha256 });
|
|
93
|
+
try {
|
|
94
|
+
verification = await verifierFn({
|
|
95
|
+
project,
|
|
96
|
+
cell,
|
|
97
|
+
runDir,
|
|
98
|
+
candidateDir,
|
|
99
|
+
config: project.verification,
|
|
100
|
+
expected: { taskId: project.id, taskVersion: project.version, candidateSha256 },
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
verification = {
|
|
104
|
+
status: "error",
|
|
105
|
+
failureOwner: "infrastructure",
|
|
106
|
+
score: null,
|
|
107
|
+
solvedScore: "NA",
|
|
108
|
+
error: String(error?.message || error),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (verification.score && typeof verification.score.percentage === "number") {
|
|
112
|
+
verification.solvedScore = verification.score.percentage;
|
|
113
|
+
}
|
|
114
|
+
writeJson(path.join(runDir, "verifier.json"), verification);
|
|
115
|
+
} else if (generation.status !== "completed") {
|
|
116
|
+
verification = {
|
|
117
|
+
status: "not_run",
|
|
118
|
+
failureOwner: "candidate",
|
|
119
|
+
score: null,
|
|
120
|
+
solvedScore: "NA",
|
|
121
|
+
reason: "generation did not submit a candidate; verification was skipped",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const merged = mergeRunResult({
|
|
126
|
+
task: { id: project.id, version: project.version, type: project.type || "brownfield" },
|
|
127
|
+
cell,
|
|
128
|
+
generation: { ...generation, candidateSha256 },
|
|
129
|
+
verification,
|
|
130
|
+
artifacts: { runPath: record.runPath, candidateSha256 },
|
|
131
|
+
});
|
|
132
|
+
writeMetrics({
|
|
133
|
+
runDir,
|
|
134
|
+
root,
|
|
135
|
+
metrics: {
|
|
136
|
+
...generation,
|
|
137
|
+
model: cell.model,
|
|
138
|
+
reasoning: cell.reasoning,
|
|
139
|
+
verification_status: verification.status,
|
|
140
|
+
verification_score: verification.score?.percentage ?? "",
|
|
141
|
+
solved_score: verification.solvedScore ?? "NA",
|
|
142
|
+
verification_hard_pass: verification.score?.hardFailPassed ?? "",
|
|
143
|
+
failure_owner: verification.failureOwner ?? "",
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
writeJson(path.join(runDir, "result.json"), merged);
|
|
147
|
+
transitionCell(ledger, cell.cellKey, "completed", { candidateSha256, resultPath: path.join(record.runPath, "result.json"), failureOwner: verification.failureOwner });
|
|
148
|
+
results.push(merged);
|
|
149
|
+
try { refreshReport(root); } catch { /* report regeneration is best-effort per cell */ }
|
|
150
|
+
onCell({ phase: "completed", cell, record, result: merged });
|
|
151
|
+
}
|
|
152
|
+
try { refreshReport(root); } catch { /* report regeneration is best-effort */ }
|
|
153
|
+
return results;
|
|
154
|
+
}
|