@alisio/alisio-code 0.5.2 → 0.7.0
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/dist/analysis.d.ts +24 -1
- package/dist/analysis.js +100 -1
- package/dist/eval.d.ts +101 -0
- package/dist/eval.js +690 -0
- package/dist/forge.d.ts +13 -0
- package/dist/forge.js +46 -0
- package/dist/main.js +94 -4
- package/dist/tui/artifacts.d.ts +1 -1
- package/dist/tui/artifacts.js +5 -1
- package/dist/tui/components.js +5 -3
- package/dist/tui/state.d.ts +17 -1
- package/dist/tui/state.js +63 -3
- package/package.json +7 -7
package/dist/eval.js
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `alisio eval list|run|record|compare|variants|export` (specs/010 5.4, Phases 3-4). Every function
|
|
3
|
+
* returns the exit code and talks only through the injected `EvalEnv`, so tests drive them without
|
|
4
|
+
* a process:
|
|
5
|
+
*
|
|
6
|
+
* 0 completed (independent of which tasks passed) 1 usage or infrastructure error
|
|
7
|
+
* 2 a replay diverged from its cassette 3 `compare` of incompatible suites
|
|
8
|
+
*
|
|
9
|
+
* `record` runs each task once live and writes `<suite>/cassettes/<taskId>.cassette.jsonl`;
|
|
10
|
+
* `run --mode replay` serves those cassettes (no provider, no credentials, no network). `export`
|
|
11
|
+
* writes one local, scrubbed JSON bundle of a stored run (nothing is uploaded). Reports go to `<stateHome>/eval/<runId>/` (or `--out DIR`): report.json, report.md, runs.jsonl.
|
|
12
|
+
* A suite given with `--suite-dir` is third-party code: it needs `--yes` (or an interactive
|
|
13
|
+
* confirmation) because a subprocess is not a sandbox.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { homedir, tmpdir } from "node:os";
|
|
17
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
const DEFAULT_K = 5;
|
|
20
|
+
const DEFAULT_SEED = 0x5eed1234;
|
|
21
|
+
class UsageError extends Error {
|
|
22
|
+
}
|
|
23
|
+
const failure = (env, message, code = 1) => {
|
|
24
|
+
env.err(`${message}\n`);
|
|
25
|
+
return code;
|
|
26
|
+
};
|
|
27
|
+
function positiveInt(value, name, fallback, min) {
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
return fallback;
|
|
30
|
+
if (!/^\d+$/.test(value) || Number(value) < min || !Number.isSafeInteger(Number(value)))
|
|
31
|
+
throw new UsageError(`Invalid --${name} "${value}": expected an integer >= ${min}.`);
|
|
32
|
+
return Number(value);
|
|
33
|
+
}
|
|
34
|
+
function parseRates(flags) {
|
|
35
|
+
if (flags.inputRate === undefined && flags.outputRate === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
if (flags.inputRate === undefined || flags.outputRate === undefined)
|
|
38
|
+
throw new UsageError("--input-rate and --output-rate go together (USD per million tokens); without both no cost is estimated.");
|
|
39
|
+
const parse = (text, name) => {
|
|
40
|
+
const value = Number(text);
|
|
41
|
+
if (text.trim() === "" || !Number.isFinite(value) || value < 0)
|
|
42
|
+
throw new UsageError(`Invalid --${name} "${text}": expected a non-negative number.`);
|
|
43
|
+
return value;
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
inputPerMTokUsd: parse(flags.inputRate, "input-rate"),
|
|
47
|
+
outputPerMTokUsd: parse(flags.outputRate, "output-rate"),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function invokerOf(flags) {
|
|
51
|
+
return {
|
|
52
|
+
write: !!flags.allowWrite,
|
|
53
|
+
process: !!flags.allowProcess,
|
|
54
|
+
external: !!flags.allowExternal,
|
|
55
|
+
analysis: !!flags.allowAnalysis,
|
|
56
|
+
...(flags.readOnly ? { readOnly: true } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// ---------------------------------------------------------------------------------------------
|
|
60
|
+
// Suites
|
|
61
|
+
// ---------------------------------------------------------------------------------------------
|
|
62
|
+
/**
|
|
63
|
+
* The bundled suites (smoke-coding, tool-sequences, long-context) live in `tests/fixtures/eval` of
|
|
64
|
+
* a source checkout and are NOT part of the npm package or the standalone binary (specs/010 O4).
|
|
65
|
+
*/
|
|
66
|
+
const NO_BUNDLED_HINT = "The bundled suites exist only in a source checkout (tests/fixtures/eval); they are not in the npm package or the binary. Pass --suite-dir DIR (a suite or a directory of suites) instead.";
|
|
67
|
+
/** `root` itself when it holds a suite.json, otherwise its subdirectories that do. */
|
|
68
|
+
function suitesUnder(root, origin) {
|
|
69
|
+
if (!existsSync(root) || !statSync(root).isDirectory())
|
|
70
|
+
return [];
|
|
71
|
+
if (existsSync(join(root, "suite.json")))
|
|
72
|
+
return [{ dir: root, origin }];
|
|
73
|
+
return readdirSync(root, { withFileTypes: true })
|
|
74
|
+
.filter((e) => e.isDirectory() && existsSync(join(root, e.name, "suite.json")))
|
|
75
|
+
.map((e) => ({ dir: join(root, e.name), origin }))
|
|
76
|
+
.sort((a, b) => (a.dir < b.dir ? -1 : 1));
|
|
77
|
+
}
|
|
78
|
+
function discover(env, flags) {
|
|
79
|
+
return [
|
|
80
|
+
...(env.bundledDir ? suitesUnder(resolve(env.bundledDir), "bundled") : []),
|
|
81
|
+
...(flags.suiteDir ? suitesUnder(resolve(flags.suiteDir), "suite-dir") : []),
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
export async function evalList(env, flags) {
|
|
85
|
+
const { loadSuite } = await import("@alisio/core");
|
|
86
|
+
if (flags.suiteDir && !existsSync(resolve(flags.suiteDir)))
|
|
87
|
+
return failure(env, `--suite-dir ${flags.suiteDir} does not exist.`);
|
|
88
|
+
const rows = discover(env, flags).map((found) => {
|
|
89
|
+
try {
|
|
90
|
+
const { suite } = loadSuite(found.dir);
|
|
91
|
+
return {
|
|
92
|
+
id: suite.suiteId,
|
|
93
|
+
version: suite.suiteVersion,
|
|
94
|
+
tasks: suite.tasks.length,
|
|
95
|
+
description: suite.description,
|
|
96
|
+
origin: found.origin,
|
|
97
|
+
dir: found.dir,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
return {
|
|
102
|
+
id: basename(found.dir),
|
|
103
|
+
origin: found.origin,
|
|
104
|
+
dir: found.dir,
|
|
105
|
+
error: error instanceof Error ? error.message : String(error),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
if (flags.json)
|
|
110
|
+
env.out(`${JSON.stringify({ suites: rows })}\n`);
|
|
111
|
+
else if (rows.length === 0)
|
|
112
|
+
env.out(`No suites found. ${NO_BUNDLED_HINT}\n`);
|
|
113
|
+
else
|
|
114
|
+
for (const row of rows)
|
|
115
|
+
env.out("error" in row
|
|
116
|
+
? `${row.id} [${row.origin}] invalid: ${row.error}\n`
|
|
117
|
+
: `${row.id} ${row.version} ${row.tasks} task${row.tasks === 1 ? "" : "s"} [${row.origin}] ${row.description}\n`);
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
async function resolveSuite(env, flags, name) {
|
|
121
|
+
const { loadSuite } = await import("@alisio/core");
|
|
122
|
+
const found = discover(env, flags);
|
|
123
|
+
const matches = found.flatMap((candidate) => {
|
|
124
|
+
if (basename(candidate.dir) === name)
|
|
125
|
+
return [candidate];
|
|
126
|
+
try {
|
|
127
|
+
return loadSuite(candidate.dir).suite.suiteId === name ? [candidate] : [];
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
const [match] = matches;
|
|
134
|
+
if (!match)
|
|
135
|
+
return failure(env, `Unknown suite "${name}". ${found.length > 0
|
|
136
|
+
? `Available: ${found.map((f) => basename(f.dir)).join(", ")}.`
|
|
137
|
+
: NO_BUNDLED_HINT}`);
|
|
138
|
+
return { loaded: loadSuite(match.dir), origin: match.origin };
|
|
139
|
+
}
|
|
140
|
+
/** A third-party suite is code that runs with the user's privileges: warn and ask. */
|
|
141
|
+
async function confirmThirdParty(env, flags, suiteDir) {
|
|
142
|
+
env.err(`Warning: the suite in ${suiteDir} comes from --suite-dir. Its verifier commands and the agent tools it enables run with YOUR privileges; a subprocess is not a sandbox. Only run suites you trust.\n`);
|
|
143
|
+
if (flags.yes)
|
|
144
|
+
return true;
|
|
145
|
+
if (!env.interactive) {
|
|
146
|
+
env.err("Refusing to run it without confirmation: pass --yes to accept this in a non-interactive run.\n");
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
return env.confirm("Run this suite? [y/N] ");
|
|
150
|
+
}
|
|
151
|
+
// ---------------------------------------------------------------------------------------------
|
|
152
|
+
// Report files
|
|
153
|
+
// ---------------------------------------------------------------------------------------------
|
|
154
|
+
function writeFiles(dir, files) {
|
|
155
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
156
|
+
for (const [name, content] of Object.entries(files))
|
|
157
|
+
writeFileSync(join(dir, name), content);
|
|
158
|
+
}
|
|
159
|
+
const jsonl = (values) => values.map((v) => `${JSON.stringify(v)}\n`).join("");
|
|
160
|
+
/** Adds the estimated cost of each run when rates were supplied (never otherwise). */
|
|
161
|
+
function withEstimate(core, record, rates) {
|
|
162
|
+
const costUsd = rates ? core.estimateRunCost(record, rates) : undefined;
|
|
163
|
+
return costUsd === undefined
|
|
164
|
+
? record
|
|
165
|
+
: { ...record, estimated: { ...record.estimated, costUsd } };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Shown only for runs that ended `error`, which are infrastructure failures by definition (fixture
|
|
169
|
+
* copy, application creation, provider network/auth, a verifier that cannot start). Failures the
|
|
170
|
+
* model causes are `fail` runs and never reach this message.
|
|
171
|
+
*/
|
|
172
|
+
function infrastructureMessage(errored, what, dir) {
|
|
173
|
+
return `${errored} run(s) ended in an infrastructure error and are excluded from the rates (a comparison with excluded runs claims no improvement or regression); the ${what} ${what === "report" ? "was" : "were"} written to ${dir}. Fix the cause and rerun.`;
|
|
174
|
+
}
|
|
175
|
+
/** Shared validation of `run` and `variants`; a number is an exit code. */
|
|
176
|
+
async function prepare(env, flags, suiteName) {
|
|
177
|
+
// A replay is deterministic: repeating one cassette k times measures nothing new, so it runs once.
|
|
178
|
+
const k = positiveInt(flags.k, "k", flags.mode === "replay" ? 1 : DEFAULT_K, 1);
|
|
179
|
+
const seedBase = positiveInt(flags.seed, "seed", DEFAULT_SEED, 0);
|
|
180
|
+
const rates = parseRates(flags);
|
|
181
|
+
if (flags.mode !== undefined && flags.mode !== "live" && flags.mode !== "replay")
|
|
182
|
+
return failure(env, `Invalid --mode "${flags.mode}": expected "live" or "replay".`);
|
|
183
|
+
const taskIds = flags.tasks
|
|
184
|
+
?.split(",")
|
|
185
|
+
.map((id) => id.trim())
|
|
186
|
+
.filter(Boolean);
|
|
187
|
+
if (flags.tasks !== undefined && (taskIds?.length ?? 0) === 0)
|
|
188
|
+
return failure(env, "--tasks needs at least one task id (a comma-separated list).");
|
|
189
|
+
const suite = await resolveSuite(env, flags, suiteName);
|
|
190
|
+
if (typeof suite === "number")
|
|
191
|
+
return suite;
|
|
192
|
+
if (suite.origin === "suite-dir" && !(await confirmThirdParty(env, flags, suite.loaded.dir)))
|
|
193
|
+
return 1;
|
|
194
|
+
return {
|
|
195
|
+
loaded: suite.loaded,
|
|
196
|
+
k,
|
|
197
|
+
seedBase,
|
|
198
|
+
...(taskIds ? { taskIds } : {}),
|
|
199
|
+
...(rates ? { rates } : {}),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function reportDir(env, flags, runId) {
|
|
203
|
+
return flags.out ? resolve(flags.out) : join(env.stateDir, "eval", runId);
|
|
204
|
+
}
|
|
205
|
+
// ---------------------------------------------------------------------------------------------
|
|
206
|
+
// Cassettes
|
|
207
|
+
// ---------------------------------------------------------------------------------------------
|
|
208
|
+
const cassetteDirOf = (flags, loaded) => flags.cassetteDir ? resolve(flags.cassetteDir) : join(loaded.dir, "cassettes");
|
|
209
|
+
const cassetteFile = (dir, taskId) => join(dir, `${taskId}.cassette.jsonl`);
|
|
210
|
+
/** Ids of the tasks a command works on (all of the suite, or `--tasks`); unknown ids are refused. */
|
|
211
|
+
function selectedTaskIds(loaded, taskIds) {
|
|
212
|
+
const all = loaded.suite.tasks.map((t) => t.id);
|
|
213
|
+
if (!taskIds)
|
|
214
|
+
return all;
|
|
215
|
+
const unknown = taskIds.find((id) => !all.includes(id));
|
|
216
|
+
if (unknown)
|
|
217
|
+
throw new UsageError(`Unknown task "${unknown}" in suite "${loaded.suite.suiteId}". Tasks: ${all.join(", ")}.`);
|
|
218
|
+
return [...new Set(taskIds)];
|
|
219
|
+
}
|
|
220
|
+
/** Values of environment variables that look like credentials (a cassette holding one is refused). */
|
|
221
|
+
function envSecrets() {
|
|
222
|
+
return Object.entries(process.env)
|
|
223
|
+
.filter(([name, value]) => /(API_?KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i.test(name) && value)
|
|
224
|
+
.map(([, value]) => value);
|
|
225
|
+
}
|
|
226
|
+
/** What `run --mode replay` prints for a run that diverged: the call, the field and the excerpts. */
|
|
227
|
+
function describeDivergence(record) {
|
|
228
|
+
const divergence = record.divergence;
|
|
229
|
+
if (!divergence)
|
|
230
|
+
return "";
|
|
231
|
+
const header = `[eval] DIVERGED ${record.taskId}#${record.repetition + 1}: ${divergence.message}\n`;
|
|
232
|
+
const entries = divergence.diff
|
|
233
|
+
.map((d) => ` call ${d.call}: ${d.field}\n recorded: ${JSON.stringify(d.excerpt.before)}\n now: ${JSON.stringify(d.excerpt.after)}\n`)
|
|
234
|
+
.join("");
|
|
235
|
+
return header + entries;
|
|
236
|
+
}
|
|
237
|
+
export async function evalRun(env, flags, suiteName) {
|
|
238
|
+
try {
|
|
239
|
+
const prepared = await prepare(env, flags, suiteName);
|
|
240
|
+
if (typeof prepared === "number")
|
|
241
|
+
return prepared;
|
|
242
|
+
const core = await import("@alisio/core");
|
|
243
|
+
const replaying = flags.mode === "replay";
|
|
244
|
+
const cassettes = new Map();
|
|
245
|
+
if (replaying) {
|
|
246
|
+
const dir = cassetteDirOf(flags, prepared.loaded);
|
|
247
|
+
for (const id of selectedTaskIds(prepared.loaded, prepared.taskIds)) {
|
|
248
|
+
const path = cassetteFile(dir, id);
|
|
249
|
+
if (!existsSync(path))
|
|
250
|
+
return failure(env, `No cassette for task "${id}" at ${path}. Record it with: alisio eval record ${suiteName} --tasks ${id}`);
|
|
251
|
+
cassettes.set(id, core.readCassette(path));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const runtime = await env
|
|
255
|
+
.openRuntime(flags, replaying ? { requireProvider: false } : undefined)
|
|
256
|
+
.catch((error) => {
|
|
257
|
+
throw new UsageError(error instanceof Error ? error.message : String(error));
|
|
258
|
+
});
|
|
259
|
+
try {
|
|
260
|
+
const rates = prepared.rates;
|
|
261
|
+
const result = await core.runEvaluation({
|
|
262
|
+
suite: prepared.loaded,
|
|
263
|
+
...(replaying
|
|
264
|
+
? {
|
|
265
|
+
mode: "replay",
|
|
266
|
+
cassetteFor: (task) => cassettes.get(task.id),
|
|
267
|
+
}
|
|
268
|
+
: { provider: runtime.provider }),
|
|
269
|
+
config: runtime.config,
|
|
270
|
+
invoker: invokerOf(flags),
|
|
271
|
+
k: prepared.k,
|
|
272
|
+
seedBase: prepared.seedBase,
|
|
273
|
+
builtins: runtime.builtins,
|
|
274
|
+
...(prepared.taskIds ? { taskIds: prepared.taskIds } : {}),
|
|
275
|
+
...(flags.allowSuiteFull ? { allowSuiteFull: true } : {}),
|
|
276
|
+
...(flags.keep ? { keep: true } : {}),
|
|
277
|
+
...(runtime.resolveAgent ? { resolveAgent: runtime.resolveAgent } : {}),
|
|
278
|
+
...(env.tmpRoot ? { tmpRoot: env.tmpRoot } : {}),
|
|
279
|
+
onRecord: (record, progress) => {
|
|
280
|
+
env.err(`[eval] ${progress.done}/${progress.total} ${record.taskId}#${record.repetition + 1}: ${record.status}${record.error ? ` (${record.error})` : ""}\n`);
|
|
281
|
+
if (flags.json)
|
|
282
|
+
env.out(`${JSON.stringify(withEstimate(core, record, rates))}\n`);
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
const records = result.records.map((r) => withEstimate(core, r, rates));
|
|
286
|
+
const report = core.buildReport(records, {
|
|
287
|
+
seedBase: prepared.seedBase,
|
|
288
|
+
k: prepared.k,
|
|
289
|
+
...(rates ? { rates } : {}),
|
|
290
|
+
});
|
|
291
|
+
const markdown = core.renderReportMarkdown(report);
|
|
292
|
+
const dir = reportDir(env, flags, env.newRunId());
|
|
293
|
+
writeFiles(dir, {
|
|
294
|
+
"report.json": `${JSON.stringify(report, null, 2)}\n`,
|
|
295
|
+
"report.md": markdown,
|
|
296
|
+
"runs.jsonl": jsonl(records),
|
|
297
|
+
});
|
|
298
|
+
if (flags.json)
|
|
299
|
+
env.out(`${JSON.stringify(report)}\n`);
|
|
300
|
+
else
|
|
301
|
+
env.out(`${markdown}\nReport written to ${dir}\n`);
|
|
302
|
+
for (const kept of result.keptDirs)
|
|
303
|
+
env.err(`Kept run directory: ${kept}\n`);
|
|
304
|
+
for (const record of records) {
|
|
305
|
+
const cassette = cassettes.get(record.taskId);
|
|
306
|
+
if (cassette && cassette.header.configHash !== record.configHash)
|
|
307
|
+
env.err(`[eval] warning: task ${record.taskId} was recorded under configuration ${cassette.header.configHash} and replayed under ${record.configHash}; a difference may show up as a divergence.\n`);
|
|
308
|
+
}
|
|
309
|
+
const diverged = records.filter((r) => r.divergence);
|
|
310
|
+
if (diverged.length > 0) {
|
|
311
|
+
env.err(diverged.map(describeDivergence).join(""));
|
|
312
|
+
env.err(`${diverged.length} replay(s) diverged from their cassette; the report was written to ${dir}. Re-record the cassette if the change is intended: alisio eval record ${suiteName}\n`);
|
|
313
|
+
return 2;
|
|
314
|
+
}
|
|
315
|
+
if (report.summary.errored > 0)
|
|
316
|
+
return failure(env, infrastructureMessage(report.summary.errored, "report", dir));
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
await runtime.close();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
return failure(env, error instanceof Error ? error.message : String(error));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// ---------------------------------------------------------------------------------------------
|
|
328
|
+
// record
|
|
329
|
+
// ---------------------------------------------------------------------------------------------
|
|
330
|
+
/**
|
|
331
|
+
* Runs each selected task ONCE live and writes its cassette. A task whose run errored, whose
|
|
332
|
+
* recording is unstable, or whose cassette holds a secret or is over the size limits writes
|
|
333
|
+
* nothing and makes the command exit 1; the other tasks are still recorded.
|
|
334
|
+
*/
|
|
335
|
+
export async function evalRecord(env, flags, suiteName) {
|
|
336
|
+
try {
|
|
337
|
+
if (flags.mode !== undefined && flags.mode !== "live")
|
|
338
|
+
return failure(env, "`alisio eval record` always runs live; --mode is not accepted.");
|
|
339
|
+
if (flags.k !== undefined && flags.k !== "1")
|
|
340
|
+
return failure(env, "`alisio eval record` runs each task once; drop --k.");
|
|
341
|
+
const prepared = await prepare(env, { ...flags, k: "1" }, suiteName);
|
|
342
|
+
if (typeof prepared === "number")
|
|
343
|
+
return prepared;
|
|
344
|
+
const core = await import("@alisio/core");
|
|
345
|
+
const dir = cassetteDirOf(flags, prepared.loaded);
|
|
346
|
+
const runtime = await env.openRuntime(flags).catch((error) => {
|
|
347
|
+
throw new UsageError(error instanceof Error ? error.message : String(error));
|
|
348
|
+
});
|
|
349
|
+
try {
|
|
350
|
+
const recorders = new Map();
|
|
351
|
+
const result = await core.runEvaluation({
|
|
352
|
+
suite: prepared.loaded,
|
|
353
|
+
provider: runtime.provider,
|
|
354
|
+
mode: "record",
|
|
355
|
+
cassetteFor: (task) => {
|
|
356
|
+
const recorder = new core.CassetteRecorder();
|
|
357
|
+
recorders.set(task.id, recorder);
|
|
358
|
+
return recorder;
|
|
359
|
+
},
|
|
360
|
+
config: runtime.config,
|
|
361
|
+
invoker: invokerOf(flags),
|
|
362
|
+
k: 1,
|
|
363
|
+
seedBase: prepared.seedBase,
|
|
364
|
+
builtins: runtime.builtins,
|
|
365
|
+
...(prepared.taskIds ? { taskIds: prepared.taskIds } : {}),
|
|
366
|
+
...(flags.allowSuiteFull ? { allowSuiteFull: true } : {}),
|
|
367
|
+
...(flags.keep ? { keep: true } : {}),
|
|
368
|
+
...(runtime.resolveAgent ? { resolveAgent: runtime.resolveAgent } : {}),
|
|
369
|
+
...(env.tmpRoot ? { tmpRoot: env.tmpRoot } : {}),
|
|
370
|
+
onRecord: (record, progress) => {
|
|
371
|
+
env.err(`[eval] ${progress.done}/${progress.total} ${record.taskId}: ${record.status}${record.error ? ` (${record.error})` : ""}\n`);
|
|
372
|
+
if (flags.json)
|
|
373
|
+
env.out(`${JSON.stringify(record)}\n`);
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
const secrets = [...(runtime.secrets ?? []), ...envSecrets()];
|
|
377
|
+
const failures = [];
|
|
378
|
+
const written = [];
|
|
379
|
+
for (const record of result.records) {
|
|
380
|
+
const recorder = recorders.get(record.taskId);
|
|
381
|
+
if (!recorder)
|
|
382
|
+
continue;
|
|
383
|
+
if (record.status === "error") {
|
|
384
|
+
failures.push(`${record.taskId}: not recorded (${record.error ?? "the run errored"}).`);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
const path = cassetteFile(dir, record.taskId);
|
|
389
|
+
core.writeCassette(path, recorder.toCassette({
|
|
390
|
+
suiteId: record.suiteId,
|
|
391
|
+
taskId: record.taskId,
|
|
392
|
+
harnessVersion: record.harnessVersion,
|
|
393
|
+
configHash: record.configHash,
|
|
394
|
+
recordedAt: record.startedAt,
|
|
395
|
+
provider: record.provider,
|
|
396
|
+
model: record.model,
|
|
397
|
+
}), { secrets });
|
|
398
|
+
written.push(path);
|
|
399
|
+
if (record.status !== "pass")
|
|
400
|
+
env.err(`[eval] warning: ${record.taskId} ended "${record.status}"; its cassette records that run as it happened.\n`);
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
failures.push(`${record.taskId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
for (const kept of result.keptDirs)
|
|
407
|
+
env.err(`Kept run directory: ${kept}\n`);
|
|
408
|
+
if (!flags.json)
|
|
409
|
+
for (const path of written)
|
|
410
|
+
env.out(`Recorded ${path}\n`);
|
|
411
|
+
if (failures.length > 0) {
|
|
412
|
+
env.err(`${failures.join("\n")}\n`);
|
|
413
|
+
return 1;
|
|
414
|
+
}
|
|
415
|
+
return 0;
|
|
416
|
+
}
|
|
417
|
+
finally {
|
|
418
|
+
await runtime.close();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
return failure(env, error instanceof Error ? error.message : String(error));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// ---------------------------------------------------------------------------------------------
|
|
426
|
+
// compare
|
|
427
|
+
// ---------------------------------------------------------------------------------------------
|
|
428
|
+
async function readReport(path) {
|
|
429
|
+
const { parseReport } = await import("@alisio/core");
|
|
430
|
+
const file = existsSync(path) && statSync(path).isDirectory() ? join(path, "report.json") : path;
|
|
431
|
+
let text;
|
|
432
|
+
try {
|
|
433
|
+
text = readFileSync(file, "utf8");
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
throw new UsageError(`Cannot read the report ${file}. Pass a report.json or the directory that holds it.`);
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
return parseReport(JSON.parse(text));
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
throw new UsageError(error instanceof SyntaxError ? `${file} is not valid JSON.` : error.message);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
export async function evalCompare(env, flags, pathA, pathB) {
|
|
446
|
+
try {
|
|
447
|
+
const core = await import("@alisio/core");
|
|
448
|
+
const [a, b] = [await readReport(resolve(pathA)), await readReport(resolve(pathB))];
|
|
449
|
+
let comparison;
|
|
450
|
+
try {
|
|
451
|
+
comparison = core.compareReports(a, b);
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
if (error instanceof core.EvalCompareError)
|
|
455
|
+
return failure(env, error.message, 3);
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
env.out(flags.json ? `${JSON.stringify(comparison)}\n` : core.renderComparisonMarkdown(comparison));
|
|
459
|
+
return 0;
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
return failure(env, error instanceof Error ? error.message : String(error));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// ---------------------------------------------------------------------------------------------
|
|
466
|
+
// variants
|
|
467
|
+
// ---------------------------------------------------------------------------------------------
|
|
468
|
+
export async function evalVariants(env, flags, suiteName) {
|
|
469
|
+
try {
|
|
470
|
+
if (flags.mode === "replay")
|
|
471
|
+
return failure(env, "`alisio eval variants` compares strategies against a live model; replay serves a fixed recording and cannot tell variants apart.");
|
|
472
|
+
if (!flags.variants)
|
|
473
|
+
return failure(env, "Pass --variants compare.json (the variants file).");
|
|
474
|
+
const core = await import("@alisio/core");
|
|
475
|
+
const file = core.loadVariantsFile(resolve(flags.variants));
|
|
476
|
+
const prepared = await prepare(env, flags, suiteName);
|
|
477
|
+
if (typeof prepared === "number")
|
|
478
|
+
return prepared;
|
|
479
|
+
const suiteId = prepared.loaded.suite.suiteId;
|
|
480
|
+
if (file.suite !== suiteId && file.suite !== suiteName)
|
|
481
|
+
return failure(env, `The variants file is for suite "${file.suite}" but the suite is "${suiteId}". Use a file written for this suite.`);
|
|
482
|
+
const runtime = await env.openRuntime(flags).catch((error) => {
|
|
483
|
+
throw new UsageError(error instanceof Error ? error.message : String(error));
|
|
484
|
+
});
|
|
485
|
+
try {
|
|
486
|
+
// Every override is validated before the first run: one bad key rejects the file.
|
|
487
|
+
const variants = core.resolveVariants(file, runtime.config);
|
|
488
|
+
const rates = prepared.rates;
|
|
489
|
+
const root = reportDir(env, flags, env.newRunId());
|
|
490
|
+
const reports = [];
|
|
491
|
+
const kept = [];
|
|
492
|
+
// Interleaved at each plan step (A,B then B,A ...) so provider drift is spread over the
|
|
493
|
+
// variants instead of landing on whichever ran last. Same tasks, repetitions and seeds.
|
|
494
|
+
const results = await core.runVariantEvaluations({
|
|
495
|
+
suite: prepared.loaded,
|
|
496
|
+
provider: runtime.provider,
|
|
497
|
+
variants,
|
|
498
|
+
invoker: invokerOf(flags),
|
|
499
|
+
k: prepared.k,
|
|
500
|
+
seedBase: prepared.seedBase,
|
|
501
|
+
builtins: runtime.builtins,
|
|
502
|
+
...(prepared.taskIds ? { taskIds: prepared.taskIds } : {}),
|
|
503
|
+
...(flags.allowSuiteFull ? { allowSuiteFull: true } : {}),
|
|
504
|
+
...(flags.keep ? { keep: true } : {}),
|
|
505
|
+
...(runtime.resolveAgent ? { resolveAgent: runtime.resolveAgent } : {}),
|
|
506
|
+
...(env.tmpRoot ? { tmpRoot: env.tmpRoot } : {}),
|
|
507
|
+
onRecord: (id, record, progress) => env.err(`[eval] ${id} ${progress.done}/${progress.total} ${record.taskId}#${record.repetition + 1}: ${record.status}\n`),
|
|
508
|
+
});
|
|
509
|
+
for (const result of results) {
|
|
510
|
+
const overrides = file.variants.find((v) => v.id === result.id)?.overrides ?? {};
|
|
511
|
+
kept.push(...result.keptDirs);
|
|
512
|
+
const records = result.records.map((r) => withEstimate(core, r, rates));
|
|
513
|
+
const report = core.buildReport(records, {
|
|
514
|
+
seedBase: prepared.seedBase,
|
|
515
|
+
k: prepared.k,
|
|
516
|
+
variant: { id: result.id, overrides },
|
|
517
|
+
...(rates ? { rates } : {}),
|
|
518
|
+
});
|
|
519
|
+
writeFiles(join(root, result.id), {
|
|
520
|
+
"report.json": `${JSON.stringify(report, null, 2)}\n`,
|
|
521
|
+
"report.md": core.renderReportMarkdown(report),
|
|
522
|
+
"runs.jsonl": jsonl(records),
|
|
523
|
+
});
|
|
524
|
+
if (flags.json)
|
|
525
|
+
env.out(`${JSON.stringify({ type: "variant_report", variant: result.id, report })}\n`);
|
|
526
|
+
reports.push({ id: result.id, report, overrides });
|
|
527
|
+
}
|
|
528
|
+
let comparison;
|
|
529
|
+
try {
|
|
530
|
+
comparison = core.compareVariants(reports);
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
if (error instanceof core.EvalCompareError)
|
|
534
|
+
return failure(env, error.message, 3);
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
const markdown = core.renderVariantComparisonMarkdown(comparison);
|
|
538
|
+
writeFiles(root, {
|
|
539
|
+
"comparison.json": `${JSON.stringify(comparison, null, 2)}\n`,
|
|
540
|
+
"comparison.md": markdown,
|
|
541
|
+
});
|
|
542
|
+
if (flags.json)
|
|
543
|
+
env.out(`${JSON.stringify({ type: "comparison", comparison })}\n`);
|
|
544
|
+
else
|
|
545
|
+
env.out(`${markdown}\nReports written to ${root}\n`);
|
|
546
|
+
for (const dir of kept)
|
|
547
|
+
env.err(`Kept run directory: ${dir}\n`);
|
|
548
|
+
const errored = reports.reduce((n, r) => n + r.report.summary.errored, 0);
|
|
549
|
+
if (errored > 0)
|
|
550
|
+
return failure(env, infrastructureMessage(errored, "reports", root));
|
|
551
|
+
return 0;
|
|
552
|
+
}
|
|
553
|
+
finally {
|
|
554
|
+
await runtime.close();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
return failure(env, error instanceof Error ? error.message : String(error));
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// ---------------------------------------------------------------------------------------------
|
|
562
|
+
// export
|
|
563
|
+
// ---------------------------------------------------------------------------------------------
|
|
564
|
+
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
565
|
+
/**
|
|
566
|
+
* Writes ONE shareable JSON bundle `{schemaVersion, report, runs, markdown}` of the stored run
|
|
567
|
+
* `<stateDir>/eval/<runId>/`, scrubbed of absolute paths and secrets. A secret hit refuses it
|
|
568
|
+
* (exit 1). The file is local: nothing is uploaded.
|
|
569
|
+
*/
|
|
570
|
+
export async function evalExport(env, flags, runId) {
|
|
571
|
+
try {
|
|
572
|
+
if (!flags.out)
|
|
573
|
+
throw new UsageError("export needs --out FILE, the bundle to write.");
|
|
574
|
+
if (!RUN_ID.test(runId) || runId.includes(".."))
|
|
575
|
+
throw new UsageError(`Invalid run id "${runId}": use the directory name under <state>/eval/ (letters, digits, '.', '_', '-'; no path separators).`);
|
|
576
|
+
const dir = join(env.stateDir, "eval", runId);
|
|
577
|
+
if (!existsSync(join(dir, "report.json")))
|
|
578
|
+
throw new UsageError(`No run "${runId}" with a report.json under ${join(env.stateDir, "eval")}.`);
|
|
579
|
+
const target = resolve(flags.out);
|
|
580
|
+
if (existsSync(target) && !flags.force)
|
|
581
|
+
throw new UsageError(`${target} already exists. Pass --force to overwrite it.`);
|
|
582
|
+
const core = await import("@alisio/core");
|
|
583
|
+
const report = await readReport(dir);
|
|
584
|
+
const runsText = existsSync(join(dir, "runs.jsonl"))
|
|
585
|
+
? readFileSync(join(dir, "runs.jsonl"), "utf8")
|
|
586
|
+
: "";
|
|
587
|
+
let runs;
|
|
588
|
+
try {
|
|
589
|
+
runs = runsText
|
|
590
|
+
.split("\n")
|
|
591
|
+
.filter((line) => line.trim())
|
|
592
|
+
.map((line) => JSON.parse(line));
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
throw new UsageError(`${join(dir, "runs.jsonl")} is not valid JSONL.`);
|
|
596
|
+
}
|
|
597
|
+
const markdownFile = join(dir, "report.md");
|
|
598
|
+
const markdown = existsSync(markdownFile)
|
|
599
|
+
? readFileSync(markdownFile, "utf8")
|
|
600
|
+
: core.renderReportMarkdown(report);
|
|
601
|
+
const roots = [
|
|
602
|
+
{ path: resolve(dir), placeholder: "<run-dir>" },
|
|
603
|
+
{ path: resolve(env.stateDir), placeholder: "<state>" },
|
|
604
|
+
...(env.tmpRoot ? [{ path: resolve(env.tmpRoot), placeholder: "<tmp>" }] : []),
|
|
605
|
+
{ path: tmpdir(), placeholder: "<tmp>" },
|
|
606
|
+
{ path: homedir(), placeholder: "<home>" },
|
|
607
|
+
];
|
|
608
|
+
const bundle = core.buildExportBundle({ report, runs, markdown }, { roots, secrets: envSecrets() });
|
|
609
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
610
|
+
writeFileSync(target, `${JSON.stringify(bundle, null, 2)}\n`, {
|
|
611
|
+
flag: flags.force ? "w" : "wx",
|
|
612
|
+
});
|
|
613
|
+
env.out(`Exported run ${runId} to ${target}\n`);
|
|
614
|
+
return 0;
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
return failure(env, error instanceof Error ? error.message : String(error));
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/** `tests/fixtures/eval` of a source checkout; undefined where the URL is not a file (a binary). */
|
|
621
|
+
function bundledSuitesDir() {
|
|
622
|
+
try {
|
|
623
|
+
return fileURLToPath(new URL("../../../tests/fixtures/eval", import.meta.url));
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* The real environment of `alisio eval`: the provider is resolved from the same sources as
|
|
631
|
+
* `alisio run` (flags, saved profile, trusted config) through a throwaway Application that only
|
|
632
|
+
* lends its provider and configuration. It does not carry the invoker's `--read-only`, which
|
|
633
|
+
* would stop the provider plugins from loading; the permissions the runs get come from
|
|
634
|
+
* `InvokerPermissions` instead. Its in-memory database never touches the user's sessions.
|
|
635
|
+
*/
|
|
636
|
+
export async function realEvalEnv(appOptions, defaults) {
|
|
637
|
+
const core = await import("@alisio/core");
|
|
638
|
+
return {
|
|
639
|
+
out: (text) => void process.stdout.write(text),
|
|
640
|
+
err: (text) => void process.stderr.write(text),
|
|
641
|
+
stateDir: core.stateHome(),
|
|
642
|
+
bundledDir: bundledSuitesDir(),
|
|
643
|
+
interactive: !!process.stdin.isTTY && !!process.stderr.isTTY,
|
|
644
|
+
newRunId: () => new Date().toISOString().replace(/[:.]/g, "-"),
|
|
645
|
+
async confirm(question) {
|
|
646
|
+
const { createInterface } = await import("node:readline/promises");
|
|
647
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
648
|
+
try {
|
|
649
|
+
return /^y(es)?$/i.test((await rl.question(question)).trim());
|
|
650
|
+
}
|
|
651
|
+
finally {
|
|
652
|
+
rl.close();
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
async openRuntime(flags, runtimeOptions) {
|
|
656
|
+
const { builtins } = await defaults();
|
|
657
|
+
const disabled = new Set(flags.disablePlugin ?? []);
|
|
658
|
+
const kept = builtins.filter((b) => !disabled.has(b.id));
|
|
659
|
+
const app = await core.createApplication({
|
|
660
|
+
...(await appOptions(flags)),
|
|
661
|
+
builtins,
|
|
662
|
+
readOnly: false,
|
|
663
|
+
noHerdr: true,
|
|
664
|
+
db: ":memory:",
|
|
665
|
+
});
|
|
666
|
+
if (app.provider.id === "unconfigured" && runtimeOptions?.requireProvider !== false) {
|
|
667
|
+
await app.close();
|
|
668
|
+
throw new Error("No model provider is configured. Connect one first (/connect in the TUI), or pass --model and --base-url.");
|
|
669
|
+
}
|
|
670
|
+
// Exact credential values of the active profile, so a cassette that holds one is refused.
|
|
671
|
+
const profile = await new core.ProviderSettingsStore().active().catch(() => undefined);
|
|
672
|
+
const { mcpSources: _sources, ...config } = app.config;
|
|
673
|
+
const resolveAgent = (running, agent) => {
|
|
674
|
+
const catalog = core.agentCatalogFromState(running.plugins.pluginState("subagents", "mainAgents"));
|
|
675
|
+
const found = catalog.find((a) => a.id === agent);
|
|
676
|
+
if (!found)
|
|
677
|
+
throw new Error(`Task names agent "${agent}", which is not available here (known: ${catalog.map((a) => a.id).join(", ") || "none"}).`);
|
|
678
|
+
return core.agentRunOptions(found, { plan: running.config.plan });
|
|
679
|
+
};
|
|
680
|
+
return {
|
|
681
|
+
provider: app.provider,
|
|
682
|
+
config: config,
|
|
683
|
+
builtins: kept,
|
|
684
|
+
secrets: Object.values(profile?.credentials ?? {}),
|
|
685
|
+
resolveAgent,
|
|
686
|
+
close: () => app.close(),
|
|
687
|
+
};
|
|
688
|
+
},
|
|
689
|
+
};
|
|
690
|
+
}
|