@leostera/evalkit 0.9.0 → 0.11.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/cli.mjs CHANGED
@@ -1,16 +1,18 @@
1
1
  #!/bin/sh
2
2
  ':' //; exec "${EVALKIT_RUNTIME:-$(command -v bun || command -v node)}" "$0" "$@"
3
- import { H as canonicalParameters, V as defineEvalMatrix, a as authoringId, c as defineSuite, i as agentRuntimes, m as registerEvals, p as recordError } from "./src-BrfIXfO2.mjs";
4
- import { a as readRunEvents, c as readRunSummary, d as staticDatasetFile, f as staticDatasetFiles, i as ownerIsGone, l as localReportStore, n as upgradeReports, o as readRunManifest, p as globMatcher, r as runMatrix, t as runEval, u as runWorkspacePath } from "./src-CcdLZqFk.mjs";
5
- import { f as encodeBundle, i as normalizeBasePath, l as dashboardIndex, s as repoKey } from "./index-db-BbVblaHz.mjs";
6
- import { t as createDirectoryShareApp } from "./src-Bn6fnZnF.mjs";
3
+ import { Et as resolveLimits, Q as canonicalParameters, St as formatLimit, Tt as parseLimits, Z as defineEvalMatrix, _ as recordError, i as agentRuntimes, l as defineSuite, lt as passMark, o as authoringId, v as registerEvals, wt as parseLimitFlag } from "./src-D6Ax2t9b.mjs";
4
+ import { _ as removeRunContainer, a as readRunEvents, c as readRunSubagents, d as localReportStore, f as runWorkspacePath, g as fixtureSet, h as globMatcher, i as ownerIsGone, l as readRunSummary, m as staticDatasetFiles, n as upgradeReports, o as readRunManifest, p as staticDatasetFile, r as runMatrix, t as runEval, u as readRunTranscript } from "./src-Dc3GXacB.mjs";
5
+ import { f as encodeBundle, i as normalizeBasePath, l as dashboardIndex, s as repoKey } from "./index-db-DI7xKMSX.mjs";
6
+ import { t as createDirectoryShareApp } from "./src-CXzbHAKN.mjs";
7
+ import { createHash } from "node:crypto";
7
8
  import { Effect, LogLevel, Logger } from "effect";
