@alisio/alisio-code 0.6.0 → 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/eval.d.ts +101 -0
- package/dist/eval.js +690 -0
- package/dist/main.js +62 -0
- package/package.json +7 -7
package/dist/eval.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
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 type { BuiltinPlugin, Config, EvalApplication } from "@alisio/core";
|
|
16
|
+
import type { ModelProvider } from "@alisio/sdk";
|
|
17
|
+
/** The part of the command line `eval` reads, global flags included. */
|
|
18
|
+
export interface EvalFlags {
|
|
19
|
+
json?: boolean;
|
|
20
|
+
allowWrite?: boolean;
|
|
21
|
+
allowProcess?: boolean;
|
|
22
|
+
allowExternal?: boolean;
|
|
23
|
+
allowAnalysis?: boolean;
|
|
24
|
+
readOnly?: boolean;
|
|
25
|
+
disablePlugin?: string[];
|
|
26
|
+
suiteDir?: string;
|
|
27
|
+
tasks?: string;
|
|
28
|
+
k?: string;
|
|
29
|
+
seed?: string;
|
|
30
|
+
mode?: string;
|
|
31
|
+
keep?: boolean;
|
|
32
|
+
out?: string;
|
|
33
|
+
allowSuiteFull?: boolean;
|
|
34
|
+
yes?: boolean;
|
|
35
|
+
inputRate?: string;
|
|
36
|
+
outputRate?: string;
|
|
37
|
+
variants?: string;
|
|
38
|
+
md?: boolean;
|
|
39
|
+
cassetteDir?: string;
|
|
40
|
+
force?: boolean;
|
|
41
|
+
}
|
|
42
|
+
/** What a live run needs, resolved BEFORE any task starts (the engine takes a provider). */
|
|
43
|
+
export interface EvalRuntime {
|
|
44
|
+
provider: ModelProvider;
|
|
45
|
+
/** The effective configuration (variant overrides are applied on top of it). */
|
|
46
|
+
config: Config;
|
|
47
|
+
builtins: BuiltinPlugin[];
|
|
48
|
+
/** Exact credential values of the run's provider profile (a cassette holding one is refused). */
|
|
49
|
+
secrets?: string[];
|
|
50
|
+
resolveAgent?: (app: EvalApplication, agent: string) => ReturnType<NonNullable<ResolveAgent>>;
|
|
51
|
+
close(): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
type ResolveAgent = NonNullable<import("@alisio/core").RunEvaluationOptions["resolveAgent"]>;
|
|
54
|
+
export interface EvalEnv {
|
|
55
|
+
out(text: string): void;
|
|
56
|
+
err(text: string): void;
|
|
57
|
+
/** `<stateHome>`: reports default to `<stateDir>/eval/<runId>/`. */
|
|
58
|
+
stateDir: string;
|
|
59
|
+
/** Directory of the bundled suites: `tests/fixtures/eval` of a source checkout, absent elsewhere. */
|
|
60
|
+
bundledDir?: string;
|
|
61
|
+
interactive: boolean;
|
|
62
|
+
confirm(question: string): Promise<boolean>;
|
|
63
|
+
/**
|
|
64
|
+
* `requireProvider: false` (replay) must not fail when no provider is configured: the cassette
|
|
65
|
+
* is the provider, and the returned `provider` is then ignored.
|
|
66
|
+
*/
|
|
67
|
+
openRuntime(flags: EvalFlags, options?: {
|
|
68
|
+
requireProvider?: boolean;
|
|
69
|
+
}): Promise<EvalRuntime>;
|
|
70
|
+
newRunId(): string;
|
|
71
|
+
/** Parent of the per-run temporary directories (tests); default `os.tmpdir()`. */
|
|
72
|
+
tmpRoot?: string;
|
|
73
|
+
}
|
|
74
|
+
export declare function evalList(env: EvalEnv, flags: EvalFlags): Promise<number>;
|
|
75
|
+
export declare function evalRun(env: EvalEnv, flags: EvalFlags, suiteName: string): Promise<number>;
|
|
76
|
+
/**
|
|
77
|
+
* Runs each selected task ONCE live and writes its cassette. A task whose run errored, whose
|
|
78
|
+
* recording is unstable, or whose cassette holds a secret or is over the size limits writes
|
|
79
|
+
* nothing and makes the command exit 1; the other tasks are still recorded.
|
|
80
|
+
*/
|
|
81
|
+
export declare function evalRecord(env: EvalEnv, flags: EvalFlags, suiteName: string): Promise<number>;
|
|
82
|
+
export declare function evalCompare(env: EvalEnv, flags: EvalFlags, pathA: string, pathB: string): Promise<number>;
|
|
83
|
+
export declare function evalVariants(env: EvalEnv, flags: EvalFlags, suiteName: string): Promise<number>;
|
|
84
|
+
/**
|
|
85
|
+
* Writes ONE shareable JSON bundle `{schemaVersion, report, runs, markdown}` of the stored run
|
|
86
|
+
* `<stateDir>/eval/<runId>/`, scrubbed of absolute paths and secrets. A secret hit refuses it
|
|
87
|
+
* (exit 1). The file is local: nothing is uploaded.
|
|
88
|
+
*/
|
|
89
|
+
export declare function evalExport(env: EvalEnv, flags: EvalFlags, runId: string): Promise<number>;
|
|
90
|
+
type CliAppOptions = import("@alisio/core").AppOptions;
|
|
91
|
+
/**
|
|
92
|
+
* The real environment of `alisio eval`: the provider is resolved from the same sources as
|
|
93
|
+
* `alisio run` (flags, saved profile, trusted config) through a throwaway Application that only
|
|
94
|
+
* lends its provider and configuration. It does not carry the invoker's `--read-only`, which
|
|
95
|
+
* would stop the provider plugins from loading; the permissions the runs get come from
|
|
96
|
+
* `InvokerPermissions` instead. Its in-memory database never touches the user's sessions.
|
|
97
|
+
*/
|
|
98
|
+
export declare function realEvalEnv(appOptions: (flags: EvalFlags) => Promise<CliAppOptions>, defaults: () => Promise<{
|
|
99
|
+
builtins: BuiltinPlugin[];
|
|
100
|
+
}>): Promise<EvalEnv>;
|
|
101
|
+
export {};
|
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
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -487,6 +487,68 @@ forge
|
|
|
487
487
|
const { printForgeStatus } = await import("./forge.js");
|
|
488
488
|
await printForgeStatus(options(cmd), !!flags.probe);
|
|
489
489
|
});
|
|
490
|
+
const evalCommand = program
|
|
491
|
+
.command("eval")
|
|
492
|
+
.description("Reliability lab: run a task suite k times, report pass rates with Wilson intervals, compare reports or config variants, record cassettes and replay them without a model");
|
|
493
|
+
/** `alisio eval` entry points: lazy, and exit codes are returned, not thrown (0 ok, 1 error, 2 replay diverged, 3 incompatible). */
|
|
494
|
+
async function runEval(cmd, action) {
|
|
495
|
+
const api = await import("./eval.js");
|
|
496
|
+
const env = await api.realEvalEnv(async () => options(cmd), async () => cliDefaults());
|
|
497
|
+
process.exitCode = await action(api, env, cmd.optsWithGlobals());
|
|
498
|
+
}
|
|
499
|
+
const evalRunOptions = (command) => command
|
|
500
|
+
.option("--tasks <ids>", "Comma-separated task ids (default: every task of the suite)")
|
|
501
|
+
.option("--k <n>", "Repetitions per task (default 5; below 5 the report warns of low power)")
|
|
502
|
+
.option("--seed <n>", "Seed of the run order; each run gets seed + repetition")
|
|
503
|
+
.option("--mode <mode>", "live (default) or replay (serve the recorded cassettes: no provider, no network; measures the harness, not the model)")
|
|
504
|
+
.option("--cassette-dir <dir>", "Cassette directory (default: <suite>/cassettes; used by --mode replay and by record)")
|
|
505
|
+
.option("--keep", "Keep each run's temporary directory (it prints the paths)")
|
|
506
|
+
.option("--out <dir>", "Write the report files here (default: <state>/eval/<runId>/)")
|
|
507
|
+
.option("--suite-dir <dir>", "A suite, or a directory of suites; third-party code: needs --yes")
|
|
508
|
+
.option("--allow-suite-full", "Let a suite run tasks that ask for the full permission mode")
|
|
509
|
+
.option("--yes", "Confirm running a --suite-dir suite without asking (required if not interactive)")
|
|
510
|
+
.option("--input-rate <usd>", "USD per million input tokens, to estimate cost (needs --output-rate)")
|
|
511
|
+
.option("--output-rate <usd>", "USD per million output tokens (needs --input-rate)");
|
|
512
|
+
evalCommand
|
|
513
|
+
.command("list")
|
|
514
|
+
.description("List the available suites (bundled ones plus --suite-dir)")
|
|
515
|
+
.option("--suite-dir <dir>", "A suite, or a directory of suites")
|
|
516
|
+
.action((_flags, cmd) => runEval(cmd, (api, env, flags) => api.evalList(env, flags)));
|
|
517
|
+
evalRunOptions(evalCommand
|
|
518
|
+
.command("run")
|
|
519
|
+
.description("Run a suite k times (live, or --mode replay from cassettes) and write report.json, report.md and runs.jsonl; --json prints the run records as JSONL and the report last. Exit 0 when it completed (whatever passed), 1 on a usage or infrastructure error, 2 when a replay diverged from its cassette")
|
|
520
|
+
.argument("<suite>")).action((suite, _flags, cmd) => runEval(cmd, (api, env, flags) => api.evalRun(env, flags, suite)));
|
|
521
|
+
evalCommand
|
|
522
|
+
.command("record")
|
|
523
|
+
.description("Run each task of a suite ONCE live and write <suite>/cassettes/<taskId>.cassette.jsonl for `eval run --mode replay`. Refuses (exit 1) a cassette that holds a secret or is over 256 KB (2 MB per directory)")
|
|
524
|
+
.argument("<suite>")
|
|
525
|
+
.option("--tasks <ids>", "Comma-separated task ids (default: every task of the suite)")
|
|
526
|
+
.option("--seed <n>", "Seed of the run order")
|
|
527
|
+
.option("--keep", "Keep each run's temporary directory (it prints the paths)")
|
|
528
|
+
.option("--cassette-dir <dir>", "Write the cassettes here (default: <suite>/cassettes)")
|
|
529
|
+
.option("--suite-dir <dir>", "A suite, or a directory of suites; third-party code: needs --yes")
|
|
530
|
+
.option("--allow-suite-full", "Let a suite run tasks that ask for the full permission mode")
|
|
531
|
+
.option("--yes", "Confirm running a --suite-dir suite without asking (required if not interactive)")
|
|
532
|
+
.action((suite, _flags, cmd) => runEval(cmd, (api, env, flags) => api.evalRecord(env, flags, suite)));
|
|
533
|
+
evalCommand
|
|
534
|
+
.command("compare")
|
|
535
|
+
.description("Compare two reports of the same suite and version; exit 3 when the suites are incompatible")
|
|
536
|
+
.argument("<reportA>", "Baseline report.json (or the directory that holds it)")
|
|
537
|
+
.argument("<reportB>", "Candidate report.json (or the directory that holds it)")
|
|
538
|
+
.option("--md", "Print Markdown (the default; --json prints JSON instead)")
|
|
539
|
+
.action((a, b, _flags, cmd) => runEval(cmd, (api, env, flags) => api.evalCompare(env, flags, a, b)));
|
|
540
|
+
evalRunOptions(evalCommand
|
|
541
|
+
.command("variants")
|
|
542
|
+
.description('Run two or more config variants over the same tasks and seeds and compare them with the first. compare.json: {"suite":"id","variants":[{"id":"baseline","overrides":{}},{"id":"early","overrides":{"compaction.threshold":0.6}}]}')
|
|
543
|
+
.argument("<suite>")
|
|
544
|
+
.requiredOption("--variants <file>", "The compare.json file")).action((suite, _flags, cmd) => runEval(cmd, (api, env, flags) => api.evalVariants(env, flags, suite)));
|
|
545
|
+
evalCommand
|
|
546
|
+
.command("export")
|
|
547
|
+
.description("Write ONE shareable JSON bundle ({schemaVersion, report, runs, markdown}) of a stored run (<state>/eval/<runId>/) with absolute paths replaced by placeholders and endpoint hosts removed from provider ids. Refuses (exit 1) when a secret is found, naming the field; refuses to overwrite FILE without --force. Local only: nothing is uploaded. Scrubbing is pattern-based, not a guarantee")
|
|
548
|
+
.argument("<runId>", "Directory name of the run under <state>/eval/")
|
|
549
|
+
.requiredOption("--out <file>", "The bundle to write")
|
|
550
|
+
.option("--force", "Overwrite FILE if it exists")
|
|
551
|
+
.action((runId, _flags, cmd) => runEval(cmd, (api, env, flags) => api.evalExport(env, flags, runId)));
|
|
490
552
|
const trust = program.command("trust").description("Inspect or revoke per-directory project trust");
|
|
491
553
|
trust.command("list").action(async () => {
|
|
492
554
|
const { listTrust } = await import("@alisio/core");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alisio/alisio-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Alisio: an extensible, provider-agnostic coding-agent harness for your terminal. TUI, OpenAI-compatible providers, permissioned local tools, context compaction, persistent memory and a typed plugin SDK.",
|
|
5
5
|
"author": "Gustavo Gutiérrez",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,12 +45,12 @@
|
|
|
45
45
|
"alisio": "./dist/main.js"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@alisio/core": "0.
|
|
49
|
-
"@alisio/plugin-memory": "0.
|
|
50
|
-
"@alisio/plugin-openai-compatible": "0.
|
|
51
|
-
"@alisio/plugin-subagents": "0.
|
|
52
|
-
"@alisio/sdk": "0.
|
|
53
|
-
"@alisio/server": "0.
|
|
48
|
+
"@alisio/core": "0.7.0",
|
|
49
|
+
"@alisio/plugin-memory": "0.7.0",
|
|
50
|
+
"@alisio/plugin-openai-compatible": "0.7.0",
|
|
51
|
+
"@alisio/plugin-subagents": "0.7.0",
|
|
52
|
+
"@alisio/sdk": "0.7.0",
|
|
53
|
+
"@alisio/server": "0.7.0",
|
|
54
54
|
"@earendil-works/pi-tui": "0.87.1",
|
|
55
55
|
"commander": "15.0.0"
|
|
56
56
|
},
|