@hackerrank/astra-cli 0.1.22 → 0.1.24
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 +1 -1
- package/package.json +1 -1
- package/src/agent.js +1 -1
- package/src/bench.js +79 -57
- package/src/cli.js +47 -25
- package/src/environment.js +6 -2
- package/src/evaluator-cli.js +26 -0
- package/src/evaluator.js +49 -0
- package/src/model.js +3 -3
- package/src/project-bench.js +1 -0
- package/src/project.js +5 -1
- package/src/prompts.js +24 -0
- package/src/repl.js +198 -0
- package/src/verifier-runner.js +69 -18
package/README.md
CHANGED
|
@@ -80,7 +80,7 @@ astra -m claude-sonnet-5 # start chatting
|
|
|
80
80
|
astra -m claude-sonnet-5 -y # auto-run commands (no prompts)
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
In-REPL commands: `/help /exit /clear /history /tokens /yolo`.
|
|
83
|
+
In-REPL commands: `/help /plan <task> /exit /clear /history /tokens /yolo`.
|
|
84
84
|
|
|
85
85
|
### Autonomous task run
|
|
86
86
|
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -200,7 +200,7 @@ export class Agent {
|
|
|
200
200
|
if (output.returncode !== 0) this.nFailedCommands++;
|
|
201
201
|
|
|
202
202
|
// --- 5. Submission sentinel ---
|
|
203
|
-
const submitted = checkSubmitted(output);
|
|
203
|
+
const submitted = command.trim() === `echo ${SUBMIT_SENTINEL}` ? checkSubmitted(output) : null;
|
|
204
204
|
if (submitted != null) {
|
|
205
205
|
return this.exit("Submitted", submitted);
|
|
206
206
|
}
|
package/src/bench.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
21
22
|
import path from "node:path";
|
|
22
23
|
import { spawnSync } from "node:child_process";
|
|
23
24
|
import { GatewayModel } from "./model.js";
|
|
@@ -146,7 +147,7 @@ export function seedWorkspace(workspace, { taskPath, taskFile, taskText } = {})
|
|
|
146
147
|
|
|
147
148
|
/** Directory / file names never copied into a fresh workspace (kept clean so
|
|
148
149
|
* result tarballs don't carry build cruft). */
|
|
149
|
-
const COPY_SKIP = new Set([
|
|
150
|
+
export const COPY_SKIP = new Set([
|
|
150
151
|
"bench",
|
|
151
152
|
".git",
|
|
152
153
|
"node_modules",
|
|
@@ -157,7 +158,7 @@ const COPY_SKIP = new Set([
|
|
|
157
158
|
]);
|
|
158
159
|
|
|
159
160
|
/** Recursively copy a directory (skips build cruft — see COPY_SKIP). */
|
|
160
|
-
function copyDir(from, to) {
|
|
161
|
+
export function copyDir(from, to) {
|
|
161
162
|
fs.mkdirSync(to, { recursive: true });
|
|
162
163
|
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
|
163
164
|
if (COPY_SKIP.has(entry.name)) continue;
|
|
@@ -169,6 +170,16 @@ function copyDir(from, to) {
|
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
/** Build a metrics row object from a finished agent + run info. */
|
|
173
|
+
/** Freeze an isolated workspace into a destination directory. */
|
|
174
|
+
export function freezeWorkspace(from, to) {
|
|
175
|
+
fs.mkdirSync(to, { recursive: true });
|
|
176
|
+
for (const entry of fs.readdirSync(to, { withFileTypes: true })) {
|
|
177
|
+
if (COPY_SKIP.has(entry.name)) continue;
|
|
178
|
+
fs.rmSync(path.join(to, entry.name), { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
copyDir(from, to);
|
|
181
|
+
}
|
|
182
|
+
|
|
172
183
|
export function collectMetrics({ agent, model, reasoning }) {
|
|
173
184
|
const info = agent.serialize().info;
|
|
174
185
|
const status = generationStatus(info.exit_status);
|
|
@@ -292,6 +303,7 @@ export async function runCell({
|
|
|
292
303
|
reasoning,
|
|
293
304
|
apiKey,
|
|
294
305
|
baseUrl,
|
|
306
|
+
modelClient = null,
|
|
295
307
|
task,
|
|
296
308
|
taskPath,
|
|
297
309
|
taskFile,
|
|
@@ -320,69 +332,79 @@ export async function runCell({
|
|
|
320
332
|
fs.mkdirSync(alloc.workspace, { recursive: true });
|
|
321
333
|
onStart({ ...alloc, model, reasoning });
|
|
322
334
|
|
|
335
|
+
const isolatedWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "astra-run-workspace-"));
|
|
323
336
|
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
|
-
}
|
|
331
|
-
writeProjectMetadata(alloc.dir, projectMetadata);
|
|
332
|
-
|
|
333
|
-
const gw = new GatewayModel({
|
|
334
|
-
model,
|
|
335
|
-
baseUrl,
|
|
336
|
-
apiKey,
|
|
337
|
-
modelKwargs: reasoningKwargs(reasoning),
|
|
338
|
-
});
|
|
339
|
-
const env = new LocalEnvironment({ cwd: alloc.workspace, timeout, maxOutputChars });
|
|
340
|
-
const sessionId = existingSessionId || newSessionId();
|
|
341
|
-
const agent = new Agent(gw, env, {
|
|
342
|
-
mode: "autonomous",
|
|
343
|
-
stepLimit: steps,
|
|
344
|
-
wallTimeLimitSeconds: wall,
|
|
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: [] },
|
|
350
|
-
});
|
|
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
|
-
|
|
360
|
-
let error = null;
|
|
361
337
|
try {
|
|
338
|
+
if (!resume) {
|
|
339
|
+
const seeded = seedWorkspace(isolatedWorkspace, { taskPath, taskFile, taskText: task });
|
|
340
|
+
taskText = seeded.taskText || task || "";
|
|
341
|
+
fs.writeFileSync(path.join(alloc.dir, "task.md"), taskText);
|
|
342
|
+
} else {
|
|
343
|
+
if (fs.existsSync(alloc.workspace)) copyDir(alloc.workspace, isolatedWorkspace);
|
|
344
|
+
if (!taskText) {
|
|
345
|
+
taskText = fs.existsSync(path.join(alloc.dir, "task.md")) ? fs.readFileSync(path.join(alloc.dir, "task.md"), "utf8") : "";
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
writeProjectMetadata(alloc.dir, projectMetadata);
|
|
349
|
+
|
|
350
|
+
const gw = modelClient || new GatewayModel({
|
|
351
|
+
model,
|
|
352
|
+
baseUrl,
|
|
353
|
+
apiKey,
|
|
354
|
+
modelKwargs: reasoningKwargs(reasoning),
|
|
355
|
+
});
|
|
356
|
+
const env = new LocalEnvironment({ cwd: isolatedWorkspace, timeout, maxOutputChars });
|
|
357
|
+
const sessionId = existingSessionId || newSessionId();
|
|
358
|
+
const agent = new Agent(gw, env, {
|
|
359
|
+
mode: "autonomous",
|
|
360
|
+
stepLimit: steps,
|
|
361
|
+
wallTimeLimitSeconds: wall,
|
|
362
|
+
outputPath: path.join(alloc.dir, "trajectory.json"),
|
|
363
|
+
sessionId,
|
|
364
|
+
saveSession,
|
|
365
|
+
task: taskText,
|
|
366
|
+
bench: { dir: alloc.dir, workspace: alloc.workspace, root: alloc.root, task: taskText, taskPath, model, reasoning, steps, wall, timeout, segments: [] },
|
|
367
|
+
});
|
|
368
|
+
|
|
362
369
|
if (resume && existingSessionId) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
370
|
+
const saved = loadSession(existingSessionId);
|
|
371
|
+
agent.restore(saved);
|
|
372
|
+
agent.exitStatus = null;
|
|
373
|
+
if (Number(steps) > 0) agent.stepLimit = agent.nSteps + Number(steps);
|
|
374
|
+
if (Number(wall) > 0) agent.wallTimeLimitSeconds = Math.ceil((Date.now() - agent.startTime) / 1000) + Number(wall);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let error = null;
|
|
378
|
+
try {
|
|
379
|
+
if (resume && existingSessionId) {
|
|
380
|
+
while (true) {
|
|
381
|
+
if (agent.stepLimit > 0 && agent.nSteps >= agent.stepLimit) {
|
|
382
|
+
agent.exit("LimitsExceeded", "");
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
const turn = await agent.runTurn();
|
|
386
|
+
if (turn.kind === "exit") break;
|
|
367
387
|
}
|
|
368
|
-
|
|
369
|
-
|
|
388
|
+
} else {
|
|
389
|
+
await agent.run(taskText);
|
|
370
390
|
}
|
|
371
|
-
}
|
|
372
|
-
|
|
391
|
+
} catch (err) {
|
|
392
|
+
error = String(err?.message || err);
|
|
393
|
+
if (!agent.exitStatus) agent.exit("Error", "");
|
|
373
394
|
}
|
|
374
|
-
} catch (err) {
|
|
375
|
-
error = String(err?.message || err);
|
|
376
|
-
if (!agent.exitStatus) agent.exit("Error", "");
|
|
377
|
-
}
|
|
378
395
|
|
|
379
|
-
|
|
380
|
-
if (error) metrics.exit_status = metrics.exit_status || "Error";
|
|
381
|
-
const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
|
|
396
|
+
freezeWorkspace(isolatedWorkspace, alloc.workspace);
|
|
382
397
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
398
|
+
const metrics = collectMetrics({ agent, model: gw, reasoning });
|
|
399
|
+
if (error) metrics.exit_status = metrics.exit_status || "Error";
|
|
400
|
+
const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
|
|
401
|
+
|
|
402
|
+
const result = { ...metrics, error, slug: alloc.slug, runId: alloc.runId, dir: alloc.dir, sessionId, cell, paths };
|
|
403
|
+
onDone(result);
|
|
404
|
+
return result;
|
|
405
|
+
} finally {
|
|
406
|
+
try { fs.rmSync(isolatedWorkspace, { recursive: true, force: true }); } catch {}
|
|
407
|
+
}
|
|
386
408
|
}
|
|
387
409
|
|
|
388
410
|
/**
|
package/src/cli.js
CHANGED
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
*/
|
|
75
75
|
|
|
76
76
|
import fs from "node:fs";
|
|
77
|
+
import os from "node:os";
|
|
77
78
|
import path from "node:path";
|
|
78
79
|
import { GatewayModel } from "./model.js";
|
|
79
80
|
import { LocalEnvironment } from "./environment.js";
|
|
@@ -92,6 +93,8 @@ import { runRepl } from "./repl.js";
|
|
|
92
93
|
import { ProjectConfigError, loadProject } from "./project.js";
|
|
93
94
|
import {
|
|
94
95
|
allocateRun,
|
|
96
|
+
copyDir,
|
|
97
|
+
freezeWorkspace,
|
|
95
98
|
seedWorkspace,
|
|
96
99
|
collectMetrics,
|
|
97
100
|
writeMetrics,
|
|
@@ -202,7 +205,10 @@ async function main() {
|
|
|
202
205
|
let task = args.task;
|
|
203
206
|
if (args["task-file"]) task = fs.readFileSync(args["task-file"], "utf8");
|
|
204
207
|
let resumeBench = resumeDoc?.bench ?? null;
|
|
205
|
-
|
|
208
|
+
// Project benches resume from their task ledger, not from an interactive
|
|
209
|
+
// session document. Applying this guard to both paths stranded a leased
|
|
210
|
+
// project cell before its first model reply.
|
|
211
|
+
if (args.bench && args.resume && !args.project && !resumeBench) {
|
|
206
212
|
console.error("\x1b[31m[astra] request_error: this session is not a bench run.\x1b[0m");
|
|
207
213
|
process.exit(2);
|
|
208
214
|
}
|
|
@@ -391,6 +397,7 @@ async function main() {
|
|
|
391
397
|
// workspace under bench/<model-name-reasoning>/run-NN/. The agent's commands
|
|
392
398
|
// run inside that workspace and metrics are recorded when it finishes.
|
|
393
399
|
let benchRun = null;
|
|
400
|
+
let isolatedWorkspace = null;
|
|
394
401
|
if (mode === "autonomous" && resumeBench) {
|
|
395
402
|
if (!fs.existsSync(resumeBench.dir) || !fs.existsSync(resumeBench.workspace)) {
|
|
396
403
|
console.error("\x1b[31m[astra] request_error: saved bench workspace is missing.\x1b[0m");
|
|
@@ -403,11 +410,14 @@ async function main() {
|
|
|
403
410
|
slug: path.basename(path.dirname(resumeBench.dir)),
|
|
404
411
|
runId: path.basename(resumeBench.dir),
|
|
405
412
|
};
|
|
413
|
+
isolatedWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "astra-run-workspace-"));
|
|
414
|
+
if (fs.existsSync(benchRun.workspace)) copyDir(benchRun.workspace, isolatedWorkspace);
|
|
406
415
|
resumeBench.segments = Array.isArray(resumeBench.segments) ? resumeBench.segments : [];
|
|
407
416
|
resumeBench.segments.push({ started_at: new Date().toISOString(), resumed: true });
|
|
408
417
|
} else if (mode === "autonomous" && !resumeDoc) {
|
|
409
418
|
benchRun = allocateRun({ model: modelId, reasoning, root: args["bench-root"] });
|
|
410
|
-
|
|
419
|
+
isolatedWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "astra-run-workspace-"));
|
|
420
|
+
const seeded = seedWorkspace(isolatedWorkspace, {
|
|
411
421
|
taskPath: args.path,
|
|
412
422
|
taskFile: args["task-file"],
|
|
413
423
|
taskText: task,
|
|
@@ -431,7 +441,7 @@ async function main() {
|
|
|
431
441
|
};
|
|
432
442
|
}
|
|
433
443
|
|
|
434
|
-
const cmdCwd = benchRun?.workspace || cwd;
|
|
444
|
+
const cmdCwd = isolatedWorkspace || benchRun?.workspace || cwd;
|
|
435
445
|
const env = new LocalEnvironment({
|
|
436
446
|
cwd: cmdCwd,
|
|
437
447
|
timeout: Number(args.timeout),
|
|
@@ -478,29 +488,35 @@ async function main() {
|
|
|
478
488
|
// an isolated workspace under bench/<model-name-reasoning>/run-NN/.
|
|
479
489
|
const runBench = async (taskText, log) => {
|
|
480
490
|
const alloc = allocateRun({ model: modelId, reasoning, root: args["bench-root"] });
|
|
481
|
-
|
|
482
|
-
fs.writeFileSync(path.join(alloc.dir, "task.md"), taskText || "");
|
|
483
|
-
log(`[astra] bench ${alloc.slug}/${alloc.runId} → ${alloc.workspace}`);
|
|
484
|
-
const benchEnv = new LocalEnvironment({
|
|
485
|
-
cwd: alloc.workspace,
|
|
486
|
-
timeout: Number(args.timeout),
|
|
487
|
-
maxOutputChars: Number(args["max-output"]),
|
|
488
|
-
});
|
|
489
|
-
const benchAgent = new Agent(model, benchEnv, {
|
|
490
|
-
mode: "autonomous",
|
|
491
|
-
stepLimit: Number(args.steps),
|
|
492
|
-
wallTimeLimitSeconds: Number(args.wall),
|
|
493
|
-
outputPath: path.join(alloc.dir, "trajectory.json"),
|
|
494
|
-
});
|
|
495
|
-
const res = await benchAgent.run(taskText);
|
|
496
|
-
const metrics = collectMetrics({ agent: benchAgent, model, reasoning });
|
|
497
|
-
const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
|
|
498
|
-
log(`[astra] bench done: exit=${res.exit_status} · metrics → ${paths.runMetrics}`);
|
|
491
|
+
const benchIsolated = fs.mkdtempSync(path.join(os.tmpdir(), "astra-run-workspace-"));
|
|
499
492
|
try {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
493
|
+
seedWorkspace(benchIsolated, { taskText });
|
|
494
|
+
fs.writeFileSync(path.join(alloc.dir, "task.md"), taskText || "");
|
|
495
|
+
log(`[astra] bench ${alloc.slug}/${alloc.runId} → ${alloc.workspace}`);
|
|
496
|
+
const benchEnv = new LocalEnvironment({
|
|
497
|
+
cwd: benchIsolated,
|
|
498
|
+
timeout: Number(args.timeout),
|
|
499
|
+
maxOutputChars: Number(args["max-output"]),
|
|
500
|
+
});
|
|
501
|
+
const benchAgent = new Agent(model, benchEnv, {
|
|
502
|
+
mode: "autonomous",
|
|
503
|
+
stepLimit: Number(args.steps),
|
|
504
|
+
wallTimeLimitSeconds: Number(args.wall),
|
|
505
|
+
outputPath: path.join(alloc.dir, "trajectory.json"),
|
|
506
|
+
});
|
|
507
|
+
const res = await benchAgent.run(taskText);
|
|
508
|
+
freezeWorkspace(benchIsolated, alloc.workspace);
|
|
509
|
+
const metrics = collectMetrics({ agent: benchAgent, model, reasoning });
|
|
510
|
+
const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
|
|
511
|
+
log(`[astra] bench done: exit=${res.exit_status} · metrics → ${paths.runMetrics}`);
|
|
512
|
+
try {
|
|
513
|
+
const report = refreshReport(alloc.root);
|
|
514
|
+
log(`[astra] report → ${report.html}`);
|
|
515
|
+
} catch (err) {
|
|
516
|
+
log(`[astra] report generation skipped: ${err.message}`);
|
|
517
|
+
}
|
|
518
|
+
} finally {
|
|
519
|
+
try { fs.rmSync(benchIsolated, { recursive: true, force: true }); } catch {}
|
|
504
520
|
}
|
|
505
521
|
};
|
|
506
522
|
await runRepl(agent, { model, autoRun: !!args.yolo, fresh: !resumeDoc, reasoning, runBench });
|
|
@@ -544,6 +560,7 @@ async function main() {
|
|
|
544
560
|
}
|
|
545
561
|
agent.save();
|
|
546
562
|
if (benchRun) {
|
|
563
|
+
if (isolatedWorkspace) freezeWorkspace(isolatedWorkspace, benchRun.workspace);
|
|
547
564
|
writeMetrics({
|
|
548
565
|
runDir: benchRun.dir,
|
|
549
566
|
root: benchRun.root,
|
|
@@ -552,6 +569,11 @@ async function main() {
|
|
|
552
569
|
try { refreshReport(benchRun.root); } catch {}
|
|
553
570
|
}
|
|
554
571
|
throw error;
|
|
572
|
+
} finally {
|
|
573
|
+
if (benchRun && isolatedWorkspace) {
|
|
574
|
+
freezeWorkspace(isolatedWorkspace, benchRun.workspace);
|
|
575
|
+
try { fs.rmSync(isolatedWorkspace, { recursive: true, force: true }); } catch {}
|
|
576
|
+
}
|
|
555
577
|
}
|
|
556
578
|
agent.save();
|
|
557
579
|
|
package/src/environment.js
CHANGED
|
@@ -17,11 +17,12 @@ export class LocalEnvironment {
|
|
|
17
17
|
* @param {object} [opts.env] extra env vars merged over process.env
|
|
18
18
|
* @param {number} [opts.maxOutputChars] cap on observation size (default 16000)
|
|
19
19
|
*/
|
|
20
|
-
constructor({ cwd = process.cwd(), timeout = 60, env = {}, maxOutputChars = 16000 } = {}) {
|
|
20
|
+
constructor({ cwd = process.cwd(), timeout = 60, env = {}, maxOutputChars = 16000, inheritEnv = true } = {}) {
|
|
21
21
|
this.cwd = cwd;
|
|
22
22
|
this.timeout = timeout;
|
|
23
23
|
this.env = env;
|
|
24
24
|
this.maxOutputChars = maxOutputChars;
|
|
25
|
+
this.inheritEnv = inheritEnv;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -33,7 +34,10 @@ export class LocalEnvironment {
|
|
|
33
34
|
return new Promise((resolve) => {
|
|
34
35
|
const child = spawn("bash", ["-c", command], {
|
|
35
36
|
cwd: this.cwd,
|
|
36
|
-
|
|
37
|
+
// Evaluators must never leak their gateway credential into a command
|
|
38
|
+
// that reads an untrusted candidate workspace. Normal agent sessions
|
|
39
|
+
// retain the historical inherited environment.
|
|
40
|
+
env: this.inheritEnv ? { ...process.env, ...this.env } : { ...this.env },
|
|
37
41
|
// New process group so we can kill the whole tree on timeout.
|
|
38
42
|
detached: process.platform !== "win32",
|
|
39
43
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { runEvaluator } from "./evaluator.js";
|
|
4
|
+
|
|
5
|
+
function options(argv) {
|
|
6
|
+
const result = {};
|
|
7
|
+
for (let index = 2; index < argv.length; index += 2) {
|
|
8
|
+
const key = argv[index];
|
|
9
|
+
if (!key?.startsWith("--") || argv[index + 1] == null) throw new Error(`invalid evaluator option: ${key || ""}`);
|
|
10
|
+
result[key.slice(2)] = argv[index + 1];
|
|
11
|
+
}
|
|
12
|
+
for (const key of ["prompt-file", "workspace", "output", "model"]) if (!result[key]) throw new Error(`--${key} is required`);
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const args = options(process.argv);
|
|
18
|
+
const prompt = fs.readFileSync(args["prompt-file"], "utf8");
|
|
19
|
+
const result = await runEvaluator({ prompt, workspace: args.workspace, outputPath: args.output, model: args.model,
|
|
20
|
+
reasoning: args.reasoning || "high", apiKey: process.env.ASTRA_GATEWAY_API_KEY,
|
|
21
|
+
baseUrl: process.env.ASTRA_GATEWAY_BASE_URL, timeout: args.timeout || 0, steps: args.steps || 0, wall: args.wall || 0 });
|
|
22
|
+
process.stdout.write(JSON.stringify(result.metrics) + "\n");
|
|
23
|
+
} catch (error) {
|
|
24
|
+
process.stderr.write(`ASTRA evaluator error: ${error.message}\n`);
|
|
25
|
+
process.exitCode = 2;
|
|
26
|
+
}
|
package/src/evaluator.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** ASTRA-owned agentic evaluator for task verifiers. */
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { GatewayModel } from "./model.js";
|
|
5
|
+
import { LocalEnvironment } from "./environment.js";
|
|
6
|
+
import { Agent } from "./agent.js";
|
|
7
|
+
|
|
8
|
+
const SAFE_COMMAND_ENV = new Set([
|
|
9
|
+
"PATH", "HOME", "TMPDIR", "LANG", "LANGUAGE", "LC_ALL", "TZ",
|
|
10
|
+
"ADMIN_USERNAME", "ADMIN_PASSWORD", "DATABASE_URL", "MONGODB_URI", "REDIS_URL",
|
|
11
|
+
"PLAYWRIGHT_BROWSERS_PATH", "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export function evaluatorCommandEnvironment(source = process.env) {
|
|
15
|
+
return Object.fromEntries(Object.entries(source).filter(([key]) =>
|
|
16
|
+
SAFE_COMMAND_ENV.has(key) || key.startsWith("LC_"),
|
|
17
|
+
));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function evaluatorTask({ prompt, outputPath }) {
|
|
21
|
+
return `You are ASTRA's trusted verifier evaluator. Inspect the application and its source to answer the evaluation request below. Treat the candidate workspace as read-only: do not edit, delete, install into, or format files there. You may read files and make the explicitly requested HTTP/database probes.\n\nReturn exactly one JSON object that satisfies the requested response shape. Write only that JSON object to ${outputPath} (outside the candidate workspace), then run: printf 'COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT'. Keep commands small.\n\nEvaluation request:\n${prompt}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function runEvaluator({ prompt, workspace, outputPath, model, reasoning, apiKey, baseUrl, timeout = 0, steps = 0, wall = 0 }) {
|
|
25
|
+
const destination = path.resolve(outputPath);
|
|
26
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
27
|
+
try { fs.rmSync(destination, { force: true }); } catch {}
|
|
28
|
+
const gateway = new GatewayModel({
|
|
29
|
+
model,
|
|
30
|
+
apiKey,
|
|
31
|
+
baseUrl,
|
|
32
|
+
modelKwargs: reasoning ? { reasoning_effort: reasoning } : {},
|
|
33
|
+
});
|
|
34
|
+
const env = new LocalEnvironment({
|
|
35
|
+
cwd: path.resolve(workspace), timeout: Number(timeout), maxOutputChars: 12000,
|
|
36
|
+
env: evaluatorCommandEnvironment(), inheritEnv: false,
|
|
37
|
+
});
|
|
38
|
+
const agent = new Agent(gateway, env, {
|
|
39
|
+
mode: "autonomous", stepLimit: Number(steps), wallTimeLimitSeconds: Number(wall),
|
|
40
|
+
});
|
|
41
|
+
const result = await agent.run(evaluatorTask({ prompt, outputPath: destination }));
|
|
42
|
+
if (result.exit_status !== "Submitted") {
|
|
43
|
+
throw new Error(`ASTRA evaluator did not submit: ${result.exit_status}`);
|
|
44
|
+
}
|
|
45
|
+
if (!fs.existsSync(destination)) throw new Error("ASTRA evaluator did not produce its JSON answer");
|
|
46
|
+
const answer = fs.readFileSync(destination, "utf8");
|
|
47
|
+
JSON.parse(answer);
|
|
48
|
+
return { answer, metrics: { calls: gateway.nCalls, retries: gateway.nRetries, costUsd: gateway.totalCostUsd } };
|
|
49
|
+
}
|
package/src/model.js
CHANGED
|
@@ -42,7 +42,7 @@ export class GatewayModel {
|
|
|
42
42
|
* @param {number} [opts.maxRetries]
|
|
43
43
|
* @param {(info:object)=>void} [opts.onRetry] called before each retry sleep
|
|
44
44
|
*/
|
|
45
|
-
constructor({ model, baseUrl, apiKey, modelKwargs = {}, maxRetries = 5, maxTokens = 8192, requestTimeoutMs =
|
|
45
|
+
constructor({ model, baseUrl, apiKey, modelKwargs = {}, maxRetries = 5, maxTokens = 8192, requestTimeoutMs = 0, onRetry } = {}) {
|
|
46
46
|
if (!model) throw new Error("GatewayModel: `model` is required");
|
|
47
47
|
this.model = model;
|
|
48
48
|
this.maxTokens = maxTokens;
|
|
@@ -98,8 +98,8 @@ export class GatewayModel {
|
|
|
98
98
|
let lastErr;
|
|
99
99
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
100
100
|
try {
|
|
101
|
-
const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs);
|
|
102
|
-
const signal = this.signal ? AbortSignal.any([this.signal, timeoutSignal]) : timeoutSignal;
|
|
101
|
+
const timeoutSignal = this.requestTimeoutMs > 0 ? AbortSignal.timeout(this.requestTimeoutMs) : null;
|
|
102
|
+
const signal = timeoutSignal && this.signal ? AbortSignal.any([this.signal, timeoutSignal]) : (timeoutSignal || this.signal || undefined);
|
|
103
103
|
const res = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
104
104
|
method: "POST",
|
|
105
105
|
headers: {
|
package/src/project-bench.js
CHANGED
|
@@ -97,6 +97,7 @@ export async function runProjectBench({ project, apiKey, baseUrl, quiet = false,
|
|
|
97
97
|
runDir,
|
|
98
98
|
candidateDir,
|
|
99
99
|
config: project.verification,
|
|
100
|
+
gateway: { apiKey, baseUrl },
|
|
100
101
|
expected: { taskId: project.id, taskVersion: project.version, candidateSha256 },
|
|
101
102
|
});
|
|
102
103
|
} catch (error) {
|
package/src/project.js
CHANGED
|
@@ -75,7 +75,11 @@ function verificationConfig(value) {
|
|
|
75
75
|
baseUrl: verification.base_url === undefined ? null : requireString(verification.base_url, "verification.base_url"),
|
|
76
76
|
readinessUrl: verification.readiness_url === undefined ? null : requireString(verification.readiness_url, "verification.readiness_url"),
|
|
77
77
|
tokenEnv: verification.token_env === undefined ? null : requireString(verification.token_env, "verification.token_env"),
|
|
78
|
-
|
|
78
|
+
evaluatorModel: verification.evaluator_model === undefined ? null : requireString(verification.evaluator_model, "verification.evaluator_model"),
|
|
79
|
+
evaluatorReasoning: verification.evaluator_reasoning === undefined ? null : requireString(verification.evaluator_reasoning, "verification.evaluator_reasoning"),
|
|
80
|
+
// Zero means no verifier deadline. Tasks with agentic verification choose
|
|
81
|
+
// completion over an arbitrary wall-clock cutoff.
|
|
82
|
+
timeoutSeconds: positiveInteger(verification.timeout_seconds, "verification.timeout_seconds", 0, { allowZero: true }),
|
|
79
83
|
report: verification.report === undefined ? "verifier-report.json" : requireString(verification.report, "verification.report"),
|
|
80
84
|
};
|
|
81
85
|
}
|
package/src/prompts.js
CHANGED
|
@@ -102,3 +102,27 @@ export function render(template, vars) {
|
|
|
102
102
|
key in vars && vars[key] != null ? String(vars[key]) : ""
|
|
103
103
|
);
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
export const PLAN_PROMPT_TEMPLATE = `Please explore the workspace and create a clear, actionable plan for this task:
|
|
107
|
+
|
|
108
|
+
Task:
|
|
109
|
+
{{task}}
|
|
110
|
+
|
|
111
|
+
Instructions for planning:
|
|
112
|
+
1. Run read-only commands (e.g. ls, find, grep, cat) to explore and understand the relevant files.
|
|
113
|
+
2. Present a structured plan with:
|
|
114
|
+
- **Objective & Scope**: Summary of what needs to be done.
|
|
115
|
+
- **Files to Modify / Create**: List of target files.
|
|
116
|
+
- **Self-Testing & Verification Strategy**: Exact test commands or verification scripts to write/run.
|
|
117
|
+
- **Self-Review Checklist**: Code quality, edge cases, and cleanliness checks.
|
|
118
|
+
3. Finish with a chat response presenting the complete plan so the user can review it before execution.`;
|
|
119
|
+
|
|
120
|
+
export const AUTONOMOUS_EXECUTION_PROMPT = `The plan has been approved. Execute the plan end-to-end now in autonomous mode without asking further questions.
|
|
121
|
+
|
|
122
|
+
Workflow requirements:
|
|
123
|
+
1. Implement the changes with clean, focused edits.
|
|
124
|
+
2. Self-verify by writing or running test scripts to prove the solution works.
|
|
125
|
+
3. Review your changes with \`git diff\` (or inspect modified files) to ensure no regressions, unintended files, or debug prints remain.
|
|
126
|
+
4. If any tests or checks fail, diagnose and fix the errors.
|
|
127
|
+
5. When completely finished, verified, and reviewed, run:
|
|
128
|
+
\`echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT\``;
|
package/src/repl.js
CHANGED
|
@@ -17,6 +17,7 @@ import readline from "node:readline";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { ask, saveAgentPrefs } from "./config.js";
|
|
19
19
|
import { AVAILABLE_MODELS, REASONING_LEVELS } from "./models.js";
|
|
20
|
+
import { PLAN_PROMPT_TEMPLATE, AUTONOMOUS_EXECUTION_PROMPT, render } from "./prompts.js";
|
|
20
21
|
|
|
21
22
|
const C = {
|
|
22
23
|
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
@@ -517,6 +518,7 @@ Interactive commands:
|
|
|
517
518
|
/help show this help
|
|
518
519
|
/exit, /quit leave (session is saved)
|
|
519
520
|
/clear start a fresh conversation (new context)
|
|
521
|
+
/plan <task> plan a task, review it, then execute autonomously with self-review
|
|
520
522
|
/history print the message history
|
|
521
523
|
/tokens show token usage so far
|
|
522
524
|
/yolo toggle auto-run of commands (no confirmation)
|
|
@@ -643,6 +645,25 @@ async function runStickyRepl(agent, model, yolo, intro = [], { runBench = null }
|
|
|
643
645
|
if (cmd === "history") { printHistory(agent, log); continue; }
|
|
644
646
|
if (cmd === "tokens") { log(tokensLine(model)); continue; }
|
|
645
647
|
if (cmd === "yolo") { yolo = !yolo; log(C.dim(`[astra] auto-run ${yolo ? "ON" : "OFF"}.`)); refresh(); continue; }
|
|
648
|
+
if (cmd === "plan") {
|
|
649
|
+
const taskText = line.slice(line.indexOf("plan") + 4).trim();
|
|
650
|
+
const prevYolo = yolo;
|
|
651
|
+
await handlePlanWorkflow({
|
|
652
|
+
taskText,
|
|
653
|
+
agent,
|
|
654
|
+
model,
|
|
655
|
+
log,
|
|
656
|
+
readInput: (promptText) => screen.readLine(promptText, { guardEnter: true, echo: true }),
|
|
657
|
+
startBusy: (label) => screen.startBusy(label),
|
|
658
|
+
stopBusy: () => screen.stopBusy(),
|
|
659
|
+
interrupt,
|
|
660
|
+
setYolo: (v) => { yolo = v; },
|
|
661
|
+
onProgress: () => refresh(),
|
|
662
|
+
});
|
|
663
|
+
yolo = prevYolo;
|
|
664
|
+
refresh();
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
646
667
|
log(C.dim(`[astra] unknown command: /${cmd} (try /help)`));
|
|
647
668
|
continue;
|
|
648
669
|
}
|
|
@@ -742,6 +763,25 @@ async function runPlainRepl(agent, model, yolo) {
|
|
|
742
763
|
if (cmd === "history") { printHistory(agent, log); continue; }
|
|
743
764
|
if (cmd === "tokens") { console.error(tokensLine(model)); continue; }
|
|
744
765
|
if (cmd === "yolo") { yolo = !yolo; console.error(C.dim(`[astra] auto-run ${yolo ? "ON" : "OFF"}.`)); continue; }
|
|
766
|
+
if (cmd === "plan") {
|
|
767
|
+
const taskText = line.slice(line.indexOf("plan") + 4).trim();
|
|
768
|
+
const prevYolo = yolo;
|
|
769
|
+
await handlePlanWorkflow({
|
|
770
|
+
taskText,
|
|
771
|
+
agent,
|
|
772
|
+
model,
|
|
773
|
+
log: (t) => console.error(t),
|
|
774
|
+
readInput: (promptText) => new Promise((res) => rl.question(promptText, res)),
|
|
775
|
+
startBusy: () => {},
|
|
776
|
+
stopBusy: () => {},
|
|
777
|
+
interrupt: { aborted: false, quit: false },
|
|
778
|
+
setYolo: (v) => { yolo = v; },
|
|
779
|
+
onProgress: () => printFooterInline(agent, model, yolo),
|
|
780
|
+
});
|
|
781
|
+
yolo = prevYolo;
|
|
782
|
+
printFooterInline(agent, model, yolo);
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
745
785
|
console.error(C.dim(`[astra] unknown command: /${cmd} (try /help)`));
|
|
746
786
|
continue;
|
|
747
787
|
}
|
|
@@ -859,3 +899,161 @@ function tokensLine(model) {
|
|
|
859
899
|
const dollars = usd < 0.01 ? "$" + usd.toFixed(5) : "$" + usd.toFixed(4);
|
|
860
900
|
return C.dim(`[astra] tokens: prompt=${pt} completion=${ct} total=${pt + ct} · cost=${dollars} (${src})`);
|
|
861
901
|
}
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Run the plan-and-execute workflow:
|
|
906
|
+
* 1. Prompt model to explore workspace and draft a structured plan.
|
|
907
|
+
* 2. Pause and prompt user for approval / feedback.
|
|
908
|
+
* 3. On approval, execute autonomously to completion with auto-review and auto-fix.
|
|
909
|
+
*/
|
|
910
|
+
export async function handlePlanWorkflow({
|
|
911
|
+
taskText,
|
|
912
|
+
agent,
|
|
913
|
+
model,
|
|
914
|
+
log,
|
|
915
|
+
readInput,
|
|
916
|
+
startBusy,
|
|
917
|
+
stopBusy,
|
|
918
|
+
interrupt,
|
|
919
|
+
setYolo = () => {},
|
|
920
|
+
onProgress = () => {},
|
|
921
|
+
}) {
|
|
922
|
+
let task = taskText;
|
|
923
|
+
if (!task) {
|
|
924
|
+
task = (await readInput(C.yellow("Enter task to plan: "))).trim();
|
|
925
|
+
}
|
|
926
|
+
if (!task) {
|
|
927
|
+
log(C.dim("[astra] plan cancelled (empty task)."));
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
log(C.cyan(`[astra] exploring workspace and drafting plan for: "${task}"...`));
|
|
932
|
+
agent.addUserMessage(render(PLAN_PROMPT_TEMPLATE, { task }));
|
|
933
|
+
|
|
934
|
+
model._abort = new AbortController();
|
|
935
|
+
model.signal = model._abort.signal;
|
|
936
|
+
startBusy("planning");
|
|
937
|
+
try {
|
|
938
|
+
await driveUntilChat(agent, log, interrupt);
|
|
939
|
+
} finally {
|
|
940
|
+
stopBusy();
|
|
941
|
+
model.signal = null;
|
|
942
|
+
model._abort = null;
|
|
943
|
+
}
|
|
944
|
+
if (interrupt?.quit || interrupt?.aborted) return;
|
|
945
|
+
|
|
946
|
+
// Review gate loop
|
|
947
|
+
let approved = false;
|
|
948
|
+
while (!approved) {
|
|
949
|
+
const ans = (await readInput(
|
|
950
|
+
C.yellow("\nApprove plan and begin autonomous execution? [Y/n/feedback] ")
|
|
951
|
+
)).trim();
|
|
952
|
+
const lower = ans.toLowerCase();
|
|
953
|
+
|
|
954
|
+
if (lower === "n" || lower === "no" || lower === "cancel" || lower === "abort") {
|
|
955
|
+
log(C.dim("[astra] plan cancelled. Returning to interactive mode."));
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (ans === "" || lower === "y" || lower === "yes") {
|
|
959
|
+
approved = true;
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Feedback provided: refine plan
|
|
964
|
+
log(C.cyan(`[astra] updating plan with feedback: "${ans}"...`));
|
|
965
|
+
agent.addUserMessage(
|
|
966
|
+
`Here is feedback on the plan: "${ans}". Please refine the plan accordingly and present the updated plan.`
|
|
967
|
+
);
|
|
968
|
+
model._abort = new AbortController();
|
|
969
|
+
model.signal = model._abort.signal;
|
|
970
|
+
startBusy("planning");
|
|
971
|
+
try {
|
|
972
|
+
await driveUntilChat(agent, log, interrupt);
|
|
973
|
+
} finally {
|
|
974
|
+
stopBusy();
|
|
975
|
+
model.signal = null;
|
|
976
|
+
model._abort = null;
|
|
977
|
+
}
|
|
978
|
+
if (interrupt?.quit || interrupt?.aborted) return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Execute autonomously
|
|
982
|
+
log(C.green("\n[astra] plan approved! Starting unattended autonomous execution with auto-review & auto-fix..."));
|
|
983
|
+
const prevMode = agent.mode;
|
|
984
|
+
setYolo(true);
|
|
985
|
+
agent.mode = "autonomous";
|
|
986
|
+
agent.addUserMessage(AUTONOMOUS_EXECUTION_PROMPT);
|
|
987
|
+
|
|
988
|
+
model._abort = new AbortController();
|
|
989
|
+
model.signal = model._abort.signal;
|
|
990
|
+
startBusy("executing plan");
|
|
991
|
+
let status = "Completed";
|
|
992
|
+
try {
|
|
993
|
+
status = await driveAutonomous(agent, log, interrupt);
|
|
994
|
+
} catch (err) {
|
|
995
|
+
log(C.red(`[astra] error during autonomous execution: ${err?.message || err}`));
|
|
996
|
+
status = "Error";
|
|
997
|
+
} finally {
|
|
998
|
+
stopBusy();
|
|
999
|
+
model.signal = null;
|
|
1000
|
+
model._abort = null;
|
|
1001
|
+
agent.mode = prevMode;
|
|
1002
|
+
agent.exitStatus = null;
|
|
1003
|
+
onProgress();
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
log(C.green(`\n[astra] plan execution finished (${status}). You are now back in interactive mode.`));
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Drive turns autonomously until the model submits with the completion sentinel.
|
|
1011
|
+
*/
|
|
1012
|
+
export async function driveAutonomous(agent, log, interrupt = null) {
|
|
1013
|
+
while (true) {
|
|
1014
|
+
if (interrupt && interrupt.aborted) {
|
|
1015
|
+
log(C.yellow("[astra] autonomous execution interrupted — returning to prompt."));
|
|
1016
|
+
return "Interrupted";
|
|
1017
|
+
}
|
|
1018
|
+
let turn;
|
|
1019
|
+
try {
|
|
1020
|
+
turn = await agent.runTurn();
|
|
1021
|
+
} catch (err) {
|
|
1022
|
+
if (err && (err.name === "GatewayError" || err.name === "ContextWindowError")) {
|
|
1023
|
+
log(C.red(`[astra] ${err.message}`));
|
|
1024
|
+
return err.name;
|
|
1025
|
+
}
|
|
1026
|
+
throw err;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (interrupt && interrupt.aborted) {
|
|
1030
|
+
log(C.yellow("[astra] autonomous execution interrupted — returning to prompt."));
|
|
1031
|
+
return "Interrupted";
|
|
1032
|
+
}
|
|
1033
|
+
if (turn.kind === "exit") {
|
|
1034
|
+
log(C.dim(`[astra] autonomous run ended: ${turn.exit_status}`));
|
|
1035
|
+
return turn.exit_status;
|
|
1036
|
+
}
|
|
1037
|
+
if (turn.kind === "command") {
|
|
1038
|
+
const rc = turn.returncode;
|
|
1039
|
+
const tag = rc === 0 ? C.dim(`[rc ${rc}]`) : C.red(`[rc ${rc}]`);
|
|
1040
|
+
log(tag + "\n" + indent(turn.output));
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
if (turn.kind === "declined") {
|
|
1044
|
+
log(C.dim("[astra] command skipped."));
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
if (turn.kind === "format_error") {
|
|
1048
|
+
log(C.dim(`[astra] (reprompting: ${turn.error})`));
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
if (turn.kind === "chat") {
|
|
1052
|
+
log("\n" + C.cyan("astra › ") + turn.content.trim());
|
|
1053
|
+
agent.addUserMessage(
|
|
1054
|
+
"Continue executing the approved plan in autonomous mode. When all changes are implemented, tested, and self-reviewed, run:\necho COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT"
|
|
1055
|
+
);
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
package/src/verifier-runner.js
CHANGED
|
@@ -13,36 +13,52 @@ function redactOutput(value) {
|
|
|
13
13
|
|
|
14
14
|
function runCommand(command, { cwd, env, timeoutSeconds }) {
|
|
15
15
|
return new Promise((resolve) => {
|
|
16
|
-
const child = spawn(command, { cwd, env, shell: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
16
|
+
const child = spawn(command, { cwd, env, shell: true, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
|
|
17
17
|
let stdout = "";
|
|
18
18
|
let stderr = "";
|
|
19
19
|
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
20
20
|
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
21
21
|
let timedOut = false;
|
|
22
|
-
const
|
|
22
|
+
const limit = Number(timeoutSeconds);
|
|
23
|
+
const timer = limit > 0 ? setTimeout(() => {
|
|
23
24
|
timedOut = true;
|
|
24
|
-
child
|
|
25
|
-
setTimeout(() => child
|
|
26
|
-
},
|
|
25
|
+
stopProcessTree(child, "SIGTERM");
|
|
26
|
+
setTimeout(() => stopProcessTree(child, "SIGKILL"), 250);
|
|
27
|
+
}, limit * 1000) : null;
|
|
27
28
|
child.on("close", (code, signal) => {
|
|
28
|
-
clearTimeout(timer);
|
|
29
|
+
if (timer) clearTimeout(timer);
|
|
29
30
|
resolve({ code, signal, timedOut, stdout, stderr });
|
|
30
31
|
});
|
|
31
32
|
child.on("error", (error) => {
|
|
32
|
-
clearTimeout(timer);
|
|
33
|
+
if (timer) clearTimeout(timer);
|
|
33
34
|
resolve({ code: null, signal: null, timedOut: false, stdout, stderr: `${stderr}${error.message}` });
|
|
34
35
|
});
|
|
35
36
|
});
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
function stopProcessTree(child, signal) {
|
|
40
|
+
try {
|
|
41
|
+
if (process.platform !== "win32" && child.pid) process.kill(-child.pid, signal);
|
|
42
|
+
else child.kill(signal);
|
|
43
|
+
} catch {
|
|
44
|
+
// The command may already have exited between the timer and the signal.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
function startProcess(command, { cwd, env }) {
|
|
39
|
-
|
|
49
|
+
const child = spawn(command, { cwd, env, shell: true, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] });
|
|
50
|
+
let output = "";
|
|
51
|
+
child.stdout.on("data", (chunk) => { output += chunk; });
|
|
52
|
+
child.stderr.on("data", (chunk) => { output += chunk; });
|
|
53
|
+
child.startupOutput = () => redactOutput(output);
|
|
54
|
+
return child;
|
|
40
55
|
}
|
|
41
56
|
|
|
42
57
|
async function acquireVerifierLock(projectRoot, timeoutSeconds) {
|
|
43
58
|
const lockPath = path.join(projectRoot, ".astra", "verifier.lock");
|
|
44
59
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
45
|
-
const
|
|
60
|
+
const limit = Number(timeoutSeconds);
|
|
61
|
+
const deadline = limit > 0 ? Date.now() + limit * 1000 : Infinity;
|
|
46
62
|
while (Date.now() < deadline) {
|
|
47
63
|
try {
|
|
48
64
|
const descriptor = fs.openSync(lockPath, "wx");
|
|
@@ -71,21 +87,25 @@ async function acquireVerifierLock(projectRoot, timeoutSeconds) {
|
|
|
71
87
|
throw new Error("timed out waiting for the task verifier lock");
|
|
72
88
|
}
|
|
73
89
|
|
|
74
|
-
async function waitReady(url, timeoutSeconds) {
|
|
75
|
-
const
|
|
90
|
+
async function waitReady(url, timeoutSeconds, candidateProcess) {
|
|
91
|
+
const limit = Number(timeoutSeconds);
|
|
92
|
+
const deadline = limit > 0 ? Date.now() + limit * 1000 : Infinity;
|
|
76
93
|
while (Date.now() < deadline) {
|
|
94
|
+
if (candidateProcess && candidateProcess.exitCode !== null) {
|
|
95
|
+
return { ready: false, exited: true, output: candidateProcess.startupOutput?.() || "" };
|
|
96
|
+
}
|
|
77
97
|
try {
|
|
78
98
|
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
|
|
79
|
-
if (response.ok) return true;
|
|
99
|
+
if (response.ok) return { ready: true, exited: false, output: "" };
|
|
80
100
|
} catch {
|
|
81
101
|
// Candidate is still starting.
|
|
82
102
|
}
|
|
83
103
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
84
104
|
}
|
|
85
|
-
return false;
|
|
105
|
+
return { ready: false, exited: false, output: candidateProcess?.startupOutput?.() || "" };
|
|
86
106
|
}
|
|
87
107
|
|
|
88
|
-
export async function runVerifier({ project, cell, runDir, candidateDir, config, expected = {} }) {
|
|
108
|
+
export async function runVerifier({ project, cell, runDir, candidateDir, config, expected = {}, gateway = {} }) {
|
|
89
109
|
const started = Date.now();
|
|
90
110
|
fs.mkdirSync(runDir, { recursive: true });
|
|
91
111
|
const reportPath = path.resolve(runDir, config.report || "verifier.json");
|
|
@@ -107,6 +127,14 @@ export async function runVerifier({ project, cell, runDir, candidateDir, config,
|
|
|
107
127
|
ASTRA_TASK_TYPE: project.type || "brownfield",
|
|
108
128
|
ASTRA_CELL_KEY: cell.cellKey,
|
|
109
129
|
ASTRA_PROJECT_ROOT: project.root,
|
|
130
|
+
// The task verifier invokes ASTRA's evaluator bridge for dynamic discovery
|
|
131
|
+
// and source judgment. Keep this explicit and task-scoped: the candidate
|
|
132
|
+
// command environment is separately scrubbed by the verifier.
|
|
133
|
+
ASTRA_EVALUATOR_BIN: new URL("./evaluator-cli.js", import.meta.url).pathname,
|
|
134
|
+
...(gateway.apiKey ? { ASTRA_GATEWAY_API_KEY: gateway.apiKey } : {}),
|
|
135
|
+
...(gateway.baseUrl ? { ASTRA_GATEWAY_BASE_URL: gateway.baseUrl } : {}),
|
|
136
|
+
ASTRA_EVALUATOR_MODEL: config.evaluatorModel || cell.model,
|
|
137
|
+
ASTRA_EVALUATOR_REASONING: config.evaluatorReasoning || cell.reasoning || "high",
|
|
110
138
|
...(config.baseUrl ? { SCIM_BASE_URL: config.baseUrl } : {}),
|
|
111
139
|
...(config.tokenEnv && process.env[config.tokenEnv] ? { [config.tokenEnv]: process.env[config.tokenEnv] } : {}),
|
|
112
140
|
};
|
|
@@ -121,8 +149,31 @@ export async function runVerifier({ project, cell, runDir, candidateDir, config,
|
|
|
121
149
|
candidateProcess = startProcess(config.startCommand, { cwd: candidateDir, env });
|
|
122
150
|
// A cold Docker build legitimately exceeds a minute. Give the candidate
|
|
123
151
|
// a bounded five-minute startup window, still capped by task timeout.
|
|
124
|
-
if (config.readinessUrl
|
|
125
|
-
|
|
152
|
+
if (config.readinessUrl) {
|
|
153
|
+
const readiness = await waitReady(config.readinessUrl, config.timeoutSeconds > 0 ? Math.min(config.timeoutSeconds, 300) : 0, candidateProcess);
|
|
154
|
+
if (!readiness.ready) {
|
|
155
|
+
const durationSeconds = (Date.now() - started) / 1000;
|
|
156
|
+
const detail = readiness.output;
|
|
157
|
+
if (readiness.exited) {
|
|
158
|
+
const portCollision = /EADDRINUSE|address already in use|port is already allocated/i.test(detail);
|
|
159
|
+
return {
|
|
160
|
+
status: "error",
|
|
161
|
+
failureOwner: portCollision ? "infrastructure" : "candidate",
|
|
162
|
+
solvedScore: "NA",
|
|
163
|
+
durationSeconds,
|
|
164
|
+
error: detail
|
|
165
|
+
? `${portCollision ? "candidate startup blocked by host-port collision" : "candidate startup failed"}: ${detail}`
|
|
166
|
+
: "candidate startup process exited before readiness",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
status: "error",
|
|
171
|
+
failureOwner: "infrastructure",
|
|
172
|
+
solvedScore: "NA",
|
|
173
|
+
durationSeconds,
|
|
174
|
+
error: detail ? `candidate readiness timed out: ${detail}` : "candidate readiness timed out",
|
|
175
|
+
};
|
|
176
|
+
}
|
|
126
177
|
}
|
|
127
178
|
}
|
|
128
179
|
const commandResult = await runCommand(config.command, {
|
|
@@ -163,9 +214,9 @@ export async function runVerifier({ project, cell, runDir, candidateDir, config,
|
|
|
163
214
|
};
|
|
164
215
|
} finally {
|
|
165
216
|
if (config.stopCommand) {
|
|
166
|
-
await runCommand(config.stopCommand, { cwd: candidateDir, env, timeoutSeconds: Math.min(Number(config.timeoutSeconds)
|
|
217
|
+
await runCommand(config.stopCommand, { cwd: candidateDir, env, timeoutSeconds: config.timeoutSeconds > 0 ? Math.min(Number(config.timeoutSeconds), 30) : 0 });
|
|
167
218
|
}
|
|
168
|
-
if (candidateProcess && !candidateProcess.killed) candidateProcess
|
|
219
|
+
if (candidateProcess && !candidateProcess.killed) stopProcessTree(candidateProcess, "SIGTERM");
|
|
169
220
|
if (releaseLock) releaseLock();
|
|
170
221
|
}
|
|
171
222
|
}
|