@alisio/core 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/janitor.d.ts +48 -0
- package/dist/analysis/janitor.js +170 -18
- package/dist/analysis/jobs.d.ts +11 -2
- package/dist/analysis/jobs.js +13 -0
- package/dist/analysis/oci.d.ts +11 -2
- package/dist/analysis/oci.js +20 -4
- package/dist/analysis/python/sources.js +2 -1
- package/dist/analysis/rerun.d.ts +1 -1
- package/dist/analysis/status.d.ts +3 -1
- package/dist/analysis/status.js +3 -0
- package/dist/application.d.ts +28 -2
- package/dist/application.js +189 -4
- package/dist/changelog/data.js +42 -0
- package/dist/config.d.ts +110 -2
- package/dist/config.js +188 -6
- package/dist/core/call-internals.d.ts +22 -0
- package/dist/core/call-internals.js +9 -0
- package/dist/core/contracts.d.ts +2 -2
- package/dist/core/runner.js +111 -38
- package/dist/eval/canonical.d.ts +14 -0
- package/dist/eval/canonical.js +40 -0
- package/dist/eval/cassette.d.ts +180 -0
- package/dist/eval/cassette.js +424 -0
- package/dist/eval/compare.d.ts +147 -0
- package/dist/eval/compare.js +436 -0
- package/dist/eval/engine.d.ts +144 -0
- package/dist/eval/engine.js +728 -0
- package/dist/eval/export.d.ts +40 -0
- package/dist/eval/export.js +90 -0
- package/dist/eval/fingerprint.d.ts +88 -0
- package/dist/eval/fingerprint.js +186 -0
- package/dist/eval/normalize.d.ts +68 -0
- package/dist/eval/normalize.js +138 -0
- package/dist/eval/paths.d.ts +11 -0
- package/dist/eval/paths.js +50 -0
- package/dist/eval/plan.d.ts +26 -0
- package/dist/eval/plan.js +44 -0
- package/dist/eval/report.d.ts +53 -0
- package/dist/eval/report.js +258 -0
- package/dist/eval/run-suite.d.ts +68 -0
- package/dist/eval/run-suite.js +94 -0
- package/dist/eval/secrets.d.ts +12 -0
- package/dist/eval/secrets.js +45 -0
- package/dist/eval/stats.d.ts +17 -0
- package/dist/eval/stats.js +50 -0
- package/dist/eval/suite.d.ts +20 -0
- package/dist/eval/suite.js +165 -0
- package/dist/eval/variants.d.ts +35 -0
- package/dist/eval/variants.js +137 -0
- package/dist/eval/verifiers.d.ts +49 -0
- package/dist/eval/verifiers.js +264 -0
- package/dist/forge/broker.d.ts +51 -0
- package/dist/forge/broker.js +244 -0
- package/dist/forge/budget.d.ts +27 -0
- package/dist/forge/budget.js +60 -0
- package/dist/forge/catalog-view.d.ts +24 -0
- package/dist/forge/catalog-view.js +43 -0
- package/dist/forge/errors.d.ts +26 -0
- package/dist/forge/errors.js +49 -0
- package/dist/forge/guides/javascript.d.ts +25 -0
- package/dist/forge/guides/javascript.js +90 -0
- package/dist/forge/guides/provider.d.ts +42 -0
- package/dist/forge/guides/provider.js +96 -0
- package/dist/forge/guides/python-environment.d.ts +15 -0
- package/dist/forge/guides/python-environment.js +81 -0
- package/dist/forge/guides/python.d.ts +38 -0
- package/dist/forge/guides/python.js +170 -0
- package/dist/forge/javascript/engine.d.ts +44 -0
- package/dist/forge/javascript/engine.js +229 -0
- package/dist/forge/javascript/hosts/factory.d.ts +25 -0
- package/dist/forge/javascript/hosts/factory.js +21 -0
- package/dist/forge/javascript/hosts/framing.d.ts +17 -0
- package/dist/forge/javascript/hosts/framing.js +49 -0
- package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
- package/dist/forge/javascript/hosts/isolate-main.js +63 -0
- package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/process-entry.js +3 -0
- package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
- package/dist/forge/javascript/hosts/process-host.js +160 -0
- package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/worker-entry.js +3 -0
- package/dist/forge/javascript/hosts/worker-host.d.ts +27 -0
- package/dist/forge/javascript/hosts/worker-host.js +116 -0
- package/dist/forge/javascript/prelude.d.ts +2 -0
- package/dist/forge/javascript/prelude.js +333 -0
- package/dist/forge/javascript/probe.d.ts +2 -0
- package/dist/forge/javascript/probe.js +53 -0
- package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
- package/dist/forge/javascript/runtime-manifest.js +14 -0
- package/dist/forge/javascript/vm-runner.d.ts +21 -0
- package/dist/forge/javascript/vm-runner.js +301 -0
- package/dist/forge/javascript/wasm-asset.d.ts +8 -0
- package/dist/forge/javascript/wasm-asset.js +8 -0
- package/dist/forge/javascript/wasm.d.ts +15 -0
- package/dist/forge/javascript/wasm.js +54 -0
- package/dist/forge/journal.d.ts +105 -0
- package/dist/forge/journal.js +174 -0
- package/dist/forge/kernel.d.ts +76 -0
- package/dist/forge/kernel.js +339 -0
- package/dist/forge/limits.d.ts +38 -0
- package/dist/forge/limits.js +73 -0
- package/dist/forge/nested.d.ts +96 -0
- package/dist/forge/nested.js +214 -0
- package/dist/forge/ports.d.ts +120 -0
- package/dist/forge/ports.js +1 -0
- package/dist/forge/progress.d.ts +24 -0
- package/dist/forge/progress.js +76 -0
- package/dist/forge/protocol.d.ts +110 -0
- package/dist/forge/protocol.js +171 -0
- package/dist/forge/python/backend-selector.d.ts +38 -0
- package/dist/forge/python/backend-selector.js +111 -0
- package/dist/forge/python/backend.d.ts +89 -0
- package/dist/forge/python/backend.js +1 -0
- package/dist/forge/python/engine.d.ts +84 -0
- package/dist/forge/python/engine.js +279 -0
- package/dist/forge/python/frame-parser.d.ts +24 -0
- package/dist/forge/python/frame-parser.js +107 -0
- package/dist/forge/python/frame-transport.d.ts +40 -0
- package/dist/forge/python/frame-transport.js +101 -0
- package/dist/forge/python/import-scan.d.ts +22 -0
- package/dist/forge/python/import-scan.js +133 -0
- package/dist/forge/python/managed-backend.d.ts +30 -0
- package/dist/forge/python/managed-backend.js +111 -0
- package/dist/forge/python/oci-backend.d.ts +27 -0
- package/dist/forge/python/oci-backend.js +84 -0
- package/dist/forge/python/pyodide-backend.d.ts +40 -0
- package/dist/forge/python/pyodide-backend.js +289 -0
- package/dist/forge/python/pyodide-install.d.ts +41 -0
- package/dist/forge/python/pyodide-install.js +167 -0
- package/dist/forge/python/pyodide-manifest.d.ts +42 -0
- package/dist/forge/python/pyodide-manifest.js +376 -0
- package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
- package/dist/forge/python/pyodide-runner-source.js +3 -0
- package/dist/forge/python/rss.d.ts +2 -0
- package/dist/forge/python/rss.js +33 -0
- package/dist/forge/python/scratch-copy.d.ts +6 -0
- package/dist/forge/python/scratch-copy.js +61 -0
- package/dist/forge/python/settle.d.ts +6 -0
- package/dist/forge/python/settle.js +21 -0
- package/dist/forge/python/status.d.ts +8 -0
- package/dist/forge/python/status.js +15 -0
- package/dist/forge/python/wasm-node.d.ts +59 -0
- package/dist/forge/python/wasm-node.js +220 -0
- package/dist/forge/python/wiring.d.ts +39 -0
- package/dist/forge/python/wiring.js +47 -0
- package/dist/forge/registry.d.ts +26 -0
- package/dist/forge/registry.js +83 -0
- package/dist/forge/result.d.ts +58 -0
- package/dist/forge/result.js +86 -0
- package/dist/forge/routing-rule.d.ts +19 -0
- package/dist/forge/routing-rule.js +31 -0
- package/dist/forge/runtime.d.ts +64 -0
- package/dist/forge/runtime.js +88 -0
- package/dist/forge/scratch-files.d.ts +36 -0
- package/dist/forge/scratch-files.js +94 -0
- package/dist/forge/scratch-provider.d.ts +41 -0
- package/dist/forge/scratch-provider.js +79 -0
- package/dist/forge/scratch-quota.d.ts +8 -0
- package/dist/forge/scratch-quota.js +25 -0
- package/dist/forge/selector.d.ts +35 -0
- package/dist/forge/selector.js +51 -0
- package/dist/forge/session-dir.d.ts +59 -0
- package/dist/forge/session-dir.js +239 -0
- package/dist/forge/session-path.d.ts +31 -0
- package/dist/forge/session-path.js +61 -0
- package/dist/forge/slots.d.ts +16 -0
- package/dist/forge/slots.js +47 -0
- package/dist/forge/state-delta.d.ts +54 -0
- package/dist/forge/state-delta.js +99 -0
- package/dist/forge/state-machine.d.ts +20 -0
- package/dist/forge/state-machine.js +40 -0
- package/dist/forge/state-store.d.ts +25 -0
- package/dist/forge/state-store.js +50 -0
- package/dist/forge/task-log.d.ts +9 -0
- package/dist/forge/task-log.js +42 -0
- package/dist/forge/verdict.d.ts +20 -0
- package/dist/forge/verdict.js +28 -0
- package/dist/forge-isolate.d.ts +6 -0
- package/dist/forge-isolate.js +6 -0
- package/dist/index.d.ts +23 -1
- package/dist/index.js +23 -1
- package/dist/permissions/decide.d.ts +42 -0
- package/dist/permissions/decide.js +24 -0
- package/dist/plan/exit-plan.js +2 -1
- package/dist/resources/context.d.ts +2 -1
- package/dist/resources/context.js +2 -1
- package/dist/runtime/access.d.ts +34 -0
- package/dist/runtime/access.js +80 -0
- package/dist/runtime/ids.d.ts +21 -0
- package/dist/runtime/ids.js +41 -1
- package/dist/runtime/process.d.ts +2 -1
- package/dist/runtime/process.js +5 -5
- package/dist/runtime/store.d.ts +11 -0
- package/dist/runtime/store.js +106 -2
- package/dist/tools/analysis.d.ts +25 -2
- package/dist/tools/analysis.js +315 -267
- package/dist/tools/execute.js +6 -7
- package/dist/tools/forge-guide.d.ts +6 -0
- package/dist/tools/forge-guide.js +60 -0
- package/dist/tools/js-run.d.ts +19 -0
- package/dist/tools/js-run.js +126 -0
- package/dist/tools/standard.d.ts +7 -1
- package/dist/tools/standard.js +44 -43
- package/package.json +7 -2
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live engine of the reliability lab (specs/010 Phase 2): runs ONE task repetition through the
|
|
3
|
+
* real Application (runner, tools, store) on a throwaway copy of its fixture and returns an
|
|
4
|
+
* `EvalRunRecord` with the conditions it was measured under.
|
|
5
|
+
*
|
|
6
|
+
* What the isolation guarantees, and what it does not:
|
|
7
|
+
* - The fixture is copied; the original is never touched. Config, state, home and tmp live in the
|
|
8
|
+
* run directory and the process environment points at them while the application exists.
|
|
9
|
+
* - File tools are mediated: a write to `../x` or to an absolute path outside the copy is refused
|
|
10
|
+
* (`safePath`). That is path confinement, NOT an OS sandbox.
|
|
11
|
+
* - A granted `process` effect (or a `command` verifier) runs with the user's privileges and can
|
|
12
|
+
* write anywhere the user can. When `process` is granted the "nothing outside changes" claim does
|
|
13
|
+
* not hold. A subprocess is not a sandbox.
|
|
14
|
+
*
|
|
15
|
+
* Runs never overlap: `ALISIO_CONFIG_HOME`/`ALISIO_STATE_HOME` are process-global, so `runTask`
|
|
16
|
+
* serializes concurrent callers.
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { cpSync, mkdirSync, mkdtempSync, readdirSync, readlinkSync, realpathSync, rmSync, writeFileSync, } from "node:fs";
|
|
21
|
+
import { homedir, tmpdir } from "node:os";
|
|
22
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
23
|
+
import { createApplication } from "../application.js";
|
|
24
|
+
import { configSchema } from "../config.js";
|
|
25
|
+
import { runStatsOf } from "../core/run-stats.js";
|
|
26
|
+
import { RunTruncationError } from "../core/truncation.js";
|
|
27
|
+
import { PERMISSION_MODE_TABLE } from "../permissions/modes.js";
|
|
28
|
+
import { seededIdSource, setIdSource } from "../runtime/ids.js";
|
|
29
|
+
import { loadVersion } from "../version.js";
|
|
30
|
+
import { canonicalJson, sha256Hex } from "./canonical.js";
|
|
31
|
+
import { CassetteRecorder, RecordingProvider, ReplayDivergenceError, ReplayExhausted, ReplayProvider, ReplayUnconsumedError, } from "./cassette.js";
|
|
32
|
+
import { Normalizer, runLiterals } from "./normalize.js";
|
|
33
|
+
import { runVerifiers, snapshotTree } from "./verifiers.js";
|
|
34
|
+
/** The run was refused before it started (policy, `full` without opt-in, unsafe fixture). */
|
|
35
|
+
export class EvalRunRefusedError extends Error {
|
|
36
|
+
constructor(message) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "EvalRunRefusedError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Abort reason of the engine's own budgets (`maxWallMs`, `maxToolCalls`). */
|
|
42
|
+
export class EvalBudgetExceeded extends Error {
|
|
43
|
+
budget;
|
|
44
|
+
constructor(budget) {
|
|
45
|
+
super(`Evaluation budget exceeded: ${budget}`);
|
|
46
|
+
this.budget = budget;
|
|
47
|
+
this.name = "EvalBudgetExceeded";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------------------------
|
|
51
|
+
// The configuration hash (canonical JSON lives in canonical.ts)
|
|
52
|
+
// ---------------------------------------------------------------------------------------------
|
|
53
|
+
const sha256 = sha256Hex;
|
|
54
|
+
/**
|
|
55
|
+
* Which top-level config keys feed `configHash`. The `satisfies` clause fails the typecheck, and
|
|
56
|
+
* `tests/eval-engine.test.ts` fails at runtime, when a key is added to the schema without being
|
|
57
|
+
* classified here: a new setting must be consciously hashed or excluded.
|
|
58
|
+
*/
|
|
59
|
+
export const CONFIG_HASH_COVERAGE = {
|
|
60
|
+
limits: "hashed",
|
|
61
|
+
compaction: "hashed",
|
|
62
|
+
context: "hashed",
|
|
63
|
+
forge: "hashed",
|
|
64
|
+
analysis: "hashed",
|
|
65
|
+
websearch: "hashed",
|
|
66
|
+
decisions: "hashed",
|
|
67
|
+
skillOverrides: "hashed",
|
|
68
|
+
// Never part of the hash: secrets/endpoints/models (provider), paths and plugin options.
|
|
69
|
+
schemaVersion: "excluded",
|
|
70
|
+
provider: "excluded",
|
|
71
|
+
plugins: "excluded",
|
|
72
|
+
skills: "excluded",
|
|
73
|
+
additionalDirectories: "excluded",
|
|
74
|
+
mcp: "excluded",
|
|
75
|
+
builtinPlugins: "excluded",
|
|
76
|
+
pluginOverrides: "excluded",
|
|
77
|
+
pluginHooks: "excluded",
|
|
78
|
+
tasks: "excluded",
|
|
79
|
+
goal: "excluded",
|
|
80
|
+
plan: "excluded",
|
|
81
|
+
tui: "excluded",
|
|
82
|
+
agents: "excluded",
|
|
83
|
+
web: "excluded",
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* `sha256:` of the allowlisted effective configuration plus policy, approval mode, budget and
|
|
87
|
+
* enabled built-ins. Excludes secrets, env names, plugin options, absolute paths, endpoints, the
|
|
88
|
+
* provider and the model (those are separate columns of the run record).
|
|
89
|
+
*/
|
|
90
|
+
export function computeConfigHash(config, extras) {
|
|
91
|
+
const forge = config.forge;
|
|
92
|
+
const picked = {
|
|
93
|
+
limits: config.limits,
|
|
94
|
+
compaction: config.compaction,
|
|
95
|
+
context: config.context,
|
|
96
|
+
forge: {
|
|
97
|
+
javascript: forge.javascript,
|
|
98
|
+
state: forge.state,
|
|
99
|
+
scratch: {
|
|
100
|
+
enabled: forge.scratch.enabled,
|
|
101
|
+
retentionDays: forge.scratch.retentionDays,
|
|
102
|
+
maxBytes: forge.scratch.maxBytes,
|
|
103
|
+
},
|
|
104
|
+
guides: forge.guides,
|
|
105
|
+
},
|
|
106
|
+
analysis: {
|
|
107
|
+
enabled: config.analysis.enabled,
|
|
108
|
+
runtime: config.analysis.runtime,
|
|
109
|
+
isolation: config.analysis.isolation,
|
|
110
|
+
},
|
|
111
|
+
websearch: { provider: config.websearch.provider },
|
|
112
|
+
decisions: { enabled: config.decisions.enabled, provider: config.decisions.provider },
|
|
113
|
+
skillOverrides: config.skillOverrides,
|
|
114
|
+
policy: extras.policy,
|
|
115
|
+
approvalMode: extras.approvalMode,
|
|
116
|
+
budget: extras.budget,
|
|
117
|
+
builtins: extras.builtinIds.filter((id) => config.builtinPlugins[id]?.enabled !== false).sort(),
|
|
118
|
+
};
|
|
119
|
+
return `sha256:${sha256(canonicalJson(picked))}`;
|
|
120
|
+
}
|
|
121
|
+
const EFFECTS = ["write", "process", "external", "analysis"];
|
|
122
|
+
/** Effective policy of a task, or an `EvalRunRefusedError` naming what is not allowed and why. */
|
|
123
|
+
function planPolicy(task, opts) {
|
|
124
|
+
const { mode, allow = [], readOnly: taskReadOnly = false } = task.policy;
|
|
125
|
+
if (mode === "full" && !opts.allowSuiteFull)
|
|
126
|
+
throw new EvalRunRefusedError(`Task "${task.id}" asks for permission mode "full"; pass --allow-suite-full explicitly to run it.`);
|
|
127
|
+
const base = PERMISSION_MODE_TABLE[mode].policy;
|
|
128
|
+
const requested = {
|
|
129
|
+
write: base.write || allow.includes("write"),
|
|
130
|
+
process: base.process || allow.includes("process"),
|
|
131
|
+
external: base.external || allow.includes("external"),
|
|
132
|
+
analysis: allow.includes("analysis"),
|
|
133
|
+
};
|
|
134
|
+
const readOnly = taskReadOnly || !!opts.invoker.readOnly;
|
|
135
|
+
if (!taskReadOnly) {
|
|
136
|
+
const lacking = EFFECTS.filter((effect) => requested[effect] && (opts.invoker.readOnly || !opts.invoker[effect]));
|
|
137
|
+
if (lacking.length > 0)
|
|
138
|
+
throw new EvalRunRefusedError(`Task "${task.id}" requests ${lacking.join(", ")} but the invoker does not hold ${lacking.length === 1 ? "it" : "them"}${opts.invoker.readOnly ? " (read-only)" : ""}. A suite can only equal or restrict the invoker's permissions; grant the --allow-* flag or lower the task policy.`);
|
|
139
|
+
}
|
|
140
|
+
const effective = readOnly
|
|
141
|
+
? { write: false, process: false, external: false, analysis: false }
|
|
142
|
+
: requested;
|
|
143
|
+
return { policy: effective, readOnly };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Throws the same `EvalRunRefusedError` `runTask` would, without running anything. Lets a caller
|
|
147
|
+
* refuse a whole batch up front instead of discovering a refusal after earlier runs finished.
|
|
148
|
+
*/
|
|
149
|
+
export function checkTaskPolicy(task, opts) {
|
|
150
|
+
planPolicy(task, opts);
|
|
151
|
+
}
|
|
152
|
+
function checkMode(opts) {
|
|
153
|
+
const mode = opts.mode ?? "live";
|
|
154
|
+
if (mode === "replay") {
|
|
155
|
+
if (!opts.cassette || opts.cassette instanceof CassetteRecorder)
|
|
156
|
+
throw new EvalRunRefusedError("Replay mode needs the recorded cassette (`cassette`).");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!opts.provider)
|
|
160
|
+
throw new EvalRunRefusedError(`${mode} mode needs a model provider (\`provider\`).`);
|
|
161
|
+
if (mode === "record") {
|
|
162
|
+
if (!(opts.cassette instanceof CassetteRecorder))
|
|
163
|
+
throw new EvalRunRefusedError("Record mode needs a CassetteRecorder (`cassette`).");
|
|
164
|
+
}
|
|
165
|
+
else if (opts.cassette)
|
|
166
|
+
throw new EvalRunRefusedError("A cassette only goes with record or replay mode, not live.");
|
|
167
|
+
}
|
|
168
|
+
// ---------------------------------------------------------------------------------------------
|
|
169
|
+
// Fixture copy
|
|
170
|
+
// ---------------------------------------------------------------------------------------------
|
|
171
|
+
/** Refuses symlinks that leave the fixture (or are absolute, which a copy would turn into escapes). */
|
|
172
|
+
function assertFixtureSafe(fixtureDir, taskId) {
|
|
173
|
+
const root = realpathSync(fixtureDir);
|
|
174
|
+
const within = (path) => {
|
|
175
|
+
const rel = relative(root, path);
|
|
176
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
177
|
+
};
|
|
178
|
+
const walk = (dir, prefix) => {
|
|
179
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
180
|
+
const full = join(dir, entry.name);
|
|
181
|
+
const shown = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
182
|
+
if (entry.isSymbolicLink()) {
|
|
183
|
+
const target = readlinkSync(full);
|
|
184
|
+
if (isAbsolute(target))
|
|
185
|
+
throw new EvalRunRefusedError(`Task "${taskId}": fixture symlink "${shown}" is an absolute symlink; use a relative target inside the fixture.`);
|
|
186
|
+
if (!within(resolve(dirname(full), target)))
|
|
187
|
+
throw new EvalRunRefusedError(`Task "${taskId}": fixture symlink "${shown}" resolves outside the fixture; remove it or point it inside.`);
|
|
188
|
+
let real;
|
|
189
|
+
try {
|
|
190
|
+
real = realpathSync(full);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// Dangling link: the lexical check above already proved it stays inside.
|
|
194
|
+
}
|
|
195
|
+
if (real !== undefined && !within(real))
|
|
196
|
+
throw new EvalRunRefusedError(`Task "${taskId}": fixture symlink "${shown}" resolves outside the fixture; remove it or point it inside.`);
|
|
197
|
+
}
|
|
198
|
+
else if (entry.isDirectory())
|
|
199
|
+
walk(full, shown);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
walk(root, "");
|
|
203
|
+
}
|
|
204
|
+
function initGit(ws, home) {
|
|
205
|
+
const env = {
|
|
206
|
+
PATH: process.env.PATH ?? "",
|
|
207
|
+
HOME: home,
|
|
208
|
+
GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null",
|
|
209
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
210
|
+
};
|
|
211
|
+
const identity = [
|
|
212
|
+
"-c",
|
|
213
|
+
"user.name=alisio-eval",
|
|
214
|
+
"-c",
|
|
215
|
+
"user.email=eval@alisio.invalid",
|
|
216
|
+
"-c",
|
|
217
|
+
"commit.gpgsign=false",
|
|
218
|
+
];
|
|
219
|
+
for (const args of [
|
|
220
|
+
["init", "-q"],
|
|
221
|
+
["add", "-A"],
|
|
222
|
+
[...identity, "commit", "-q", "--allow-empty", "-m", "fixture"],
|
|
223
|
+
]) {
|
|
224
|
+
const result = spawnSync("git", args, { cwd: ws, env, encoding: "utf8" });
|
|
225
|
+
if (result.status !== 0)
|
|
226
|
+
throw new Error(`git ${args.find((a) => !a.startsWith("-") && a !== "user.name=alisio-eval") ?? ""} failed in the fixture copy`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// ---------------------------------------------------------------------------------------------
|
|
230
|
+
// Process environment scoping
|
|
231
|
+
// ---------------------------------------------------------------------------------------------
|
|
232
|
+
/** Points the process-global homes at the run and returns the function that restores them. */
|
|
233
|
+
function scopeEnv(values) {
|
|
234
|
+
const saved = Object.keys(values).map((key) => [key, process.env[key]]);
|
|
235
|
+
for (const [key, value] of Object.entries(values))
|
|
236
|
+
process.env[key] = value;
|
|
237
|
+
return () => {
|
|
238
|
+
for (const [key, value] of saved) {
|
|
239
|
+
if (value === undefined)
|
|
240
|
+
delete process.env[key];
|
|
241
|
+
else
|
|
242
|
+
process.env[key] = value;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/** The global config layer written for the run: nothing that could load code or reach real paths. */
|
|
247
|
+
function isolatedConfig(config) {
|
|
248
|
+
const copy = JSON.parse(JSON.stringify(config));
|
|
249
|
+
copy.plugins = [];
|
|
250
|
+
// User skill roots must not influence results (skillOverrides stay: they are hashed).
|
|
251
|
+
copy.skills = [];
|
|
252
|
+
copy.mcp = { ...copy.mcp, servers: {} };
|
|
253
|
+
copy.additionalDirectories = [];
|
|
254
|
+
copy.forge.scratch.root = null;
|
|
255
|
+
for (const [id, options] of Object.entries(copy.builtinPlugins)) {
|
|
256
|
+
const { dbPath: _dbPath, ...rest } = options;
|
|
257
|
+
copy.builtinPlugins[id] = rest;
|
|
258
|
+
}
|
|
259
|
+
return configSchema.parse(copy);
|
|
260
|
+
}
|
|
261
|
+
// ---------------------------------------------------------------------------------------------
|
|
262
|
+
// Event bookkeeping
|
|
263
|
+
// ---------------------------------------------------------------------------------------------
|
|
264
|
+
const INCIDENT_ORDER = [
|
|
265
|
+
"approval_denied",
|
|
266
|
+
"tool_error",
|
|
267
|
+
"turns_exceeded",
|
|
268
|
+
"compaction",
|
|
269
|
+
"compaction_failed",
|
|
270
|
+
"context_reduced",
|
|
271
|
+
"timeout",
|
|
272
|
+
"output_truncated",
|
|
273
|
+
"request_retry",
|
|
274
|
+
"truncation_recovery",
|
|
275
|
+
"response_truncated",
|
|
276
|
+
"plugin_hook_failed",
|
|
277
|
+
"run_aborted",
|
|
278
|
+
"usage_unreported",
|
|
279
|
+
];
|
|
280
|
+
class RunObserver {
|
|
281
|
+
incidents = new Map();
|
|
282
|
+
tools = [];
|
|
283
|
+
blocked = new Set();
|
|
284
|
+
turns = 0;
|
|
285
|
+
/** Turns whose `turn_completed` carried provider usage. */
|
|
286
|
+
turnsWithUsage = 0;
|
|
287
|
+
startedCalls = 0;
|
|
288
|
+
/** Cumulative tokens reported by the last `turn_completed`. */
|
|
289
|
+
tokens = 0;
|
|
290
|
+
usage;
|
|
291
|
+
model;
|
|
292
|
+
timedOut = false;
|
|
293
|
+
turnsExceeded = false;
|
|
294
|
+
bump(kind) {
|
|
295
|
+
this.incidents.set(kind, (this.incidents.get(kind) ?? 0) + 1);
|
|
296
|
+
}
|
|
297
|
+
/** Returns true when this event is a tool call that must be blocked (over `maxToolCalls`). */
|
|
298
|
+
handle(event, maxToolCalls) {
|
|
299
|
+
const data = event.data;
|
|
300
|
+
switch (event.type) {
|
|
301
|
+
case "run_started":
|
|
302
|
+
this.model = String(data.model);
|
|
303
|
+
break;
|
|
304
|
+
case "turn_completed": {
|
|
305
|
+
this.turns += 1;
|
|
306
|
+
this.tokens = Number(data.tokens) || this.tokens;
|
|
307
|
+
const usage = data.usage;
|
|
308
|
+
if (usage) {
|
|
309
|
+
this.turnsWithUsage += 1;
|
|
310
|
+
this.usage ??= { input: 0, output: 0 };
|
|
311
|
+
this.usage.input += usage.input;
|
|
312
|
+
this.usage.output += usage.output;
|
|
313
|
+
}
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
case "tool_started": {
|
|
317
|
+
const id = String(data.id);
|
|
318
|
+
if (maxToolCalls !== undefined && this.startedCalls + 1 > maxToolCalls) {
|
|
319
|
+
this.blocked.add(id);
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
this.startedCalls += 1;
|
|
323
|
+
this.tools.push({
|
|
324
|
+
id,
|
|
325
|
+
name: String(data.name),
|
|
326
|
+
args: String(data.arguments),
|
|
327
|
+
done: false,
|
|
328
|
+
isError: false,
|
|
329
|
+
});
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
case "tool_completed": {
|
|
333
|
+
const id = String(data.id);
|
|
334
|
+
if (this.blocked.has(id))
|
|
335
|
+
break;
|
|
336
|
+
const tool = this.tools.find((t) => t.id === id);
|
|
337
|
+
if (tool) {
|
|
338
|
+
tool.done = true;
|
|
339
|
+
tool.isError = data.isError === true;
|
|
340
|
+
}
|
|
341
|
+
if (data.isError === true)
|
|
342
|
+
this.bump("tool_error");
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
case "approval_resolved":
|
|
346
|
+
if (data.decision === "deny")
|
|
347
|
+
this.bump("approval_denied");
|
|
348
|
+
break;
|
|
349
|
+
case "run_turns_exceeded":
|
|
350
|
+
this.turnsExceeded = true;
|
|
351
|
+
this.bump("turns_exceeded");
|
|
352
|
+
break;
|
|
353
|
+
case "compaction_completed":
|
|
354
|
+
this.bump("compaction");
|
|
355
|
+
break;
|
|
356
|
+
case "compaction_failed":
|
|
357
|
+
case "context_reduced":
|
|
358
|
+
case "request_retry":
|
|
359
|
+
case "truncation_recovery":
|
|
360
|
+
case "response_truncated":
|
|
361
|
+
case "plugin_hook_failed":
|
|
362
|
+
this.bump(event.type);
|
|
363
|
+
break;
|
|
364
|
+
case "run_failed":
|
|
365
|
+
if (data.code === "timeout") {
|
|
366
|
+
this.timedOut = true;
|
|
367
|
+
this.bump("timeout");
|
|
368
|
+
}
|
|
369
|
+
else if (data.code === "output_truncated")
|
|
370
|
+
this.bump("output_truncated");
|
|
371
|
+
break;
|
|
372
|
+
default:
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
toolEvents() {
|
|
378
|
+
return this.tools.map((t) => ({
|
|
379
|
+
tool: t.name,
|
|
380
|
+
ok: t.done && !t.isError,
|
|
381
|
+
argsKey: argsKey(t.args),
|
|
382
|
+
}));
|
|
383
|
+
}
|
|
384
|
+
/** Only SOME turns reported usage: the summed totals understate the run. */
|
|
385
|
+
get usagePartial() {
|
|
386
|
+
return this.turnsWithUsage > 0 && this.turnsWithUsage < this.turns;
|
|
387
|
+
}
|
|
388
|
+
markAborted() {
|
|
389
|
+
this.bump("run_aborted");
|
|
390
|
+
}
|
|
391
|
+
incidentList() {
|
|
392
|
+
if (this.turns > 0 && (!this.usage || this.usagePartial))
|
|
393
|
+
this.bump("usage_unreported");
|
|
394
|
+
return INCIDENT_ORDER.flatMap((kind) => {
|
|
395
|
+
const count = this.incidents.get(kind);
|
|
396
|
+
return count ? [{ kind, count }] : [];
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/** Canonical key of the call arguments: sha256 of the sorted JSON, or of the raw text if unparsable. */
|
|
401
|
+
function argsKey(raw) {
|
|
402
|
+
let text = raw;
|
|
403
|
+
try {
|
|
404
|
+
text = canonicalJson(JSON.parse(raw));
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
// Unparsable arguments are compared as written.
|
|
408
|
+
}
|
|
409
|
+
return `sha256:${sha256(text)}`;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Whether a run's exception was caused by what the model did (so the run is a `fail`, a
|
|
413
|
+
* repetition that counts) rather than by infrastructure (provider network/auth, fixture copy,
|
|
414
|
+
* application creation: the run is `error` and excluded). Excluding model-caused failures would
|
|
415
|
+
* be survivorship bias: the model that breaks the protocol would look better than one that does
|
|
416
|
+
* not. The runner throws these as plain `Error`s, so the three protocol messages are matched
|
|
417
|
+
* exactly; `tests/eval-engine.test.ts` drives each one through the real runner.
|
|
418
|
+
*/
|
|
419
|
+
const MODEL_PROTOCOL_ERRORS = new Set([
|
|
420
|
+
"Too many tool calls in one turn",
|
|
421
|
+
"Provider returned duplicate tool call IDs",
|
|
422
|
+
"Provider reused a tool call ID across turns",
|
|
423
|
+
]);
|
|
424
|
+
function isModelCaused(error) {
|
|
425
|
+
if (error instanceof RunTruncationError)
|
|
426
|
+
return true;
|
|
427
|
+
return error instanceof Error && MODEL_PROTOCOL_ERRORS.has(error.message);
|
|
428
|
+
}
|
|
429
|
+
// ---------------------------------------------------------------------------------------------
|
|
430
|
+
// runTask
|
|
431
|
+
// ---------------------------------------------------------------------------------------------
|
|
432
|
+
let queue = Promise.resolve();
|
|
433
|
+
/**
|
|
434
|
+
* Runs one repetition of `task`. Refusals (`EvalRunRefusedError`) reject before anything is
|
|
435
|
+
* created; infrastructure failures resolve with `status: "error"` and `error` set.
|
|
436
|
+
*/
|
|
437
|
+
export async function runTask(task, opts) {
|
|
438
|
+
const plan = planPolicy(task, opts);
|
|
439
|
+
checkMode(opts);
|
|
440
|
+
const fixtureDir = opts.suite.fixtureDirs[task.id];
|
|
441
|
+
if (!fixtureDir)
|
|
442
|
+
throw new EvalRunRefusedError(`Task "${task.id}" is not part of suite "${opts.suite.suite.suiteId}".`);
|
|
443
|
+
assertFixtureSafe(fixtureDir, task.id);
|
|
444
|
+
const result = queue.then(() => execute(task, opts, plan, fixtureDir));
|
|
445
|
+
queue = result.then(() => undefined, () => undefined);
|
|
446
|
+
return result;
|
|
447
|
+
}
|
|
448
|
+
async function execute(task, opts, plan, fixtureDir) {
|
|
449
|
+
const runId = randomUUID();
|
|
450
|
+
const startedAt = Date.now();
|
|
451
|
+
const { suite } = opts.suite;
|
|
452
|
+
const observer = new RunObserver();
|
|
453
|
+
const budget = task.budget;
|
|
454
|
+
const stored = isolatedConfig(opts.config);
|
|
455
|
+
const configHash = computeConfigHash(stored, {
|
|
456
|
+
policy: { ...plan.policy, ...(plan.readOnly ? { readOnly: true } : {}) },
|
|
457
|
+
approvalMode: task.policy.mode,
|
|
458
|
+
budget,
|
|
459
|
+
builtinIds: (opts.builtins ?? []).map((b) => b.id),
|
|
460
|
+
});
|
|
461
|
+
const compactionHash = `sha256:${sha256(canonicalJson({
|
|
462
|
+
compaction: stored.compaction,
|
|
463
|
+
maxContextChars: stored.limits.maxContextChars,
|
|
464
|
+
}))}`;
|
|
465
|
+
let runDir;
|
|
466
|
+
let infraError;
|
|
467
|
+
/** A failure the model caused (truncation, protocol violation): judged as `fail`, not `error`. */
|
|
468
|
+
let modelFailure;
|
|
469
|
+
let budgetReason;
|
|
470
|
+
let usageStats;
|
|
471
|
+
let wallMs = 0;
|
|
472
|
+
let verifiers = [];
|
|
473
|
+
const mode = opts.mode ?? "live";
|
|
474
|
+
const recorder = opts.cassette instanceof CassetteRecorder ? opts.cassette : undefined;
|
|
475
|
+
let replay;
|
|
476
|
+
/** Why a replay stopped (a changed request, a call past the recording, calls never asked for). */
|
|
477
|
+
let divergence;
|
|
478
|
+
const cassetteHeader = mode === "replay" ? opts.cassette.header : undefined;
|
|
479
|
+
let model = opts.provider?.model ?? cassetteHeader?.model ?? "";
|
|
480
|
+
const providerId = opts.provider?.id ?? cassetteHeader?.provider ?? "";
|
|
481
|
+
try {
|
|
482
|
+
if (task.agent && !opts.resolveAgent)
|
|
483
|
+
throw new Error(`Task names agent "${task.agent}" but no resolveAgent was provided to the engine; agent runs are not available here.`);
|
|
484
|
+
runDir = realpathSync(mkdtempSync(join(opts.tmpRoot ?? tmpdir(), "alisio-eval-")));
|
|
485
|
+
const ws = join(runDir, "ws");
|
|
486
|
+
const dirs = {
|
|
487
|
+
config: join(runDir, "config"),
|
|
488
|
+
state: join(runDir, "state"),
|
|
489
|
+
home: join(runDir, "home"),
|
|
490
|
+
tmp: join(runDir, "tmp"),
|
|
491
|
+
};
|
|
492
|
+
for (const dir of Object.values(dirs))
|
|
493
|
+
mkdirSync(dir);
|
|
494
|
+
cpSync(fixtureDir, ws, { recursive: true, verbatimSymlinks: true });
|
|
495
|
+
writeFileSync(join(dirs.config, "config.json"), `${JSON.stringify(stored, null, 2)}\n`);
|
|
496
|
+
if (task.fixtureGit)
|
|
497
|
+
initGit(ws, dirs.home);
|
|
498
|
+
const baseline = snapshotTree(ws);
|
|
499
|
+
// Record and replay: the same deterministic setup on both sides (ids, normalization).
|
|
500
|
+
let normalizer;
|
|
501
|
+
let provider = opts.provider;
|
|
502
|
+
if (mode !== "live") {
|
|
503
|
+
normalizer = new Normalizer({
|
|
504
|
+
literals: runLiterals({
|
|
505
|
+
runDir,
|
|
506
|
+
workspace: ws,
|
|
507
|
+
home: homedir(),
|
|
508
|
+
osTmp: tmpdir(),
|
|
509
|
+
...(process.getuid ? { uid: process.getuid() } : {}),
|
|
510
|
+
}),
|
|
511
|
+
...(task.replayNormalize ? { replay: task.replayNormalize } : {}),
|
|
512
|
+
});
|
|
513
|
+
normalizer.registerRun(runId);
|
|
514
|
+
if (recorder && opts.provider) {
|
|
515
|
+
recorder.bind(normalizer);
|
|
516
|
+
provider = new RecordingProvider(opts.provider, recorder);
|
|
517
|
+
}
|
|
518
|
+
else if (mode === "replay") {
|
|
519
|
+
replay = new ReplayProvider(opts.cassette, { normalizer });
|
|
520
|
+
provider = replay;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (!provider)
|
|
524
|
+
throw new Error("No model provider for the run.");
|
|
525
|
+
const effectiveProvider = provider;
|
|
526
|
+
const controller = new AbortController();
|
|
527
|
+
const restoreEnv = scopeEnv({
|
|
528
|
+
ALISIO_CONFIG_HOME: dirs.config,
|
|
529
|
+
ALISIO_STATE_HOME: dirs.state,
|
|
530
|
+
ALISIO_TMPDIR: dirs.tmp,
|
|
531
|
+
});
|
|
532
|
+
// Ids of the run (sessions, plans, executions) come from a seed, not from fresh randomness.
|
|
533
|
+
if (mode !== "live")
|
|
534
|
+
setIdSource(seededIdSource(`${suite.suiteId}/${task.id}`));
|
|
535
|
+
let app;
|
|
536
|
+
let timer;
|
|
537
|
+
try {
|
|
538
|
+
app = await createApplication({
|
|
539
|
+
cwd: ws,
|
|
540
|
+
db: join(dirs.state, "sessions.sqlite"),
|
|
541
|
+
provider: effectiveProvider,
|
|
542
|
+
noHerdr: true,
|
|
543
|
+
trustProject: false,
|
|
544
|
+
allowWrite: plan.policy.write,
|
|
545
|
+
allowProcess: plan.policy.process,
|
|
546
|
+
allowExternal: plan.policy.external,
|
|
547
|
+
allowAnalysis: plan.policy.analysis,
|
|
548
|
+
readOnly: plan.readOnly,
|
|
549
|
+
...(opts.builtins ? { builtins: opts.builtins } : {}),
|
|
550
|
+
...(task.policy.mode === "ask" && !plan.readOnly
|
|
551
|
+
? { approve: async () => "deny" }
|
|
552
|
+
: {}),
|
|
553
|
+
onEvent: (event) => {
|
|
554
|
+
opts.onEvent?.(event);
|
|
555
|
+
if (observer.handle(event, budget.maxToolCalls)) {
|
|
556
|
+
budgetReason ??= "maxToolCalls";
|
|
557
|
+
controller.abort(new EvalBudgetExceeded("maxToolCalls"));
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
model = app.provider.model;
|
|
562
|
+
const agentOptions = task.agent && opts.resolveAgent ? await opts.resolveAgent(app, task.agent) : {};
|
|
563
|
+
const sessionId = app.store.create(app.workspace, app.provider.id, app.provider.model).id;
|
|
564
|
+
if (normalizer) {
|
|
565
|
+
normalizer.registerSession(sessionId);
|
|
566
|
+
// The first request's output limit comes from the catalog: wait for it, do not race it.
|
|
567
|
+
await app.loadModels(AbortSignal.timeout(10_000)).catch(() => undefined);
|
|
568
|
+
}
|
|
569
|
+
const runOptions = {
|
|
570
|
+
...agentOptions,
|
|
571
|
+
// Replay makes no timing promise: a silent-request retry would consume a call twice.
|
|
572
|
+
...(mode === "replay" ? { firstTokenTimeoutMs: 0 } : {}),
|
|
573
|
+
maxTurns: budget.maxTurns,
|
|
574
|
+
...(budget.maxTokens !== undefined ? { maxTokens: budget.maxTokens } : {}),
|
|
575
|
+
timeoutMs: budget.maxWallMs + 5_000,
|
|
576
|
+
runId,
|
|
577
|
+
};
|
|
578
|
+
timer = setTimeout(() => {
|
|
579
|
+
budgetReason ??= "maxWallMs";
|
|
580
|
+
controller.abort(new EvalBudgetExceeded("maxWallMs"));
|
|
581
|
+
}, budget.maxWallMs);
|
|
582
|
+
const began = performance.now();
|
|
583
|
+
try {
|
|
584
|
+
const result = await app.runner.run(sessionId, task.prompt, controller.signal, runOptions);
|
|
585
|
+
usageStats = result.usage;
|
|
586
|
+
if (result.status === "turns-exceeded")
|
|
587
|
+
budgetReason ??= "maxTurns";
|
|
588
|
+
else if (result.status !== "completed")
|
|
589
|
+
modelFailure = `The run ended with status "${result.status}" (runner protocol violation).`;
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
const stats = runStatsOf(error);
|
|
593
|
+
if (stats)
|
|
594
|
+
usageStats = stats.usage;
|
|
595
|
+
if (!budgetReason) {
|
|
596
|
+
if (observer.timedOut)
|
|
597
|
+
budgetReason = "maxWallMs";
|
|
598
|
+
else if (budget.maxTokens !== undefined &&
|
|
599
|
+
Math.max(observer.tokens, usageStats ? usageStats.input + usageStats.output : 0) >=
|
|
600
|
+
budget.maxTokens)
|
|
601
|
+
budgetReason = "maxTokens";
|
|
602
|
+
else if (isModelCaused(error))
|
|
603
|
+
modelFailure = error instanceof Error ? error.message : String(error);
|
|
604
|
+
else
|
|
605
|
+
infraError = error instanceof Error ? error.message : String(error);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
finally {
|
|
609
|
+
wallMs = performance.now() - began;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
if (timer)
|
|
614
|
+
clearTimeout(timer);
|
|
615
|
+
try {
|
|
616
|
+
await app?.close();
|
|
617
|
+
}
|
|
618
|
+
finally {
|
|
619
|
+
restoreEnv();
|
|
620
|
+
if (mode !== "live")
|
|
621
|
+
setIdSource(undefined);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (replay) {
|
|
625
|
+
let reason = replay.failure;
|
|
626
|
+
if (!reason && !infraError && !budgetReason && !modelFailure) {
|
|
627
|
+
try {
|
|
628
|
+
replay.assertConsumed();
|
|
629
|
+
}
|
|
630
|
+
catch (error) {
|
|
631
|
+
if (error instanceof ReplayUnconsumedError)
|
|
632
|
+
reason = error;
|
|
633
|
+
else
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (reason) {
|
|
638
|
+
divergence = {
|
|
639
|
+
kind: reason instanceof ReplayDivergenceError
|
|
640
|
+
? "fingerprint"
|
|
641
|
+
: reason instanceof ReplayExhausted
|
|
642
|
+
? "exhausted"
|
|
643
|
+
: "unconsumed",
|
|
644
|
+
message: reason.message,
|
|
645
|
+
diff: reason instanceof ReplayDivergenceError ? reason.diff : [],
|
|
646
|
+
};
|
|
647
|
+
infraError = "replay_divergence";
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (recorder?.unstable && !infraError)
|
|
651
|
+
infraError = `Cassette recording failed: ${recorder.unstable}`;
|
|
652
|
+
if (!budgetReason &&
|
|
653
|
+
!infraError &&
|
|
654
|
+
budget.maxTokens !== undefined &&
|
|
655
|
+
observer.tokens > budget.maxTokens)
|
|
656
|
+
budgetReason = "maxTokens";
|
|
657
|
+
if (!infraError)
|
|
658
|
+
verifiers = await runVerifiers(task.verifiers, {
|
|
659
|
+
cwd: ws,
|
|
660
|
+
baseline,
|
|
661
|
+
toolEvents: observer.toolEvents(),
|
|
662
|
+
tmpDir: dirs.tmp,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
catch (error) {
|
|
666
|
+
infraError ??= error instanceof Error ? error.message : String(error);
|
|
667
|
+
}
|
|
668
|
+
const failed = verifiers.some((v) => v.status === "fail");
|
|
669
|
+
const verifierError = verifiers.find((v) => v.status === "error");
|
|
670
|
+
let status;
|
|
671
|
+
let error = infraError;
|
|
672
|
+
if (modelFailure && !infraError) {
|
|
673
|
+
observer.markAborted();
|
|
674
|
+
error = modelFailure;
|
|
675
|
+
}
|
|
676
|
+
if (infraError)
|
|
677
|
+
status = "error";
|
|
678
|
+
else if (modelFailure)
|
|
679
|
+
status = "fail";
|
|
680
|
+
else if (verifierError && !failed) {
|
|
681
|
+
status = "error";
|
|
682
|
+
error = `Verifier "${verifierError.id}" could not run: ${verifierError.detail ?? "unknown reason"}`;
|
|
683
|
+
}
|
|
684
|
+
else if (budgetReason)
|
|
685
|
+
status = "budget_exceeded";
|
|
686
|
+
else
|
|
687
|
+
status = failed ? "fail" : "pass";
|
|
688
|
+
const usage = observer.usage ??
|
|
689
|
+
(usageStats && usageStats.input + usageStats.output > 0 ? usageStats : undefined);
|
|
690
|
+
const record = {
|
|
691
|
+
schemaVersion: 1,
|
|
692
|
+
runId,
|
|
693
|
+
suiteId: suite.suiteId,
|
|
694
|
+
suiteVersion: suite.suiteVersion,
|
|
695
|
+
taskId: task.id,
|
|
696
|
+
repetition: opts.repetition,
|
|
697
|
+
seed: opts.seed,
|
|
698
|
+
// `loadVersion` falls back to "dev" when no package manifest is found (source runs).
|
|
699
|
+
harnessVersion: loadVersion(import.meta.url),
|
|
700
|
+
configHash,
|
|
701
|
+
compactionHash,
|
|
702
|
+
provider: providerId,
|
|
703
|
+
model: observer.model ?? model,
|
|
704
|
+
mode: mode === "replay" ? "replay" : "live",
|
|
705
|
+
startedAt,
|
|
706
|
+
endedAt: Date.now(),
|
|
707
|
+
status,
|
|
708
|
+
...(status === "budget_exceeded" && budgetReason ? { budgetExceeded: budgetReason } : {}),
|
|
709
|
+
...(error ? { error: error.slice(0, 500) } : {}),
|
|
710
|
+
verifiers: verifiers.map((v) => ({
|
|
711
|
+
id: v.id,
|
|
712
|
+
status: v.status,
|
|
713
|
+
...(v.detail === undefined ? {} : { detail: v.detail }),
|
|
714
|
+
})),
|
|
715
|
+
measured: {
|
|
716
|
+
...(usage ? { inputTokens: usage.input, outputTokens: usage.output } : {}),
|
|
717
|
+
...(usage && observer.usagePartial ? { usagePartial: true } : {}),
|
|
718
|
+
wallMs,
|
|
719
|
+
turns: observer.turns,
|
|
720
|
+
toolCalls: observer.startedCalls,
|
|
721
|
+
},
|
|
722
|
+
incidents: observer.incidentList(),
|
|
723
|
+
...(divergence ? { divergence } : {}),
|
|
724
|
+
};
|
|
725
|
+
if (runDir && !opts.keep)
|
|
726
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
727
|
+
return { record, ...(runDir && opts.keep ? { keptDir: runDir } : {}) };
|
|
728
|
+
}
|