@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.
Files changed (204) hide show
  1. package/dist/analysis/janitor.d.ts +48 -0
  2. package/dist/analysis/janitor.js +170 -18
  3. package/dist/analysis/jobs.d.ts +11 -2
  4. package/dist/analysis/jobs.js +13 -0
  5. package/dist/analysis/oci.d.ts +11 -2
  6. package/dist/analysis/oci.js +20 -4
  7. package/dist/analysis/python/sources.js +2 -1
  8. package/dist/analysis/rerun.d.ts +1 -1
  9. package/dist/analysis/status.d.ts +3 -1
  10. package/dist/analysis/status.js +3 -0
  11. package/dist/application.d.ts +28 -2
  12. package/dist/application.js +189 -4
  13. package/dist/changelog/data.js +42 -0
  14. package/dist/config.d.ts +110 -2
  15. package/dist/config.js +188 -6
  16. package/dist/core/call-internals.d.ts +22 -0
  17. package/dist/core/call-internals.js +9 -0
  18. package/dist/core/contracts.d.ts +2 -2
  19. package/dist/core/runner.js +111 -38
  20. package/dist/eval/canonical.d.ts +14 -0
  21. package/dist/eval/canonical.js +40 -0
  22. package/dist/eval/cassette.d.ts +180 -0
  23. package/dist/eval/cassette.js +424 -0
  24. package/dist/eval/compare.d.ts +147 -0
  25. package/dist/eval/compare.js +436 -0
  26. package/dist/eval/engine.d.ts +144 -0
  27. package/dist/eval/engine.js +728 -0
  28. package/dist/eval/export.d.ts +40 -0
  29. package/dist/eval/export.js +90 -0
  30. package/dist/eval/fingerprint.d.ts +88 -0
  31. package/dist/eval/fingerprint.js +186 -0
  32. package/dist/eval/normalize.d.ts +68 -0
  33. package/dist/eval/normalize.js +138 -0
  34. package/dist/eval/paths.d.ts +11 -0
  35. package/dist/eval/paths.js +50 -0
  36. package/dist/eval/plan.d.ts +26 -0
  37. package/dist/eval/plan.js +44 -0
  38. package/dist/eval/report.d.ts +53 -0
  39. package/dist/eval/report.js +258 -0
  40. package/dist/eval/run-suite.d.ts +68 -0
  41. package/dist/eval/run-suite.js +94 -0
  42. package/dist/eval/secrets.d.ts +12 -0
  43. package/dist/eval/secrets.js +45 -0
  44. package/dist/eval/stats.d.ts +17 -0
  45. package/dist/eval/stats.js +50 -0
  46. package/dist/eval/suite.d.ts +20 -0
  47. package/dist/eval/suite.js +165 -0
  48. package/dist/eval/variants.d.ts +35 -0
  49. package/dist/eval/variants.js +137 -0
  50. package/dist/eval/verifiers.d.ts +49 -0
  51. package/dist/eval/verifiers.js +264 -0
  52. package/dist/forge/broker.d.ts +51 -0
  53. package/dist/forge/broker.js +244 -0
  54. package/dist/forge/budget.d.ts +27 -0
  55. package/dist/forge/budget.js +60 -0
  56. package/dist/forge/catalog-view.d.ts +24 -0
  57. package/dist/forge/catalog-view.js +43 -0
  58. package/dist/forge/errors.d.ts +26 -0
  59. package/dist/forge/errors.js +49 -0
  60. package/dist/forge/guides/javascript.d.ts +25 -0
  61. package/dist/forge/guides/javascript.js +90 -0
  62. package/dist/forge/guides/provider.d.ts +42 -0
  63. package/dist/forge/guides/provider.js +96 -0
  64. package/dist/forge/guides/python-environment.d.ts +15 -0
  65. package/dist/forge/guides/python-environment.js +81 -0
  66. package/dist/forge/guides/python.d.ts +38 -0
  67. package/dist/forge/guides/python.js +170 -0
  68. package/dist/forge/javascript/engine.d.ts +44 -0
  69. package/dist/forge/javascript/engine.js +229 -0
  70. package/dist/forge/javascript/hosts/factory.d.ts +25 -0
  71. package/dist/forge/javascript/hosts/factory.js +21 -0
  72. package/dist/forge/javascript/hosts/framing.d.ts +17 -0
  73. package/dist/forge/javascript/hosts/framing.js +49 -0
  74. package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
  75. package/dist/forge/javascript/hosts/isolate-main.js +63 -0
  76. package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
  77. package/dist/forge/javascript/hosts/process-entry.js +3 -0
  78. package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
  79. package/dist/forge/javascript/hosts/process-host.js +160 -0
  80. package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
  81. package/dist/forge/javascript/hosts/worker-entry.js +3 -0
  82. package/dist/forge/javascript/hosts/worker-host.d.ts +27 -0
  83. package/dist/forge/javascript/hosts/worker-host.js +116 -0
  84. package/dist/forge/javascript/prelude.d.ts +2 -0
  85. package/dist/forge/javascript/prelude.js +333 -0
  86. package/dist/forge/javascript/probe.d.ts +2 -0
  87. package/dist/forge/javascript/probe.js +53 -0
  88. package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
  89. package/dist/forge/javascript/runtime-manifest.js +14 -0
  90. package/dist/forge/javascript/vm-runner.d.ts +21 -0
  91. package/dist/forge/javascript/vm-runner.js +301 -0
  92. package/dist/forge/javascript/wasm-asset.d.ts +8 -0
  93. package/dist/forge/javascript/wasm-asset.js +8 -0
  94. package/dist/forge/javascript/wasm.d.ts +15 -0
  95. package/dist/forge/javascript/wasm.js +54 -0
  96. package/dist/forge/journal.d.ts +105 -0
  97. package/dist/forge/journal.js +174 -0
  98. package/dist/forge/kernel.d.ts +76 -0
  99. package/dist/forge/kernel.js +339 -0
  100. package/dist/forge/limits.d.ts +38 -0
  101. package/dist/forge/limits.js +73 -0
  102. package/dist/forge/nested.d.ts +96 -0
  103. package/dist/forge/nested.js +214 -0
  104. package/dist/forge/ports.d.ts +120 -0
  105. package/dist/forge/ports.js +1 -0
  106. package/dist/forge/progress.d.ts +24 -0
  107. package/dist/forge/progress.js +76 -0
  108. package/dist/forge/protocol.d.ts +110 -0
  109. package/dist/forge/protocol.js +171 -0
  110. package/dist/forge/python/backend-selector.d.ts +38 -0
  111. package/dist/forge/python/backend-selector.js +111 -0
  112. package/dist/forge/python/backend.d.ts +89 -0
  113. package/dist/forge/python/backend.js +1 -0
  114. package/dist/forge/python/engine.d.ts +84 -0
  115. package/dist/forge/python/engine.js +279 -0
  116. package/dist/forge/python/frame-parser.d.ts +24 -0
  117. package/dist/forge/python/frame-parser.js +107 -0
  118. package/dist/forge/python/frame-transport.d.ts +40 -0
  119. package/dist/forge/python/frame-transport.js +101 -0
  120. package/dist/forge/python/import-scan.d.ts +22 -0
  121. package/dist/forge/python/import-scan.js +133 -0
  122. package/dist/forge/python/managed-backend.d.ts +30 -0
  123. package/dist/forge/python/managed-backend.js +111 -0
  124. package/dist/forge/python/oci-backend.d.ts +27 -0
  125. package/dist/forge/python/oci-backend.js +84 -0
  126. package/dist/forge/python/pyodide-backend.d.ts +40 -0
  127. package/dist/forge/python/pyodide-backend.js +289 -0
  128. package/dist/forge/python/pyodide-install.d.ts +41 -0
  129. package/dist/forge/python/pyodide-install.js +167 -0
  130. package/dist/forge/python/pyodide-manifest.d.ts +42 -0
  131. package/dist/forge/python/pyodide-manifest.js +376 -0
  132. package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
  133. package/dist/forge/python/pyodide-runner-source.js +3 -0
  134. package/dist/forge/python/rss.d.ts +2 -0
  135. package/dist/forge/python/rss.js +33 -0
  136. package/dist/forge/python/scratch-copy.d.ts +6 -0
  137. package/dist/forge/python/scratch-copy.js +61 -0
  138. package/dist/forge/python/settle.d.ts +6 -0
  139. package/dist/forge/python/settle.js +21 -0
  140. package/dist/forge/python/status.d.ts +8 -0
  141. package/dist/forge/python/status.js +15 -0
  142. package/dist/forge/python/wasm-node.d.ts +59 -0
  143. package/dist/forge/python/wasm-node.js +220 -0
  144. package/dist/forge/python/wiring.d.ts +39 -0
  145. package/dist/forge/python/wiring.js +47 -0
  146. package/dist/forge/registry.d.ts +26 -0
  147. package/dist/forge/registry.js +83 -0
  148. package/dist/forge/result.d.ts +58 -0
  149. package/dist/forge/result.js +86 -0
  150. package/dist/forge/routing-rule.d.ts +19 -0
  151. package/dist/forge/routing-rule.js +31 -0
  152. package/dist/forge/runtime.d.ts +64 -0
  153. package/dist/forge/runtime.js +88 -0
  154. package/dist/forge/scratch-files.d.ts +36 -0
  155. package/dist/forge/scratch-files.js +94 -0
  156. package/dist/forge/scratch-provider.d.ts +41 -0
  157. package/dist/forge/scratch-provider.js +79 -0
  158. package/dist/forge/scratch-quota.d.ts +8 -0
  159. package/dist/forge/scratch-quota.js +25 -0
  160. package/dist/forge/selector.d.ts +35 -0
  161. package/dist/forge/selector.js +51 -0
  162. package/dist/forge/session-dir.d.ts +59 -0
  163. package/dist/forge/session-dir.js +239 -0
  164. package/dist/forge/session-path.d.ts +31 -0
  165. package/dist/forge/session-path.js +61 -0
  166. package/dist/forge/slots.d.ts +16 -0
  167. package/dist/forge/slots.js +47 -0
  168. package/dist/forge/state-delta.d.ts +54 -0
  169. package/dist/forge/state-delta.js +99 -0
  170. package/dist/forge/state-machine.d.ts +20 -0
  171. package/dist/forge/state-machine.js +40 -0
  172. package/dist/forge/state-store.d.ts +25 -0
  173. package/dist/forge/state-store.js +50 -0
  174. package/dist/forge/task-log.d.ts +9 -0
  175. package/dist/forge/task-log.js +42 -0
  176. package/dist/forge/verdict.d.ts +20 -0
  177. package/dist/forge/verdict.js +28 -0
  178. package/dist/forge-isolate.d.ts +6 -0
  179. package/dist/forge-isolate.js +6 -0
  180. package/dist/index.d.ts +23 -1
  181. package/dist/index.js +23 -1
  182. package/dist/permissions/decide.d.ts +42 -0
  183. package/dist/permissions/decide.js +24 -0
  184. package/dist/plan/exit-plan.js +2 -1
  185. package/dist/resources/context.d.ts +2 -1
  186. package/dist/resources/context.js +2 -1
  187. package/dist/runtime/access.d.ts +34 -0
  188. package/dist/runtime/access.js +80 -0
  189. package/dist/runtime/ids.d.ts +21 -0
  190. package/dist/runtime/ids.js +41 -1
  191. package/dist/runtime/process.d.ts +2 -1
  192. package/dist/runtime/process.js +5 -5
  193. package/dist/runtime/store.d.ts +11 -0
  194. package/dist/runtime/store.js +106 -2
  195. package/dist/tools/analysis.d.ts +25 -2
  196. package/dist/tools/analysis.js +315 -267
  197. package/dist/tools/execute.js +6 -7
  198. package/dist/tools/forge-guide.d.ts +6 -0
  199. package/dist/tools/forge-guide.js +60 -0
  200. package/dist/tools/js-run.d.ts +19 -0
  201. package/dist/tools/js-run.js +126 -0
  202. package/dist/tools/standard.d.ts +7 -1
  203. package/dist/tools/standard.js +44 -43
  204. 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
+ }