9
+ import { tmpdir } from "node:os";
8
10
  import { cp, lstat, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
9
- import path, { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
11
+ import path, { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
12
+ import childProcess, { execFile, spawn } from "node:child_process";
10
13
  import fs, { existsSync } from "node:fs";
11
14
  import { fileURLToPath, pathToFileURL } from "node:url";
12
15
  import { Hono } from "hono";
13
- import childProcess, { execFile, spawn } from "node:child_process";
14
16
  import { promisify, stripVTControlCharacters } from "node:util";
15
17
  import { createServer } from "node:http";
16
18
  import { EventEmitter } from "node:events";
@@ -166,6 +168,19 @@ function shortSource(source) {
166
168
  //#endregion
167
169
  //#region ../cli/src/dashboard-reports.ts
168
170
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
171
+ const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g;
172
+ const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g;
173
+ /**
174
+ * The first non-empty line of a message, without terminal colours, cut to
175
+ * `max` characters: a scorer may explain itself with 300 lines of stderr,
176
+ * and a summary line must stay one line. The run page has the rest.
177
+ */
178
+ function firstLine(text, max = 100) {
179
+ const lines = (text ?? "").replace(ANSI, "").split(/\r?\n/).map((line) => line.replace(CONTROL, "").trim()).filter(Boolean);
180
+ const line = lines[0] ?? "";
181
+ const more = lines.length > 1 ? " …" : "";
182
+ return line.length > max ? `${line.slice(0, max - 1)}…` : `${line}${more}`;
183
+ }
169
184
  /** Written into a run directory by `evalkit tidy`; deleting it restores the run. */
170
185
  const ARCHIVE_MARKER = "archived.json";
171
186
  function isMissing(error) {
@@ -226,7 +241,7 @@ function createDashboardReportReader(root) {
226
241
  } catch {
227
242
  return false;
228
243
  }
229
- return typeof version === "number" && version < 6;
244
+ return typeof version === "number" && version < 7;
230
245
  }
231
246
  async function readRun(id) {
232
247
  if (await isLegacyRun(id)) {
@@ -256,6 +271,9 @@ function createDashboardReportReader(root) {
256
271
  manifest.aut.version
257
272
  ].filter(Boolean).join(" / ") } : {},
258
273
  status: summary ? summary.status : interrupted ? "cancelled" : "running",
274
+ ...manifest.limits ? { limits: manifest.limits } : {},
275
+ ...manifest.isolation ? { isolation: manifest.isolation } : {},
276
+ ...summary?.limit ? { limit: summary.limit } : {},
259
277
  ...interrupted ? { interrupted: true } : {},
260
278
  startedAt: manifest.startedAt,
261
279
  ...summary?.endedAt ? { completedAt: summary.endedAt } : {},
@@ -266,7 +284,12 @@ function createDashboardReportReader(root) {
266
284
  name: result.name,
267
285
  kind: result.kind,
268
286
  ...result.skipped ? { skipped: true } : {},
269
- ...result.value === void 0 || result.skipped ? {} : { value: result.value }
287
+ ...result.value === void 0 || result.skipped ? {} : { value: result.value },
288
+ ...result.passed === void 0 || result.skipped ? {} : { passed: result.passed },
289
+ ...result.error ? { errored: true } : {},
290
+ ...result.choice ? { choice: result.choice } : {},
291
+ ...result.explanation ? { explanation: firstLine(result.explanation, 200) } : {},
292
+ ...result.judge?.model ? { model: result.judge.model } : {}
270
293
  })) } : {},
271
294
  ...scoring?.metrics?.length ? { metrics: scoring.metrics.map((metric) => ({
272
295
  name: metric.name,
@@ -312,9 +335,19 @@ function createDashboardReportReader(root) {
312
335
  }
313
336
  const candidateFile = (runId, path) => artifactFile(runId, "candidate", path);
314
337
  const attachmentFile = (runId, name) => artifactFile(runId, "attachments", name);
315
- async function trajectory(runId) {
338
+ /** The agent's transcript and EvalKit's events, as far as a running run has got. */
339
+ async function transcript(runId) {
316
340
  runPath(root, runId);
317
- return await ifPresent(() => readRunEvents(root, runId)) ?? [];
341
+ const [recorded, subagents, runEvents] = await Promise.all([
342
+ ifPresent(() => readRunTranscript(root, runId)),
343
+ readRunSubagents(root, runId),
344
+ ifPresent(() => readRunEvents(root, runId))
345
+ ]);
346
+ return {
347
+ transcript: recorded ?? null,
348
+ subagents,
349
+ runEvents: runEvents ?? []
350
+ };
318
351
  }
319
352
  return {
320
353
  listRuns,
@@ -325,7 +358,7 @@ function createDashboardReportReader(root) {
325
358
  listCandidateWorkspace,
326
359
  candidateFile,
327
360
  attachmentFile,
328
- trajectory
361
+ transcript
329
362
  };
330
363
  }
331
364
  /** Both artifact endpoints walk a specific root; never broaden the candidate-file read root. */
@@ -456,13 +489,22 @@ function staticRoutes(ids) {
456
489
  ...under("library/scenarios", ids.scenarios),
457
490
  ...under("library/agents", ids.agents),
458
491
  ...under("library/datasets", ids.datasets),
459
- ...ids.runs.filter(safe).flatMap((id) => [
460
- `runs/${id}`,
461
- `runs/${id}/workspace`,
462
- `runs/${id}/attachments`
463
- ])
492
+ ...ids.runs.filter(safe).flatMap((id) => [`runs/${id}`, ...RUN_SECTIONS.map((section) => `runs/${id}/${section}`)])
464
493
  ];
465
494
  }
495
+ /**
496
+ * A run's sections, each its own page; `attachments` is the logs' URL from
497
+ * before 0.10, kept so old links redirect.
498
+ */
499
+ const RUN_SECTIONS = [
500
+ "judging",
501
+ "metrics",
502
+ "workspace",
503
+ "logs",
504
+ "datasets",
505
+ "raw",
506
+ "attachments"
507
+ ];
466
508
  /** `runs/abc.html` is served at `<mount>/runs/abc`; its base is one level up. */
467
509
  function baseFor(route) {
468
510
  const depth = route.split("/").length - 1;
@@ -493,7 +535,7 @@ function directorySink(root) {
493
535
  }
494
536
  /**
495
537
  * One run's snapshot, shared by `evalkit build` (files on disk) and
496
- * `evalkit share` (an upload bundle): `detail.json`, `events.json`,
538
+ * `evalkit share` (an upload bundle): `detail.json`, `transcript.json`,
497
539
  * `artifacts.json` and `workspace.json`, the candidate files under
498
540
  * `workspace/`, and attachments under `attachments/` (both left out with
499
541
  * `workspaceFiles: false`; they stay listed). Evaluator workspaces are never
@@ -502,7 +544,7 @@ function directorySink(root) {
502
544
  async function snapshotRun(reports, runId, sink, options = {}) {
503
545
  const detail = await reports.runDetail(runId);
504
546
  await sink.json("detail.json", detail);
505
- await sink.json("events.json", { events: await reports.trajectory(runId) });
547
+ await sink.json("transcript.json", await reports.transcript(runId));
506
548
  await sink.json("artifacts.json", { artifacts: await reports.listArtifacts(runId) });
507
549
  const workspace = await reports.listCandidateWorkspace(runId);
508
550
  await sink.json("workspace.json", workspace);
@@ -843,6 +885,7 @@ async function loadConfig(cwd, configPath) {
843
885
  const config = file ? (await importProjectModule(file)).default : {};
844
886
  if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("Config must default-export defineConfig({...})");
845
887
  if (config.execution && "trials" in config.execution) throw new Error("execution.trials was renamed to execution.repeat in EvalKit 0.9");
888
+ if (config.judge !== void 0 && typeof config.judge?.start !== "function") throw new Error("judge in the config must be an agent made with defineAgent()");
846
889
  for (const [key, value] of Object.entries(config.execution ?? {})) if (value !== void 0 && (!Number.isSafeInteger(value) || value < 1)) throw new Error(`execution.${key} must be a positive integer`);
847
890
  return {
848
891
  root,
@@ -880,6 +923,17 @@ async function loadProject(cwd, configPath) {
880
923
  matrices: [...registry.matrices, matrix]
881
924
  };
882
925
  }
926
+ const judge = config.judge;
927
+ if (judge) {
928
+ const catalog = registry.catalog;
929
+ registry = {
930
+ ...registry,
931
+ catalog: (options) => catalog({
932
+ judge,
933
+ ...options
934
+ })
935
+ };
936
+ }
883
937
  return {
884
938
  root,
885
939
  config,
@@ -887,6 +941,34 @@ async function loadProject(cwd, configPath) {
887
941
  loadErrors
888
942
  };
889
943
  }
944
+ /**
945
+ * Where a project's run workspaces live. By default outside the project, in
946
+ * the system temp directory (one directory per project, so `tidy` and `dev`
947
+ * find the ones it kept): an agent that looks around its workspace finds no
948
+ * project source, no `AGENTS.md` or skills above it, and no results.
949
+ * `sandboxDir` overrides it; `inside` says when that puts them back in the
950
+ * project, which `run` warns about.
951
+ */
952
+ function sandboxLocation(project) {
953
+ if (project.config.sandboxDir === void 0) return {
954
+ path: defaultSandboxDir(project.root),
955
+ inside: false
956
+ };
957
+ const path = resolve(project.root, project.config.sandboxDir);
958
+ const fromRoot = relative(project.root, path);
959
+ return {
960
+ path,
961
+ inside: !fromRoot.startsWith("..") && !isAbsolute(fromRoot)
962
+ };
963
+ }
964
+ /** `<tmp>/evalkit/<project name>-<hash of its path>`: stable per checkout. */
965
+ function defaultSandboxDir(root) {
966
+ const hash = createHash("sha256").update(root).digest("hex").slice(0, 10);
967
+ const name = basename(root).replace(/[^A-Za-z0-9._-]/g, "-") || "project";
968
+ return join(tmpdir(), "evalkit", `${name}-${hash}`);
969
+ }
970
+ /** The warning `run` prints when workspaces sit inside the project. */
971
+ const SANDBOX_INSIDE_WARNING = "sandboxDir is inside the project: agents can read its source, its AGENTS.md and skills, its results and other runs’ workspaces from there. Leave sandboxDir unset to keep workspaces in the system temp directory.";
890
972
  //#endregion
891
973
  //#region ../cli/src/terminal.ts
892
974
  /** Human CLI output. Colors and live progress only appear on an interactive terminal. */
@@ -910,8 +992,8 @@ function bytes(size) {
910
992
  if (size < 1024 ** 2) return `${(size / 1024).toFixed(1)} kB`;
911
993
  return `${(size / 1024 ** 2).toFixed(1)} MB`;
912
994
  }
913
- function plural(count, noun) {
914
- return `${count} ${noun}${count === 1 ? "" : "s"}`;
995
+ function plural(count, noun, nouns = `${noun}s`) {
996
+ return `${count} ${count === 1 ? noun : nouns}`;
915
997
  }
916
998
  /** Times one build step, showing a live line while it runs. */
917
999
  async function step(label, work, describe) {
@@ -1009,30 +1091,50 @@ async function confirm(question) {
1009
1091
  //#region ../cli/src/run-reporter.ts
1010
1092
  function runOutcome(result) {
1011
1093
  if (result.status === "cancelled") return "cancelled";
1094
+ if (result.status === "limited") return "limited";
1012
1095
  return result.status === "passed" ? "passed" : "failed";
1013
1096
  }
1014
- function passAtFor(definition, name) {
1015
- const rule = [...definition.transcript, ...definition.scoring].find((step) => step.kind === "predicate" && step.name === name);
1016
- return rule?.kind === "predicate" ? rule.passAt : void 0;
1017
- }
1018
1097
  function value(score) {
1019
1098
  return score.value === void 0 ? "" : score.value.toFixed(1);
1020
1099
  }
1100
+ /**
1101
+ * One failed verdict in words: `history 0.0 (passes at 1): got 2 entries`,
1102
+ * or for a choices judge `Correctness C (passes at A or B): wrong product`.
1103
+ */
1104
+ function verdictReason(definition, verdict) {
1105
+ const rule = [...definition.transcript, ...definition.scoring].find((step) => step.kind === "judge" && step.name === verdict.name);
1106
+ const answer = rule?.kind === "judge" ? rule.answer : void 0;
1107
+ const mark = verdict.passAt === void 0 ? "" : answer?.kind === "choices" ? ` (passes at ${passMark(verdict.passAt, answer)})` : ` (passes at ${verdict.passAt})`;
1108
+ const why = [criteriaReason(verdict.criteria), firstLine(verdict.explanation)].filter(Boolean).join(" · ");
1109
+ return `${verdict.name} ${verdict.choice ?? value(verdict)}${mark}${why ? `: ${why}` : ""}`;
1110
+ }
1111
+ /**
1112
+ * Which criteria let the score down, so seven checks the app never reached
1113
+ * read as one cause (`not reached: saves, history`), not seven failures.
1114
+ */
1115
+ function criteriaReason(criteria) {
1116
+ if (!criteria?.length) return "";
1117
+ const failed = criteria.filter((criterion) => "value" in criterion && criterion.value < 1);
1118
+ const unreached = criteria.filter((criterion) => !("value" in criterion));
1119
+ return [failed.length ? `failed ${failed.map((criterion) => {
1120
+ const why = firstLine(criterion.explanation, 60);
1121
+ return why ? `${criterion.name} (${why})` : criterion.name;
1122
+ }).join(", ")}` : "", unreached.length ? `not reached ${unreached.map((criterion) => criterion.name).join(", ")}` : ""].filter(Boolean).join(" · ");
1123
+ }
1021
1124
  function scoreReason(definition, score) {
1022
- if (score.error) return `${score.name} error: ${score.error.message}`;
1023
- const passAt = passAtFor(definition, score.name);
1024
- return `${score.name} ${value(score)}${passAt === void 0 ? "" : ` (passes at ${passAt})`}`;
1125
+ if (score.error) return `${score.name} error: ${firstLine(score.error.message)}`;
1126
+ return verdictReason(definition, score);
1025
1127
  }
1026
1128
  function checkpointReason(definition, checkpoint) {
1027
- if (checkpoint.status === "error") return `step ${checkpoint.step} ${checkpoint.name} error: ${checkpoint.error?.message ?? ""}`;
1028
- const passAt = passAtFor(definition, checkpoint.name);
1029
- return `step ${checkpoint.step} ${checkpoint.name} ${checkpoint.value === void 0 ? checkpoint.status : value(checkpoint)}${passAt === void 0 ? "" : ` (passes at ${passAt})`}`;
1129
+ if (checkpoint.status === "error") return `step ${checkpoint.step} ${checkpoint.name} error: ${firstLine(checkpoint.error?.message)}`;
1130
+ if (checkpoint.value === void 0 && !checkpoint.choice) return `step ${checkpoint.step} ${checkpoint.name} ${checkpoint.status}`;
1131
+ return `step ${checkpoint.step} ${verdictReason(definition, checkpoint)}`;
1030
1132
  }
1031
1133
  /** What went wrong in a run. */
1032
1134
  function reasonsFor(definition, run) {
1033
1135
  const reasons = /* @__PURE__ */ new Set();
1034
1136
  const skipped = /* @__PURE__ */ new Set();
1035
- if (run.status === "errored" && run.error) reasons.add(`error: ${run.error.message}`);
1137
+ if (run.status === "errored" && run.error) reasons.add(`error: ${firstLine(run.error.message, 160)}`);
1036
1138
  for (const checkpoint of run.scoring?.checkpoints ?? []) if (checkpoint.status === "failed" || checkpoint.status === "error") reasons.add(checkpointReason(definition, checkpoint));
1037
1139
  for (const score of run.scoring?.results ?? []) if (score.skipped) skipped.add(score.name);
1038
1140
  else if (!score.passed) reasons.add(scoreReason(definition, score));
@@ -1060,17 +1162,26 @@ function parameterLabel(parameters) {
1060
1162
  }
1061
1163
  /** One printed line for a finished run; failing reasons on the same line. */
1062
1164
  function runLine(scenario) {
1063
- const mark = scenario.outcome === "passed" ? green("✓") : scenario.outcome === "cancelled" ? yellow("◌") : red("✗");
1165
+ const mark = scenario.outcome === "passed" ? green("✓") : scenario.outcome === "cancelled" ? yellow("◌") : scenario.outcome === "limited" ? yellow("✗") : red("✗");
1064
1166
  const parameters = parameterLabel(scenario.parameters);
1065
1167
  const batch = scenario.result.batch;
1066
1168
  const position = batch ? dim(` #${batch.index + 1}/${batch.size}`) : "";
1067
1169
  const details = [
1068
- ...scenario.outcome === "failed" ? scenario.reasons : [],
1170
+ ...scenario.outcome === "limited" ? [limitReason(scenario.result)] : [],
1171
+ ...scenario.outcome === "failed" || scenario.outcome === "limited" ? scenario.reasons : [],
1069
1172
  ...scenario.outcome === "cancelled" ? ["cancelled"] : [],
1070
1173
  ...scenario.skipped.map((name) => `${name} skipped`)
1071
1174
  ];
1072
1175
  return ` ${mark} ${bold(scenario.label)}${parameters ? ` ${dim(parameters)}` : ""}${position}${details.length ? ` ${dim(details.join(" · "))}` : ""}`;
1073
1176
  }
1177
+ /** `limited: 41 of 40 steps · score 0.40 (graded what it left)`. */
1178
+ function limitReason(result) {
1179
+ const limit = result.limit;
1180
+ if (!limit) return "limited";
1181
+ const used = limit.limit === "time" ? `${formatLimit("time", Math.round(limit.used / 1e3) * 1e3)} of ${formatLimit("time", limit.value)}` : `${limit.used.toLocaleString("en-US")} of ${formatLimit(limit.limit, limit.value)}`;
1182
+ const overall = result.scoring?.overall;
1183
+ return `limited: ${used}${overall === void 0 || !result.scoring?.results.length ? "" : ` · score ${overall.toFixed(2)} (graded what it left)`}`;
1184
+ }
1074
1185
  /**
1075
1186
  * Prints run progress. On a terminal, failures print as they happen and a live
1076
1187
  * line shows counts and what is running. Elsewhere (CI), every finished
@@ -1115,8 +1226,15 @@ function createRunReporter(options) {
1115
1226
  timer.unref?.();
1116
1227
  }
1117
1228
  },
1118
- onEvent(run, event) {
1119
- if (event.source === "runner" && event.kind === "run-started") {
1229
+ onUpdate(run, update) {
1230
+ if (update.kind === "record") {
1231
+ const activity = running.get(run.runId);
1232
+ const call = update.record.type === "message" && update.record.role === "assistant" ? update.record.content.filter((block) => block.type === "tool_call").at(-1) : void 0;
1233
+ if (activity && call?.type === "tool_call") activity.doing = `tool ${call.name}`;
1234
+ return;
1235
+ }
1236
+ const event = update.event;
1237
+ if (event.kind === "run-started") {
1120
1238
  running.set(run.runId, {
1121
1239
  label: options.labelFor(run),
1122
1240
  startedAt: now()
@@ -1125,10 +1243,10 @@ function createRunReporter(options) {
1125
1243
  }
1126
1244
  const activity = running.get(run.runId);
1127
1245
  if (!activity) return;
1128
- if (event.source === "runner" && (event.kind === "run-completed" || event.kind === "error")) running.delete(run.runId);
1129
- else if (event.source === "aut" && event.kind === "tool-call") activity.doing = `tool ${event.name}`;
1130
- else if (event.source === "runner" && event.kind === "scorer-started") activity.doing = `scoring ${event.scorer}`;
1131
- else if (event.source === "runner" && event.kind === "checkpoint-started") activity.doing = `check ${event.name}`;
1246
+ if (event.kind === "run-completed" || event.kind === "error") running.delete(run.runId);
1247
+ else if (event.kind === "limit-reached") activity.doing = `stopping at its ${event.limit} limit`;
1248
+ else if (event.kind === "scorer-started") activity.doing = `scoring ${event.scorer}`;
1249
+ else if (event.kind === "checkpoint-started") activity.doing = `check ${event.name}`;
1132
1250
  },
1133
1251
  onRun(scenario) {
1134
1252
  scenarios.push(scenario);
@@ -1145,6 +1263,7 @@ function createRunReporter(options) {
1145
1263
  return {
1146
1264
  passed: scenarios.filter((s) => s.outcome === "passed").length,
1147
1265
  failed: scenarios.filter((s) => s.outcome === "failed").length,
1266
+ limited: scenarios.filter((s) => s.outcome === "limited").length,
1148
1267
  cancelled: scenarios.filter((s) => s.outcome === "cancelled").length,
1149
1268
  runs: scenarios.length,
1150
1269
  scenarios: new Set(scenarios.map((s) => `${s.label} ${parameterLabel(s.parameters)}`)).size,
@@ -1158,6 +1277,7 @@ function totalsLine(totals) {
1158
1277
  const parts = [
1159
1278
  green(`${totals.passed} passed`),
1160
1279
  totals.failed ? red(`${totals.failed} failed`) : "",
1280
+ totals.limited ? yellow(`${totals.limited} limited`) : "",
1161
1281
  totals.cancelled ? yellow(`${totals.cancelled} cancelled`) : ""
1162
1282
  ].filter(Boolean);
1163
1283
  const coverage = totals.runs === totals.scenarios ? plural(totals.runs, "run") : `${plural(totals.runs, "run")} of ${plural(totals.scenarios, "scenario")}`;
@@ -1181,13 +1301,17 @@ function junitReport(scenarios) {
1181
1301
  bySuite.set(suite, [...bySuite.get(suite) ?? [], scenario]);
1182
1302
  }
1183
1303
  const suites = [...bySuite].map(([suite, cases]) => {
1184
- const failures = cases.filter((c) => c.outcome === "failed").length;
1304
+ const failures = cases.filter((c) => c.outcome === "failed" || c.outcome === "limited").length;
1185
1305
  const skipped = cases.filter((c) => c.outcome === "cancelled" || c.outcome === "passed" && c.skipped.length > 0 && allSkipped(c)).length;
1186
1306
  const time = cases.reduce((sum, c) => sum + (c.durationMs ?? 0), 0);
1187
1307
  const testcases = cases.map((scenario) => {
1188
1308
  const parameters = parameterLabel(scenario.parameters);
1189
1309
  const batch = scenario.result.batch;
1190
1310
  const attributes = `name="${escape((parameters ? `${scenario.evalId} [${parameters}]` : scenario.evalId) + (batch ? ` #${batch.index + 1}` : ""))}" classname="${escape(suite)}" time="${seconds(scenario.durationMs)}"`;
1311
+ if (scenario.outcome === "limited") {
1312
+ const reasons = [limitReason(scenario.result), ...scenario.reasons];
1313
+ return ` <testcase ${attributes}>\n <failure message="${escape(reasons.join("; "))}" type="limited">${escape(reasons.join("\n"))}</failure>\n </testcase>`;
1314
+ }
1191
1315
  if (scenario.outcome === "failed") return ` <testcase ${attributes}>\n <failure message="${escape(scenario.reasons.join("; ") || "failed")}">${escape(scenario.reasons.join("\n"))}</failure>\n </testcase>`;
1192
1316
  if (scenario.outcome === "cancelled") return ` <testcase ${attributes}>\n <skipped message="cancelled"/>\n </testcase>`;
1193
1317
  if (allSkipped(scenario)) return ` <testcase ${attributes}>\n <skipped message="${escape(`all scorers skipped: ${scenario.skipped.join(", ")}`)}"/>\n </testcase>`;
@@ -1195,7 +1319,7 @@ function junitReport(scenarios) {
1195
1319
  });
1196
1320
  return ` <testsuite name="${escape(suite)}" tests="${cases.length}" failures="${failures}" errors="0" skipped="${skipped}" time="${seconds(time)}">\n${testcases.join("\n")}\n </testsuite>`;
1197
1321
  });
1198
- return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites name="evalkit" tests="${scenarios.length}" failures="${scenarios.filter((c) => c.outcome === "failed").length}">\n${suites.join("\n")}\n</testsuites>\n`;
1322
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites name="evalkit" tests="${scenarios.length}" failures="${scenarios.filter((c) => c.outcome === "failed" || c.outcome === "limited").length}">\n${suites.join("\n")}\n</testsuites>\n`;
1199
1323
  }
1200
1324
  /** True when every score was skip()ped. */
1201
1325
  function allSkipped(scenario) {
@@ -1203,9 +1327,71 @@ function allSkipped(scenario) {
1203
1327
  return results.length > 0 && results.every((score) => score.skipped);
1204
1328
  }
1205
1329
  //#endregion
1330
+ //#region ../cli/src/run-options.ts
1331
+ /**
1332
+ * What every run of the project shares, from its config, wherever it is
1333
+ * launched: the CLI's `run` and the dashboard's buttons.
1334
+ */
1335
+ function projectRunOptions(root, config) {
1336
+ return {
1337
+ root,
1338
+ ...config?.isolation ? { isolation: config.isolation } : {},
1339
+ limits: { base: parseLimits(config?.limits, "The config") },
1340
+ ...config?.keepSandboxes ? { keepWorkspaces: config.keepSandboxes } : {},
1341
+ ...config?.artifacts?.exclude ? { artifactExclude: config.artifacts.exclude } : {},
1342
+ ...config?.artifacts?.attachments ? { attachmentLimits: config.artifacts.attachments } : {},
1343
+ ...config?.judge ? { judge: config.judge } : {}
1344
+ };
1345
+ }
1346
+ /**
1347
+ * The project's fixtures for one invocation, or undefined when it has
1348
+ * none. The caller stops them when its runs are done.
1349
+ */
1350
+ function projectFixtures(root, config) {
1351
+ const fixtures = config?.fixtures;
1352
+ if (!fixtures || !Object.keys(fixtures).length) return void 0;
1353
+ return fixtureSet(fixtures, { root });
1354
+ }
1355
+ /** Runs `work` with the project's fixtures, stopping them afterwards. */
1356
+ async function withFixtures(root, config, work) {
1357
+ const fixtures = projectFixtures(root, config);
1358
+ try {
1359
+ return await work(fixtures);
1360
+ } finally {
1361
+ await fixtures?.stop();
1362
+ }
1363
+ }
1364
+ //#endregion
1365
+ //#region ../cli/src/package-manager.ts
1366
+ const LOCKFILES = [
1367
+ ["bun.lock", "bun"],
1368
+ ["bun.lockb", "bun"],
1369
+ ["pnpm-lock.yaml", "pnpm"],
1370
+ ["yarn.lock", "yarn"],
1371
+ ["package-lock.json", "npm"]
1372
+ ];
1373
+ /**
1374
+ * The package manager a project uses: the one that started this process
1375
+ * (`npm_config_user_agent`), else the nearest lockfile at or above `root`
1376
+ * (a workspace keeps it at its root), else bun. Suggesting another manager's
1377
+ * command can make someone run its install and rewrite `node_modules`.
1378
+ */
1379
+ function detectPackageManager(root, userAgent = process.env.npm_config_user_agent) {
1380
+ const started = userAgent?.split("/")[0];
1381
+ if (started === "bun" || started === "pnpm" || started === "yarn" || started === "npm") return started;
1382
+ for (let dir = root;; dir = dirname(dir)) {
1383
+ for (const [file, manager] of LOCKFILES) if (existsSync(join(dir, file))) return manager;
1384
+ if (dirname(dir) === dir) return "bun";
1385
+ }
1386
+ }
1387
+ /** `pnpm run evalkit dev` (or `npm run evalkit -- dev`: npm needs the `--` for flags). */
1388
+ function evalkitCommand(manager, args) {
1389
+ return manager === "npm" ? `npm run evalkit -- ${args}` : `${manager} run evalkit ${args}`;
1390
+ }
1391
+ //#endregion
1206
1392
  //#region ../cli/src/version.ts
1207
1393
  /** The CLI version; kept in sync with package.json when releasing. */
1208
- const VERSION = "0.9.0";
1394
+ const VERSION = "0.11.0";
1209
1395
  //#endregion
1210
1396
  //#region ../cli/src/run-command.ts
1211
1397
  /** Validates `run` flags into a selection, parameter overrides and limits. */
@@ -1218,14 +1404,7 @@ function normalizeRunOptions(values, positionals) {
1218
1404
  if (!Number.isSafeInteger(number) || number < 1) throw new Error(`--${name} must be a positive integer`);
1219
1405
  return number;
1220
1406
  }
1221
- for (const [flag, key] of [
1222
- ["max-tokens", "maxTokens"],
1223
- ["chat-timeout-ms", "chatTimeoutMs"],
1224
- ["turn-budget", "turnBudget"]
1225
- ]) {
1226
- const value = integer(flag);
1227
- if (value !== void 0) parameters[key] = value;
1228
- }
1407
+ const limits = resolveLimits(...(values.limit ?? []).flatMap((value) => value.split(",")).map(parseLimitFlag));
1229
1408
  const selection = {};
1230
1409
  for (const key of ["model", "mode"]) if (values[key]?.length) selection[key] = values[key].flatMap((value) => value.split(","));
1231
1410
  for (const value of values.select ?? []) {
@@ -1251,6 +1430,7 @@ function normalizeRunOptions(values, positionals) {
1251
1430
  positionals,
1252
1431
  parameters,
1253
1432
  selection,
1433
+ limits,
1254
1434
  concurrency: integer("concurrency"),
1255
1435
  repeat: integer("repeat")
1256
1436
  };
@@ -1323,6 +1503,7 @@ async function runProjectCommand(cwd, parsed) {
1323
1503
  const cells = matrix.count(selection);
1324
1504
  if (!cells) throw new Error("Selection contains no eligible cells; check cases and exclusions");
1325
1505
  const repeat = parsed.repeat ?? project.config.execution?.repeat;
1506
+ const configLimits = parseLimits(project.config.limits, "The config");
1326
1507
  let runs = 0;
1327
1508
  for (const cell of matrix.cells(selection)) runs += repeat ?? cell.eval.repeat ?? 1;
1328
1509
  const plan = {
@@ -1332,7 +1513,14 @@ async function runProjectCommand(cwd, parsed) {
1332
1513
  runs,
1333
1514
  parameters: selection.parameters,
1334
1515
  overrides: selection.overrides,
1335
- repeat: repeat ?? "scenario"
1516
+ repeat: repeat ?? "scenario",
1517
+ limits: {
1518
+ config: configLimits,
1519
+ cli: parsed.limits
1520
+ },
1521
+ isolation: project.config.isolation ?? { kind: "none" },
1522
+ fixtures: Object.keys(project.config.fixtures ?? {}),
1523
+ scoring: Object.fromEntries(registry.catalog({ root: project.root }).filter((entry) => matrix.evals.some((definition) => definition.id === entry.id)).map((entry) => [entry.id, plannedChecks(entry)]))
1336
1524
  };
1337
1525
  if (values["dry-run"]) {
1338
1526
  console.log(JSON.stringify(plan, null, 2));
@@ -1341,7 +1529,9 @@ async function runProjectCommand(cwd, parsed) {
1341
1529
  const sweeps = Object.keys(matrix.parameters).length > 0 || Boolean(matrix.cases?.length);
1342
1530
  if (sweeps && cells > (project.config.execution?.maxCells ?? 100) && !values.all) throw new Error(`Selected ${cells} cells. Narrow the selection, inspect --dry-run, or pass --all.`);
1343
1531
  const reportDir = resolve(project.root, project.config.reportDir ?? "_evalkit-results");
1344
- const sandboxDir = resolve(project.root, project.config.sandboxDir ?? "_evalkit-sandbox");
1532
+ const sandbox = sandboxLocation(project);
1533
+ const sandboxDir = sandbox.path;
1534
+ if (sandbox.inside) console.error(` ${yellow("!")} ${SANDBOX_INSIDE_WARNING}`);
1345
1535
  const source = await detectSource(project.root, { outputDirs: [
1346
1536
  reportDir,
1347
1537
  sandboxDir,
@@ -1371,7 +1561,9 @@ async function runProjectCommand(cwd, parsed) {
1371
1561
  reporter.start();
1372
1562
  let summary;
1373
1563
  try {
1374
- summary = await Effect.runPromise(runnerLogs(runMatrix(matrix, {
1564
+ summary = await withFixtures(project.root, project.config, (fixtures) => Effect.runPromise(runnerLogs(runMatrix(matrix, {
1565
+ ...projectRunOptions(project.root, project.config),
1566
+ ...fixtures ? { fixtures } : {},
1375
1567
  selection,
1376
1568
  concurrency: parsed.concurrency ?? project.config.execution?.concurrency ?? 4,
1377
1569
  ...repeat ? { repeat } : {},
@@ -1382,11 +1574,12 @@ async function runProjectCommand(cwd, parsed) {
1382
1574
  catalog: runCatalog(registry, project.root),
1383
1575
  report: localReportStore(reportDir),
1384
1576
  workspaceRoot: sandboxDir,
1385
- keepWorkspaces: project.config.keepSandboxes,
1386
- artifactExclude: project.config.artifacts?.exclude,
1387
- attachmentLimits: project.config.artifacts?.attachments,
1388
1577
  signal: controller.signal,
1389
- onEvent: (run, event) => reporter.onEvent(run, event),
1578
+ limits: {
1579
+ base: configLimits,
1580
+ override: parsed.limits
1581
+ },
1582
+ onUpdate: (run, update) => reporter.onUpdate(run, update),
1390
1583
  onResult: (cell, results) => {
1391
1584
  for (const result of results) {
1392
1585
  const report = runReport(cell, result, suiteFor(authoringId(cell.eval)));
@@ -1402,7 +1595,7 @@ async function runProjectCommand(cwd, parsed) {
1402
1595
  reporter.onRun(report);
1403
1596
  }
1404
1597
  }
1405
- })));
1598
+ }))));
1406
1599
  } finally {
1407
1600
  process.off("SIGINT", onInterrupt);
1408
1601
  }
@@ -1416,12 +1609,31 @@ async function runProjectCommand(cwd, parsed) {
1416
1609
  else {
1417
1610
  console.log(`\n${totalsLine(totals)}`);
1418
1611
  if (values.junit) console.log(` ${dim(`JUnit report: ${values.junit}`)}`);
1419
- console.log(` ${cyan("➜")} bun run evalkit dev ${dim("to inspect")}`);
1420
- if (project.config.share) console.log(` ${cyan("➜")} bun run evalkit share ${dim("to publish")}`);
1612
+ const manager = detectPackageManager(project.root);
1613
+ const hint = (args, why) => console.log(` ${cyan("➜")} ${evalkitCommand(manager, args).padEnd(24)} ${dim(why)}`);
1614
+ hint("dev", "to inspect");
1615
+ if (project.config.share) hint("share", "to publish");
1421
1616
  console.log("");
1422
1617
  }
1423
1618
  if (controller.signal.aborted) process.exitCode = 130;
1424
- else if (summary.failed || broken.length) process.exitCode = 1;
1619
+ else if (summary.failed || summary.limited || broken.length) process.exitCode = 1;
1620
+ }
1621
+ /** A scenario's checkpoints and final scorers, as `--dry-run` lists them. */
1622
+ function plannedChecks(entry) {
1623
+ return [...entry.transcript.flatMap((step, index) => step.kind === "checkpoint" ? [{
1624
+ scorer: step.scorer,
1625
+ at: `step ${index}`
1626
+ }] : []), ...entry.scorers.map((scorer) => ({
1627
+ scorer,
1628
+ at: "final"
1629
+ }))].map(({ scorer, at }) => ({
1630
+ name: scorer.name,
1631
+ kind: scorer.kind,
1632
+ at,
1633
+ ...scorer.description ? { description: scorer.description } : {},
1634
+ passes: passMark(scorer.passAt, scorer.answer),
1635
+ ...scorer.agent ? { agent: scorer.agent.name ?? scorer.agent.id ?? scorer.agent.kind } : {}
1636
+ }));
1425
1637
  }
1426
1638
  //#endregion
1427
1639
  //#region ../cli/src/tidy.ts
@@ -1473,8 +1685,8 @@ async function tidyReports(options) {
1473
1685
  return result;
1474
1686
  }
1475
1687
  /**
1476
- * Remove the run workspaces `keep` would not have kept: those of passed runs
1477
- * by default. Unfinished runs are never touched, so a run in progress keeps
1688
+ * Remove the run workspaces `keep` would not have kept, and their kept
1689
+ * containers: those of passed runs by default. Unfinished runs are never touched, so a run in progress keeps
1478
1690
  * its workspace; workspaces without a report are left alone.
1479
1691
  */
1480
1692
  async function tidySandboxes(options) {
@@ -1491,6 +1703,7 @@ async function tidySandboxes(options) {
1491
1703
  recursive: true,
1492
1704
  force: true
1493
1705
  });
1706
+ if (run.isolation?.kind === "container") await removeRunContainer(run.id, options.runtime);
1494
1707
  removed += 1;
1495
1708
  }
1496
1709
  return { removed };
@@ -1569,7 +1782,7 @@ async function runBundle(options) {
1569
1782
  files.push({
1570
1783
  path: "run.json",
1571
1784
  data: encode({
1572
- snapshotVersion: 2,
1785
+ snapshotVersion: 3,
1573
1786
  ...options.run
1574
1787
  })
1575
1788
  });
@@ -1764,7 +1977,7 @@ export const greetingAgent = defineAgent({
1764
1977
  identity: { id: 'greeting-agent', kind: 'in-process' },
1765
1978
  async start({ context }) {
1766
1979
  return {
1767
- // Return the reply; EvalKit records it. Call onEvent instead to stream events.
1980
+ // Return the reply; EvalKit records it. Use the transcript writer in start() to record tool calls and usage.
1768
1981
  async send(message: string) {
1769
1982
  const file = join(context.workspace.root, 'greeting', 'greeting.txt');
1770
1983
  const prefix = (await readFile(file, 'utf8')).trim();
@@ -1831,11 +2044,10 @@ scores, evidence, and candidate files (never evaluator workspaces): review it be
1831
2044
  }
1832
2045
  //#endregion
1833
2046
  //#region ../cli/src/new-project.ts
1834
- const npmDependency = "^0.9.0";
2047
+ const npmDependency = "^0.11.0";
1835
2048
  const ignored = [
1836
2049
  "node_modules/",
1837
2050
  "_evalkit-results/",
1838
- "_evalkit-sandbox/",
1839
2051
  "_evalkit-dashboard/",
1840
2052
  "_evalkit-share/"
1841
2053
  ];
@@ -1947,9 +2159,11 @@ async function writeStarterReadme(root, name, inPlace) {
1947
2159
  }
1948
2160
  //#endregion
1949
2161
  //#region ../cli/src/compare.ts
2162
+ /** Runs that ended on their own; a limited run is a finished failure. */
1950
2163
  const FINISHED = /* @__PURE__ */ new Set([
1951
2164
  "passed",
1952
2165
  "failed",
2166
+ "limited",
1953
2167
  "errored"
1954
2168
  ]);
1955
2169
  function unitKey(run) {
@@ -5256,12 +5470,13 @@ function createProgram(actions) {
5256
5470
  showGlobalOptions: false
5257
5471
  }).addHelpText("after", `\nRun from your eval project's directory. Docs: https://evalkit.leostera.dev/docs/\n`);
5258
5472
  program.command("new").description("create an eval project (never overwrites files)").argument("<directory>", "where to create it; use . for the current directory").addHelpText("after", examples(["bunx @leostera/evalkit new ./agent-evals", "evalkit new ."])).action((directory) => actions.create(directory));
5259
- program.command("run").description("run scenarios and save the results in _evalkit-results/").argument("[eval-ids...]", "scenarios to run (default: all)").addOption(configOption()).option("-e, --eval <id>", "run this scenario; repeatable, or comma-separated", collect).option("-s, --suite <id>", "run the scenarios in this suite; with -m, only those in both").option("-m, --matrix <id>", "run this matrix").option("--model <value>", "--select model=<value> for each comma-separated value", collect).option("--mode <value>", "--select mode=<value> for each comma-separated value", collect).option("-a, --select <axis=value>", "keep only these values of a matrix axis (a for axis); repeatable, comma-separated", collect).option("--param <name=json>", "set a parameter for every scenario; repeatable", collect).option("-n, --repeat <n>", "runs of each scenario, as one batch; overrides config and scenario").option("-j, --concurrency <n>", "matrix cells to run at once (default: 4)").option("--max-tokens <n>", "pass maxTokens to the agent in context.parameters").option("--turn-budget <n>", "pass turnBudget to the agent in context.parameters").option("--chat-timeout-ms <n>", "pass chatTimeoutMs to the agent in context.parameters").option("--local", "require the agent's local runtime").option("--all", "allow more matrix cells than execution.maxCells").option("--dry-run", "print the plan as JSON; run nothing").option("--json", "print one JSON result per matrix cell").option("--junit <file>", "write a JUnit XML report").option("--skip-broken", "run healthy scenarios when some files fail to load, then exit 1").option("-v, --verbose", "print every scenario, not only failures").addOption(logLevelOption()).addHelpText("after", examples([
5473
+ program.command("run").description("run scenarios and save the results in _evalkit-results/").argument("[eval-ids...]", "scenarios to run (default: all)").addOption(configOption()).option("-e, --eval <id>", "run this scenario; repeatable, or comma-separated", collect).option("-s, --suite <id>", "run the scenarios in this suite; with -m, only those in both").option("-m, --matrix <id>", "run this matrix").option("--model <value>", "--select model=<value> for each comma-separated value", collect).option("--mode <value>", "--select mode=<value> for each comma-separated value", collect).option("-a, --select <axis=value>", "keep only these values of a matrix axis (a for axis); repeatable, comma-separated; one value per axis runs one cell", collect).option("--param <name=json>", "set a parameter for every scenario; repeatable", collect).option("-n, --repeat <n>", "runs of each scenario, as one batch; overrides config and scenario").option("-j, --concurrency <n>", "matrix cells to run at once (default: 4)").option("--limit <kind=value>", "stop each run past this limit: steps=80, tokens=2000000, time=20m; repeatable; overrides config, scenario and matrix", collect).option("--local", "require the agent's local runtime").option("--all", "allow more matrix cells than execution.maxCells").option("--dry-run", "print the plan as JSON; run nothing").option("--json", "print one JSON result per matrix cell").option("--junit <file>", "write a JUnit XML report").option("--skip-broken", "run healthy scenarios when some files fail to load, then exit 1").option("-v, --verbose", "print every scenario, not only failures").addOption(logLevelOption()).addHelpText("after", examples([
5260
5474
  "evalkit run",
5261
5475
  "evalkit run greeting -n 5",
5262
5476
  "evalkit run -s billing --junit _evalkit-results/junit.xml",
5263
5477
  "evalkit run -m models --model model-a,model-b --dry-run",
5264
- "evalkit run -m models -s testing -a model=luna -a toolchain=riot"
5478
+ "evalkit run -m models -s testing -a model=luna -a toolchain=riot",
5479
+ "evalkit run todo-app -a model=luna -a skills=none -a docs=search"
5265
5480
  ])).action((evalIds, options) => {
5266
5481
  setRunnerLogLevel(options.logLevel);
5267
5482
  return actions.run(runInput(evalIds, options));
@@ -5300,9 +5515,7 @@ function runInput(evalIds, flags) {
5300
5515
  mode: flags.mode,
5301
5516
  select: flags.select,
5302
5517
  param: flags.param,
5303
- "max-tokens": flags.maxTokens,
5304
- "chat-timeout-ms": flags.chatTimeoutMs,
5305
- "turn-budget": flags.turnBudget,
5518
+ limit: flags.limit,
5306
5519
  concurrency: flags.concurrency,
5307
5520
  repeat: flags.repeat,
5308
5521
  json: flags.json,
@@ -5318,7 +5531,7 @@ function runInput(evalIds, flags) {
5318
5531
  //#region ../cli/src/index.ts
5319
5532
  let projectRoot = process.cwd();
5320
5533
  let reportRoot = resolve(projectRoot, "_evalkit-results");
5321
- let sandboxRoot = resolve(projectRoot, "_evalkit-sandbox");
5534
+ let sandboxRoot = defaultSandboxDir(projectRoot);
5322
5535
  let project;
5323
5536
  async function loadRegistry() {
5324
5537
  project ??= await loadProject(projectRoot);
@@ -5336,21 +5549,20 @@ async function runEvals(options) {
5336
5549
  const evaluations = options.evalIds.map((id) => registry.get(id));
5337
5550
  if (evaluations.some((e) => !e)) throw new Error("Unknown eval");
5338
5551
  const provenance = await runProvenance(registry);
5339
- await Effect.runPromise(runnerLogs(Effect.all(evaluations.map((evaluation) => {
5552
+ await withFixtures(projectRoot, project?.config, (fixtures) => Effect.runPromise(runnerLogs(Effect.all(evaluations.map((evaluation) => {
5340
5553
  const name = evaluation.name ?? authoringId(evaluation);
5341
5554
  const started = performance.now();
5342
5555
  return Effect.sync(() => runLog.started(name, {}, options.suiteId)).pipe(Effect.andThen(runEval(evaluation, {
5556
+ ...projectRunOptions(projectRoot, project?.config),
5557
+ ...fixtures ? { fixtures } : {},
5343
5558
  report: localReportStore(reportRoot),
5344
5559
  workspaceRoot: sandboxRoot,
5345
- keepWorkspaces: project?.config?.keepSandboxes,
5346
- artifactExclude: project?.config?.artifacts?.exclude,
5347
- attachmentLimits: project?.config?.artifacts?.attachments,
5348
5560
  suiteId: options.suiteId,
5349
5561
  runtime: runtimeFor(evaluation),
5350
5562
  concurrency: options.concurrency,
5351
5563
  ...provenance
5352
5564
  })), Effect.tap((result) => runLog.finished(name, {}, result, performance.now() - started)), Effect.tapError((error) => Effect.sync(() => runLog.failed(name, error))));
5353
- }), { concurrency: options.concurrency })));
5565
+ }), { concurrency: options.concurrency }))));
5354
5566
  }
5355
5567
  /** Detected per launch: `evalkit dev` keeps running while commits change. */
5356
5568
  async function runProvenance(registry) {
@@ -5408,19 +5620,18 @@ function registerMatrixRoutes(app, registry, dashboardMatrix, repeat) {
5408
5620
  const name = evaluation.name ?? authoringId(evaluation);
5409
5621
  const started = performance.now();
5410
5622
  runLog.started(name, body.parameters ?? {}, suiteId);
5411
- runProvenance(registry).then((provenance) => Effect.runPromise(runnerLogs(runMatrix(dashboardMatrix, {
5623
+ runProvenance(registry).then((provenance) => withFixtures(projectRoot, project?.config, (fixtures) => Effect.runPromise(runnerLogs(runMatrix(dashboardMatrix, {
5624
+ ...projectRunOptions(projectRoot, project?.config),
5625
+ ...fixtures ? { fixtures } : {},
5412
5626
  selection,
5413
5627
  concurrency: 1,
5414
5628
  ...repeat ? { repeat } : {},
5415
5629
  ...suiteId ? { suiteId } : {},
5416
5630
  report: localReportStore(reportRoot),
5417
5631
  workspaceRoot: sandboxRoot,
5418
- keepWorkspaces: project?.config?.keepSandboxes,
5419
- artifactExclude: project?.config?.artifacts?.exclude,
5420
- attachmentLimits: project?.config?.artifacts?.attachments,
5421
5632
  ...provenance,
5422
5633
  onResult: (cell, result) => runLog.cell(cell, result, performance.now() - started)
5423
- })))).catch((error) => runLog.failed(name, error)).finally(() => {
5634
+ }))))).catch((error) => runLog.failed(name, error)).finally(() => {
5424
5635
  matrixRunning = false;
5425
5636
  });
5426
5637
  } else {
@@ -5515,9 +5726,9 @@ function registerReportRoutes(app, registry, reports) {
5515
5726
  return context.text(error instanceof Error ? error.message : "Not found", 404);
5516
5727
  }
5517
5728
  });
5518
- app.get("/v1/runs/:runId/events", async (context) => {
5729
+ app.get("/v1/runs/:runId/transcript", async (context) => {
5519
5730
  try {
5520
- return context.json({ events: await reports.trajectory(context.req.param("runId")) });
5731
+ return context.json(await reports.transcript(context.req.param("runId")));
5521
5732
  } catch (error) {
5522
5733
  return context.text(error instanceof Error ? error.message : "Not found", 404);
5523
5734
  }
@@ -5550,7 +5761,7 @@ async function useProject(configPath) {
5550
5761
  project = await loadProject(projectRoot, configPath);
5551
5762
  projectRoot = project.root;
5552
5763
  reportRoot = resolve(projectRoot, project.config.reportDir ?? "_evalkit-results");
5553
- sandboxRoot = resolve(projectRoot, project.config.sandboxDir ?? "_evalkit-sandbox");
5764
+ sandboxRoot = sandboxLocation(project).path;
5554
5765
  }
5555
5766
  /**
5556
5767
  * `--port` is honoured exactly; without it the server takes the first free
@@ -5671,7 +5882,8 @@ async function tidyAction(options) {
5671
5882
  registry: project.registry,
5672
5883
  reportRoot,
5673
5884
  sandboxRoot,
5674
- keep: project.config.keepSandboxes
5885
+ keep: project.config.keepSandboxes,
5886
+ ...project.config.isolation?.kind === "container" ? { runtime: project.config.isolation.runtime } : {}
5675
5887
  }), (value) => {
5676
5888
  const keep = project.config.keepSandboxes ?? "failed";
5677
5889
  if (keep === "all") return "all kept (keepSandboxes: 'all')";
@@ -5690,14 +5902,21 @@ async function upgradeAction(options) {
5690
5902
  banner("upgrade", VERSION);
5691
5903
  const { root, config } = await loadConfig(projectRoot, options.config);
5692
5904
  reportRoot = resolve(root, config.reportDir ?? "_evalkit-results");
5693
- const report = await step(options.dryRun ? "Checking results" : "Upgrading results", () => upgradeReports(reportRoot, { dryRun: options.dryRun }), (value) => `${plural(value.upgraded, "run")} from an older EvalKit ${options.dryRun ? "found" : "upgraded"}`);
5905
+ const report = await step(options.dryRun ? "Checking results" : "Upgrading results", () => upgradeReports(reportRoot, { dryRun: options.dryRun }), (value) => `${plural(value.transcripts, "run")} ${options.dryRun ? "to upgrade" : "upgraded"}`);
5694
5906
  console.log("");
5695
- if (!report.upgraded) console.log(` ${cyan("✓")} Every run is already in the current format.`);
5696
- else {
5697
- const into = `${plural(report.upgraded, "run")} from older EvalKit versions into ${plural(report.runs, "run")}`;
5698
- console.log(options.dryRun ? ` ${cyan("◆")} Would upgrade ${into}. Nothing was changed.` : ` ${cyan("✓")} Upgraded ${into}.`);
5907
+ if (!report.upgraded && !report.transcripts && !report.failed.length) console.log(` ${cyan("✓")} Every run is already in the current format.`);
5908
+ if (report.upgraded) {
5909
+ const into = `${plural(report.upgraded, "run")} with trials into ${plural(report.runs, "run")}`;
5910
+ console.log(options.dryRun ? ` ${cyan("◆")} Would upgrade ${into}.` : ` ${cyan("✓")} Upgraded ${into}.`);
5699
5911
  if (report.batches) console.log(` ${dim(`${plural(report.batches, "run")} had more than one trial; each trial is now a run in a batch.`)}`);
5700
5912
  }
5913
+ if (report.transcripts) console.log(options.dryRun ? ` ${cyan("◆")} Would convert ${plural(report.transcripts, "trajectory", "trajectories")} to ATXF transcripts.` : ` ${cyan("✓")} Converted ${plural(report.transcripts, "trajectory", "trajectories")} to ATXF transcripts.`);
5914
+ if (options.dryRun && (report.upgraded || report.transcripts)) console.log(` ${dim("Nothing was changed.")}`);
5915
+ if (report.failed.length) {
5916
+ console.log(` ${red("✗")} Left ${plural(report.failed.length, "run")} as ${report.failed.length === 1 ? "it was" : "they were"}; ${report.failed.length === 1 ? "it" : "they"} could not be converted:`);
5917
+ for (const { runId, reason } of report.failed) console.log(` ${runId} ${dim(reason)}`);
5918
+ process.exitCode = 1;
5919
+ }
5701
5920
  if (report.running.length) console.log(` ${yellow("◆")} Left ${plural(report.running.length, "run")} still running alone; upgrade again when they finish.`);
5702
5921
  console.log("");
5703
5922
  }