@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.
@@ -0,0 +1,3637 @@
1
+ import { $ as DEFAULT_ARTIFACT_EXCLUDE, B as RunMetadataSchema, Ct as limitsFromParameters, Et as resolveLimits, G as ScoreResultSchema, It as resourceUri, L as RunEventSchema, O as CheckpointResultSchema, Q as canonicalParameters, St as formatLimit, Tt as parseLimits, V as RunScoringSchema, W as RunSummarySchema, Y as none, _ as recordError, _t as contentText, a as assertJudgesIndependent, bt as parseModel, ct as judgePrompt, dt as weightedMean, et as DEFAULT_ATTACHMENT_LIMITS, gt as callsIn, ht as activePath, i as agentRuntimes, it as answerInstruction, kt as relativeDatasetPath, mt as toJsonObject, o as authoringId, pt as toJson, rt as UnreadableAnswer, st as judgeInputNames, ut as readJudgeAnswer, vt as isTreeRecord, xt as toolResultValue, yt as modelName, z as RunManifestSchema } from "./src-D6Ax2t9b.mjs";
2
+ import { a as upgradeManifest, c as normalizeUsage, d as uncachedTokens, f as usageMetrics, l as reportedUsages, n as assertNoRemovedOptions, o as upgradeStatus, r as upgradeCatalog, s as upgradeTrajectory, t as UPGRADABLE_SCHEMA_VERSIONS, u as transcriptUsage } from "./upgrade-rMsGzUEJ.mjs";
3
+ import { n as upgradeScoring, t as convertTrajectory } from "./upgrade-transcript-C8DUykLd.mjs";
4
+ import * as Schema from "effect/Schema";
5
+ import { TranscriptHasher, addIntegrity, parseTranscript, serializeTranscript, validateTranscript } from "@leostera/atxf";
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { Data, Effect, Either, Exit, Scope } from "effect";
8
+ import os, { constants, homedir, tmpdir } from "node:os";
9
+ import { appendFile, copyFile, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
10
+ import path, { join } from "node:path";
11
+ import { spawn } from "node:child_process";
12
+ import { realpathSync } from "node:fs";
13
+ import { createInterface } from "node:readline";
14
+ import * as ParseResult from "effect/ParseResult";
15
+ //#region ../runner/src/errors.ts
16
+ var AutExecutionError = class extends Data.TaggedError("AutExecutionError") {};
17
+ var DatasetError = class extends Data.TaggedError("DatasetError") {};
18
+ var ReportError = class extends Data.TaggedError("ReportError") {};
19
+ var ScoringError = class extends Data.TaggedError("ScoringError") {};
20
+ var RuleExecutionError = class extends Data.TaggedError("RuleExecutionError") {};
21
+ var CheckpointExecutionError = class extends Data.TaggedError("CheckpointExecutionError") {};
22
+ /** The run was cancelled (Ctrl+C); the run stops and is finalized as cancelled. */
23
+ var CancelledError = class extends Data.TaggedError("CancelledError") {};
24
+ function cancelled() {
25
+ return new CancelledError({ message: "Cancelled" });
26
+ }
27
+ /** The run hit one of its limits; EvalKit stopped it. */
28
+ var LimitReachedError = class extends Data.TaggedError("LimitReachedError") {};
29
+ /** Why `signal` aborted: the limit it hit, or a cancellation. */
30
+ function abortError(signal) {
31
+ return signal.reason instanceof LimitReachedError ? signal.reason : cancelled();
32
+ }
33
+ /** How long a turn may take to settle after its run hits a limit (RFD0009). */
34
+ const LIMIT_GRACE_MS = 1e4;
35
+ /**
36
+ * Settles like `work`, or rejects once `signal` aborts: at once when
37
+ * cancelled, and after up to `graceMs` when a limit was hit, so an agent
38
+ * that sees `context.signal` can finish what it was writing.
39
+ */
40
+ async function unlessAborted(work, signal, graceMs = 0) {
41
+ if (signal.aborted) throw abortError(signal);
42
+ let onAbort;
43
+ let timer;
44
+ const running = work();
45
+ const aborted = new Promise((_, reject) => {
46
+ onAbort = () => {
47
+ const error = abortError(signal);
48
+ if (!(error instanceof LimitReachedError) || graceMs <= 0) return reject(error);
49
+ timer = setTimeout(() => reject(error), graceMs);
50
+ };
51
+ signal.addEventListener("abort", onAbort, { once: true });
52
+ });
53
+ try {
54
+ return await Promise.race([running, aborted]);
55
+ } finally {
56
+ if (timer) clearTimeout(timer);
57
+ if (onAbort) signal.removeEventListener("abort", onAbort);
58
+ }
59
+ }
60
+ //#endregion
61
+ //#region ../runner/src/attachments.ts
62
+ const SEGMENT = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,99}$/;
63
+ const MAX_NAME = 200;
64
+ /**
65
+ * Checks an attachment name: a relative path whose segments are letters,
66
+ * digits, `.`, `_` and `-`, not starting with a dot. That rules out
67
+ * absolute paths, `..`, hidden files, separators other than `/`, and
68
+ * anything a filesystem or URL would read differently.
69
+ */
70
+ function attachmentName(name) {
71
+ if (typeof name !== "string" || !name || name.length > MAX_NAME || !name.split("/").every((segment) => SEGMENT.test(segment))) throw new Error(`Attachment name ${JSON.stringify(name)} is not allowed: use a relative path of letters, digits, ".", "_" and "-", like "logs/agent.log"`);
72
+ return name;
73
+ }
74
+ /** The last `max` bytes, starting on a whole UTF-8 character when the content is text. */
75
+ function lastBytes(bytes, max, text) {
76
+ let start = bytes.byteLength - max;
77
+ if (text) while (start < bytes.byteLength && (bytes[start] & 192) === 128) start++;
78
+ return bytes.subarray(start);
79
+ }
80
+ /**
81
+ * Saves a run's attachments as `attachments/<name>` below its artifacts.
82
+ * Content is stored as given and never interpreted. A file over
83
+ * `maxFileBytes` keeps its end (where a log's errors are); one that would
84
+ * take the run past `maxRunBytes` is rejected.
85
+ */
86
+ function attachmentStore(writer, limits = {}) {
87
+ const { maxFileBytes, maxRunBytes } = {
88
+ ...DEFAULT_ATTACHMENT_LIMITS,
89
+ ...limits
90
+ };
91
+ const entries = [];
92
+ const names = /* @__PURE__ */ new Set();
93
+ const pending = /* @__PURE__ */ new Set();
94
+ let used = 0;
95
+ let closed = false;
96
+ async function attach(name, content) {
97
+ if (closed) throw new Error(`Cannot attach ${JSON.stringify(name)}: the run has finished`);
98
+ attachmentName(name);
99
+ if (typeof content !== "string" && !(content instanceof Uint8Array)) throw new Error(`Attachment ${name} must be a string or a Uint8Array, not ${content === null ? "null" : typeof content}`);
100
+ if (names.has(name)) throw new Error(`Attachment ${name} was already attached`);
101
+ const text = typeof content === "string";
102
+ const original = text ? new TextEncoder().encode(content) : content;
103
+ const bytes = original.byteLength > maxFileBytes ? lastBytes(original, maxFileBytes, text) : original;
104
+ if (used + bytes.byteLength > maxRunBytes) throw new Error(`Attachment ${name} (${bytes.byteLength} bytes) would take this run past its ${maxRunBytes}-byte attachment limit (${used} used)`);
105
+ names.add(name);
106
+ used += bytes.byteLength;
107
+ const write = writer.writeArtifact(`attachments/${name}`, bytes);
108
+ pending.add(write);
109
+ try {
110
+ await write;
111
+ entries.push({
112
+ name,
113
+ size: bytes.byteLength,
114
+ ...bytes.byteLength < original.byteLength ? { truncatedFrom: original.byteLength } : {}
115
+ });
116
+ } catch (error) {
117
+ names.delete(name);
118
+ used -= bytes.byteLength;
119
+ throw error;
120
+ } finally {
121
+ pending.delete(write);
122
+ }
123
+ }
124
+ return {
125
+ attach,
126
+ async close() {
127
+ closed = true;
128
+ await Promise.allSettled(pending);
129
+ return [...entries].sort((a, b) => a.name.localeCompare(b.name));
130
+ }
131
+ };
132
+ }
133
+ /**
134
+ * An `attach` that remembers what it saved, so a score or checkpoint can
135
+ * link the files its scorer attached, even when the scorer then failed.
136
+ */
137
+ function trackAttachments(attach) {
138
+ const attached = [];
139
+ return {
140
+ attached,
141
+ async attach(name, content) {
142
+ await attach(name, content);
143
+ attached.push(name);
144
+ }
145
+ };
146
+ }
147
+ /** `{ attachments }` when a scorer attached any. */
148
+ const attachedFields = (attached) => attached.length ? { attachments: [...attached] } : {};
149
+ //#endregion
150
+ //#region ../runner/src/run-transcript.ts
151
+ /**
152
+ * One session's ATXF transcript as it is written: every record hashed as
153
+ * it is appended (ATXF §17), saved in order, and sealed at the end. It is
154
+ * the adapter's `TranscriptWriter` and EvalKit's own.
155
+ */
156
+ var RunTranscript = class {
157
+ source;
158
+ header;
159
+ hasher;
160
+ entries = [];
161
+ ids = /* @__PURE__ */ new Set();
162
+ imported = /* @__PURE__ */ new Set();
163
+ importBase;
164
+ leaf = null;
165
+ next = 0;
166
+ pendingUser;
167
+ turns = 0;
168
+ subagentTranscripts = /* @__PURE__ */ new Map();
169
+ subagentSeen = /* @__PURE__ */ new Map();
170
+ sealed = false;
171
+ writes = Promise.resolve();
172
+ writeError;
173
+ constructor(source) {
174
+ this.source = source;
175
+ this.hasher = new TranscriptHasher({
176
+ type: "transcript",
177
+ atxf: "0.1",
178
+ id: source.id,
179
+ time: source.now().toISOString(),
180
+ source: {
181
+ agent: source.agent.id,
182
+ ...source.agent.version ? { version: source.agent.version } : {}
183
+ },
184
+ fidelity: {
185
+ topology: "tree",
186
+ reasoning: "mixed",
187
+ usage: "per-response",
188
+ native: "partial",
189
+ ids: "stable"
190
+ },
191
+ ext: { evalkit: {
192
+ runId: source.runId,
193
+ scenarioId: source.scenarioId
194
+ } }
195
+ });
196
+ this.header = this.hasher.header;
197
+ this.save(this.header);
198
+ }
199
+ /** The transcript so far. */
200
+ get transcript() {
201
+ return {
202
+ header: this.header,
203
+ records: [...this.entries]
204
+ };
205
+ }
206
+ /** The ID of the active leaf: where run events are placed. */
207
+ get leafId() {
208
+ return this.leaf;
209
+ }
210
+ /** Records appended since `from` (a `records.length` taken earlier). */
211
+ since(from) {
212
+ return this.entries.slice(from);
213
+ }
214
+ get length() {
215
+ return this.entries.length;
216
+ }
217
+ /** Resolves once every line is saved; rejects with the first write error. */
218
+ async settled() {
219
+ await this.writes;
220
+ if (this.writeError) throw this.writeError;
221
+ }
222
+ /**
223
+ * The message EvalKit is about to send. It is written before the adapter's
224
+ * first record of the turn, unless the adapter writes a user message (or
225
+ * imports one with the same text) first, which then stands for it.
226
+ */
227
+ scriptUser(message) {
228
+ this.flushUser();
229
+ this.pendingUser = message;
230
+ }
231
+ /** Writes the scripted message if nothing in the turn stood for it. */
232
+ endTurn() {
233
+ this.flushUser();
234
+ }
235
+ /** What `send()` returned, as records: tool calls with results, then the reply. */
236
+ writeTurnResult(result, turn) {
237
+ if (typeof result === "string") result = { reply: result };
238
+ if (typeof result !== "object" || result === null) throw new Error("send() must return nothing, a string, or a turn result");
239
+ const calls = (result.toolCalls ?? []).map((call, index) => ({
240
+ ...call,
241
+ id: call.id ?? `turn-${turn}-call-${index + 1}`
242
+ }));
243
+ const extras = {
244
+ ...result.model === void 0 ? {} : { model: result.model },
245
+ ...result.usage === void 0 ? {} : { usage: result.usage }
246
+ };
247
+ const hasReply = result.reply !== void 0;
248
+ if (calls.length) {
249
+ this.assistant({
250
+ content: calls.map((call) => ({
251
+ type: "tool_call",
252
+ id: call.id,
253
+ name: call.name,
254
+ kind: "function",
255
+ arguments: toolArguments(call.arguments)
256
+ })),
257
+ ...hasReply ? {} : extras
258
+ });
259
+ for (const call of calls) if (call.result !== void 0) this.toolResult(call.id, call.result, { name: call.name });
260
+ }
261
+ if (hasReply) this.assistant({
262
+ content: typeof result.reply === "string" ? result.reply : JSON.stringify(toJson(result.reply, "reply")),
263
+ ...extras
264
+ });
265
+ else if (!calls.length && result.usage) {
266
+ const usage = normalizeUsage(result.usage);
267
+ if (usage) this.append({
268
+ type: "usage",
269
+ kind: "turn",
270
+ usage,
271
+ ...result.model === void 0 ? {} : { model: parseModel(result.model) }
272
+ });
273
+ }
274
+ }
275
+ /** The adapter's writer. */
276
+ writer() {
277
+ return {
278
+ user: (content, options) => {
279
+ this.pendingUser = void 0;
280
+ return this.append({
281
+ type: "message",
282
+ role: "user",
283
+ content,
284
+ ...options?.origin ? { origin: options.origin } : {}
285
+ });
286
+ },
287
+ assistant: (message) => this.assistant(message),
288
+ toolResult: (callId, result, options) => this.toolResult(callId, result, options),
289
+ append: (record) => this.append(record),
290
+ import: (transcript) => this.importTranscript(transcript),
291
+ turn: (phase, options = {}) => {
292
+ const turnId = options.turnId ?? `turn-${++this.turns}`;
293
+ this.append({
294
+ type: "turn",
295
+ phase,
296
+ turnId,
297
+ ...options.outcome ? { outcome: options.outcome } : {},
298
+ ...options.error ? { error: options.error } : {}
299
+ });
300
+ return turnId;
301
+ },
302
+ subagent: (transcript) => this.keepSubagent(transcript)
303
+ };
304
+ }
305
+ /** Sub-agent transcripts kept so far, by transcript ID. */
306
+ get subagents() {
307
+ return Object.fromEntries(this.subagentTranscripts);
308
+ }
309
+ keepSubagent(transcript) {
310
+ if (this.sealed) throw new Error("The transcript is sealed: the run has ended");
311
+ const id = transcript?.header?.id;
312
+ if (typeof id !== "string" || !id || !Array.isArray(transcript.records)) throw new Error("subagent() takes an ATXF transcript: { header: { id, … }, records: [...] }");
313
+ if (id === this.header.id) throw new Error(`A sub-agent cannot have the run's transcript ID ${id}`);
314
+ const seen = this.subagentSeen.get(id) ?? /* @__PURE__ */ new Set();
315
+ this.subagentSeen.set(id, seen);
316
+ for (const record of transcript.records) {
317
+ if (seen.has(record.id)) continue;
318
+ seen.add(record.id);
319
+ try {
320
+ this.source.onSubagentRecord?.(record);
321
+ } catch {}
322
+ }
323
+ this.subagentTranscripts.set(id, transcript);
324
+ this.saveSubagent(id, transcript);
325
+ }
326
+ saveSubagent(id, transcript) {
327
+ const persist = this.source.persistSubagent;
328
+ if (!persist) return;
329
+ const text = serializeTranscript(transcript);
330
+ this.writes = this.writes.then(() => persist(id, text).catch((error) => {
331
+ this.writeError ??= error;
332
+ }));
333
+ }
334
+ assistant(message) {
335
+ const usage = normalizeUsage(message.usage);
336
+ return this.append({
337
+ type: "message",
338
+ role: "assistant",
339
+ content: typeof message.content === "string" ? [{
340
+ type: "text",
341
+ text: message.content
342
+ }] : message.content,
343
+ ...message.model === void 0 ? {} : { model: parseModel(message.model) },
344
+ ...usage ? { usage } : {},
345
+ ...message.stop ? { stop: message.stop } : {},
346
+ ...message.error ? { error: message.error } : {}
347
+ });
348
+ }
349
+ toolResult(callId, result, options = {}) {
350
+ const value = toJson(result === void 0 ? null : result, "tool result");
351
+ return this.append({
352
+ type: "message",
353
+ role: "tool",
354
+ toolCallId: callId,
355
+ ...options.name ? { toolName: options.name } : {},
356
+ content: typeof value === "string" ? value : JSON.stringify(value),
357
+ ...typeof value === "string" ? {} : { details: { result: value } },
358
+ ...options.isError ? { isError: true } : {}
359
+ });
360
+ }
361
+ /** Appends one tree record after the active leaf (or below `parentId`). */
362
+ append(input) {
363
+ if (this.sealed) throw new Error("The transcript is sealed: the run has ended");
364
+ if (!isTreeRecord(input)) throw new Error(`Transcript records must be ATXF tree records; received type ${JSON.stringify(input.type)}`);
365
+ this.flushUser();
366
+ return this.add(input);
367
+ }
368
+ add(input) {
369
+ const id = input.id ?? `ek-${++this.next}`;
370
+ if (this.ids.has(id)) throw new Error(`Transcript record ID ${id} is already used`);
371
+ const parentId = input.parentId === void 0 ? this.leaf : input.parentId;
372
+ if (parentId !== null && !this.ids.has(parentId)) throw new Error(`Transcript record ${id} names parent ${parentId}, which is not earlier in the transcript`);
373
+ const { hash: _, ...record } = {
374
+ ...input,
375
+ id,
376
+ time: input.time ?? this.source.now().toISOString(),
377
+ parentId
378
+ };
379
+ this.push(record);
380
+ return id;
381
+ }
382
+ push(record) {
383
+ const hashed = this.hasher.push(record);
384
+ this.entries.push(hashed);
385
+ this.ids.add(hashed.id);
386
+ if (isTreeRecord(hashed)) this.leaf = hashed.id;
387
+ else if (hashed.type === "leaf") this.leaf = hashed.targetId;
388
+ this.save(hashed);
389
+ try {
390
+ this.source.onRecord?.(hashed);
391
+ } catch {}
392
+ }
393
+ flushUser() {
394
+ if (this.pendingUser === void 0) return;
395
+ const text = this.pendingUser;
396
+ this.pendingUser = void 0;
397
+ this.add({
398
+ type: "message",
399
+ role: "user",
400
+ content: text,
401
+ origin: {
402
+ kind: "synthetic",
403
+ name: "evalkit"
404
+ }
405
+ });
406
+ }
407
+ importTranscript(transcript) {
408
+ if (this.sealed) throw new Error("The transcript is sealed: the run has ended");
409
+ const fresh = transcript.records.filter((record) => record.type !== "seal" && !this.imported.has(record.id));
410
+ if (!fresh.length) return;
411
+ if (fresh.findIndex((record) => record.type === "message" && record.role === "user" && contentText(record.content) === this.pendingUser) === -1) this.flushUser();
412
+ else this.pendingUser = void 0;
413
+ if (this.importBase === void 0) this.importBase = this.leaf;
414
+ for (const record of fresh) {
415
+ if (this.ids.has(record.id)) throw new Error(`Imported record ID ${record.id} is already used in this transcript`);
416
+ const { hash: _, ...unhashed } = record;
417
+ const placed = isTreeRecord(record) && record.parentId === null ? {
418
+ ...unhashed,
419
+ parentId: this.importBase
420
+ } : unhashed;
421
+ this.imported.add(record.id);
422
+ this.push(placed);
423
+ }
424
+ }
425
+ /** Every usage reported so far that ATXF counts toward the total. */
426
+ *usages() {
427
+ for (const record of this.entries) if (record.type === "message" && record.role === "assistant") {
428
+ if (record.usage) yield record.usage;
429
+ } else if ((record.type === "compaction" || record.type === "branch_summary") && record.usage) yield record.usage;
430
+ else if (record.type === "usage" && !record.cumulative) yield record.usage;
431
+ }
432
+ /** Appends the seal (ATXF §17.2); nothing can be appended after it. */
433
+ seal() {
434
+ if (this.sealed) return;
435
+ this.flushUser();
436
+ const seal = this.hasher.seal(`ek-seal-${++this.next}`, this.source.now().toISOString());
437
+ this.entries.push(seal);
438
+ this.ids.add(seal.id);
439
+ this.save(seal);
440
+ this.sealed = true;
441
+ for (const [id, transcript] of this.subagentTranscripts) {
442
+ const records = transcript.records.filter((record) => record.type !== "seal");
443
+ const last = records.at(-1)?.time ?? transcript.header.time;
444
+ const sealed = addIntegrity({
445
+ header: transcript.header,
446
+ records
447
+ }, { seal: {
448
+ id: `ek-seal-${id}`,
449
+ time: last
450
+ } });
451
+ this.subagentTranscripts.set(id, sealed);
452
+ this.saveSubagent(id, sealed);
453
+ }
454
+ }
455
+ /** What the ATXF validator finds wrong with the transcript and its sub-agents'; empty when valid. */
456
+ problems() {
457
+ const check = (transcript, prefix) => validateTranscript(serializeTranscript(transcript)).diagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => `${prefix}${diagnostic.line ? `line ${diagnostic.line}: ` : ""}${diagnostic.message}`);
458
+ return [...check(this.transcript, ""), ...[...this.subagentTranscripts].flatMap(([id, transcript]) => check(transcript, `sub-agent ${id}: `))];
459
+ }
460
+ save(line) {
461
+ const persist = this.source.persist;
462
+ if (!persist) return;
463
+ this.writes = this.writes.then(() => persist(line).catch((error) => {
464
+ this.writeError ??= error;
465
+ }));
466
+ }
467
+ };
468
+ /** ATXF tool arguments are an object; anything else is kept under `value`. */
469
+ function toolArguments(value) {
470
+ if (value === void 0) return {};
471
+ const json = toJson(value, "tool arguments");
472
+ return typeof json === "object" && json !== null && !Array.isArray(json) ? json : { value: json };
473
+ }
474
+ //#endregion
475
+ //#region ../runner/src/shell/local-process.ts
476
+ /**
477
+ * Process groups started here and not yet stopped. Each command leads its
478
+ * own group, so the terminal's Ctrl+C no longer reaches it: when this
479
+ * process exits, even at once on a second Ctrl+C, they are killed.
480
+ */
481
+ const liveGroups = /* @__PURE__ */ new Set();
482
+ let exitHook = false;
483
+ /** Kills `group` when this process exits, until `forgetGroup`. */
484
+ function rememberGroup(group) {
485
+ liveGroups.add(group);
486
+ if (exitHook) return;
487
+ exitHook = true;
488
+ process.once("exit", () => {
489
+ for (const pid of liveGroups) try {
490
+ process.kill(-pid, "SIGKILL");
491
+ } catch {}
492
+ });
493
+ }
494
+ function forgetGroup(group) {
495
+ liveGroups.delete(group);
496
+ }
497
+ /** Output kept per stream: the last megabyte, where errors are. */
498
+ const OUTPUT_LIMIT = 1e6;
499
+ /** How long a process gets to stop after SIGTERM before SIGKILL. */
500
+ const STOP_GRACE_MS = 2e3;
501
+ /** A process's text output, keeping the last `OUTPUT_LIMIT` characters. */
502
+ var OutputBuffer = class {
503
+ #text = "";
504
+ append(chunk) {
505
+ this.#text += chunk;
506
+ if (this.#text.length > OUTPUT_LIMIT * 2) this.#text = this.#text.slice(-1e6);
507
+ }
508
+ toString() {
509
+ return this.#text.length > OUTPUT_LIMIT ? this.#text.slice(-1e6) : this.#text;
510
+ }
511
+ };
512
+ /** The exit code a shell reports for a signal: 128 plus its number. */
513
+ function signalExitCode(signal) {
514
+ return 128 + (constants.signals[signal] ?? 15);
515
+ }
516
+ /**
517
+ * Starts a process on this machine, in its own process group, and reads its
518
+ * output as it comes. A command that cannot start exits 127 with the reason
519
+ * on stderr, as a shell would.
520
+ */
521
+ function startLocalProcess(argv, options) {
522
+ const [program, ...args] = argv;
523
+ if (!program) throw new Error("A command needs at least a program to run");
524
+ const stdout = new OutputBuffer();
525
+ const stderr = new OutputBuffer();
526
+ const listeners = /* @__PURE__ */ new Set();
527
+ const emit = (chunk, stream) => {
528
+ (stream === "stdout" ? stdout : stderr).append(chunk);
529
+ for (const listener of listeners) listener(chunk, stream);
530
+ };
531
+ let child;
532
+ let timedOut = false;
533
+ let exited = false;
534
+ let resolveExit;
535
+ const exit = new Promise((resolve) => {
536
+ resolveExit = resolve;
537
+ });
538
+ const finish = (result) => {
539
+ if (exited) return;
540
+ exited = true;
541
+ cleanup();
542
+ resolveExit(timedOut ? {
543
+ ...result,
544
+ exitCode: 124,
545
+ timedOut: true
546
+ } : result);
547
+ };
548
+ const signalGroup = async (signal) => {
549
+ if (exited || child?.pid === void 0) return;
550
+ try {
551
+ process.kill(-child.pid, signal);
552
+ } catch {
553
+ try {
554
+ child.kill(signal);
555
+ } catch {}
556
+ }
557
+ };
558
+ const kill = options.kill ?? signalGroup;
559
+ /** SIGTERM, then SIGKILL if it is still running after the grace. */
560
+ const stop = async () => {
561
+ await kill("SIGTERM");
562
+ const timer = setTimeout(() => void kill("SIGKILL"), STOP_GRACE_MS);
563
+ timer.unref?.();
564
+ await exit;
565
+ clearTimeout(timer);
566
+ };
567
+ const onAbort = () => void stop();
568
+ let timer;
569
+ const cleanup = () => {
570
+ options.signal?.removeEventListener("abort", onAbort);
571
+ if (timer) clearTimeout(timer);
572
+ };
573
+ try {
574
+ child = spawn(program, args, {
575
+ cwd: options.cwd,
576
+ env: {
577
+ ...options.env,
578
+ PWD: options.cwd
579
+ },
580
+ detached: true,
581
+ stdio: [
582
+ "pipe",
583
+ "pipe",
584
+ "pipe"
585
+ ]
586
+ });
587
+ } catch (error) {
588
+ emit(`${errorMessage(error)}\n`, "stderr");
589
+ finish({ exitCode: 127 });
590
+ }
591
+ if (child) {
592
+ const group = child.pid;
593
+ if (group !== void 0) {
594
+ rememberGroup(group);
595
+ if (options.forgetOnExit !== false) exit.then(() => forgetGroup(group));
596
+ }
597
+ child.stdout?.setEncoding("utf8").on("data", (chunk) => emit(chunk, "stdout"));
598
+ child.stderr?.setEncoding("utf8").on("data", (chunk) => emit(chunk, "stderr"));
599
+ child.on("error", (error) => {
600
+ emit(`${program}: ${errorMessage(error)}\n`, "stderr");
601
+ finish({ exitCode: 127 });
602
+ });
603
+ child.on("close", (code, signal) => finish(signal ? {
604
+ exitCode: signalExitCode(signal),
605
+ signal
606
+ } : { exitCode: code ?? 0 }));
607
+ child.stdin?.on("error", () => {});
608
+ child.stdin?.end(options.input ?? "");
609
+ if (options.signal?.aborted) onAbort();
610
+ else options.signal?.addEventListener("abort", onAbort, { once: true });
611
+ if (options.timeoutMs !== void 0) {
612
+ timer = setTimeout(() => {
613
+ timedOut = true;
614
+ stop();
615
+ }, options.timeoutMs);
616
+ timer.unref?.();
617
+ }
618
+ }
619
+ return {
620
+ get pid() {
621
+ return child?.pid;
622
+ },
623
+ stdout: () => stdout.toString(),
624
+ stderr: () => stderr.toString(),
625
+ onOutput(listener) {
626
+ listeners.add(listener);
627
+ return () => listeners.delete(listener);
628
+ },
629
+ wait: () => exit,
630
+ async kill(signal = "SIGTERM") {
631
+ await kill(signal);
632
+ },
633
+ killLocal: signalGroup
634
+ };
635
+ }
636
+ function errorMessage(error) {
637
+ return error instanceof Error ? error.message : String(error);
638
+ }
639
+ /** Runs a process to its end and returns its output. */
640
+ async function collect(process) {
641
+ return {
642
+ ...await process.wait(),
643
+ stdout: process.stdout(),
644
+ stderr: process.stderr()
645
+ };
646
+ }
647
+ /**
648
+ * The processes a run's shell started, so they stop when the run ends:
649
+ * SIGTERM, then SIGKILL after a grace.
650
+ */
651
+ var ProcessTracker = class {
652
+ #live = /* @__PURE__ */ new Set();
653
+ #closed = false;
654
+ track(process) {
655
+ if (this.#closed) {
656
+ process.kill("SIGKILL");
657
+ throw new Error("The run is over: its shell takes no more commands");
658
+ }
659
+ this.#live.add(process);
660
+ process.wait().then(() => this.#live.delete(process));
661
+ return process;
662
+ }
663
+ get closed() {
664
+ return this.#closed;
665
+ }
666
+ async close() {
667
+ this.#closed = true;
668
+ await Promise.all([...this.#live].map(async (process) => {
669
+ await process.kill("SIGTERM");
670
+ const timer = setTimeout(() => void process.kill("SIGKILL"), STOP_GRACE_MS);
671
+ timer.unref?.();
672
+ await process.wait();
673
+ clearTimeout(timer);
674
+ }));
675
+ }
676
+ };
677
+ //#endregion
678
+ //#region ../runner/src/shell/docker.ts
679
+ /** Runs the container runtime's CLI and returns its trimmed stdout; throws with its stderr. */
680
+ async function docker(runtime, args, options = {}) {
681
+ const result = await collect(startLocalProcess([runtime, ...args], {
682
+ cwd: process.cwd(),
683
+ env: process.env,
684
+ ...options.input !== void 0 ? { input: options.input } : {},
685
+ ...options.signal ? { signal: options.signal } : {}
686
+ }));
687
+ if (result.exitCode !== 0) throw new Error(`${runtime} ${args[0]} failed (exit ${result.exitCode}): ${result.stderr.trim() || result.stdout.trim()}`);
688
+ return result.stdout.trim();
689
+ }
690
+ const runtimes = /* @__PURE__ */ new Map();
691
+ /** Checks once per process that the runtime's CLI and its daemon answer. */
692
+ function ensureRuntime(runtime) {
693
+ let checked = runtimes.get(runtime);
694
+ if (!checked) {
695
+ checked = docker(runtime, [
696
+ "version",
697
+ "--format",
698
+ "{{.Server.Version}}"
699
+ ]).then(() => void 0).catch((error) => {
700
+ runtimes.delete(runtime);
701
+ throw new Error(`Container isolation needs the ${runtime} CLI and a running engine, which did not answer: ${error instanceof Error ? error.message : String(error)}. Install and start Docker Desktop, OrbStack, Colima, Rancher Desktop or podman, or name another docker-compatible command with container({ runtime }).`);
702
+ });
703
+ runtimes.set(runtime, checked);
704
+ }
705
+ return checked;
706
+ }
707
+ /** True for an image that names a Dockerfile (or its directory) rather than an image reference. */
708
+ function isDockerfile(image) {
709
+ return image.startsWith("./") || image.startsWith("../") || image.startsWith("/") || /(^|\/)Dockerfile[^/]*$/i.test(image);
710
+ }
711
+ const IGNORED = /* @__PURE__ */ new Set([
712
+ "node_modules",
713
+ ".git",
714
+ "_evalkit-results"
715
+ ]);
716
+ /** A hash of a build context: each file's path, size and modification time. */
717
+ async function contextHash(directory, dockerfile) {
718
+ const hash = createHash("sha256");
719
+ hash.update(await readFile(dockerfile));
720
+ const walk = async (dir) => {
721
+ const entries = await readdir(dir, { withFileTypes: true });
722
+ entries.sort((a, b) => a.name.localeCompare(b.name));
723
+ for (const entry of entries) {
724
+ if (IGNORED.has(entry.name)) continue;
725
+ const full = path.join(dir, entry.name);
726
+ if (entry.isDirectory()) await walk(full);
727
+ else if (entry.isFile()) {
728
+ const info = await stat(full);
729
+ hash.update(`${path.relative(directory, full)}\0${info.size}\0${info.mtimeMs}\n`);
730
+ }
731
+ }
732
+ };
733
+ await walk(directory);
734
+ return hash.digest("hex").slice(0, 16);
735
+ }
736
+ const builds = /* @__PURE__ */ new Map();
737
+ /**
738
+ * The image a run's container starts from: a reference as it is, or a
739
+ * Dockerfile built once per process and tagged by the hash of its
740
+ * directory, reused while the directory is unchanged.
741
+ */
742
+ function resolveImage(runtime, image, projectRoot) {
743
+ if (!isDockerfile(image)) return Promise.resolve(image);
744
+ const target = path.resolve(projectRoot, image);
745
+ const key = `${runtime}\0${target}`;
746
+ let built = builds.get(key);
747
+ if (!built) {
748
+ built = (async () => {
749
+ const info = await stat(target).catch(() => void 0);
750
+ if (!info) throw new Error(`container() image ${image} is not an image name or a Dockerfile in the project: ${target} does not exist`);
751
+ const dockerfile = info.isDirectory() ? path.join(target, "Dockerfile") : target;
752
+ const context = path.dirname(dockerfile);
753
+ const tag = `evalkit-${path.basename(context).toLowerCase().replace(/[^a-z0-9_.-]/g, "-").replace(/^[^a-z0-9]+/, "") || "image"}:${await contextHash(context, dockerfile)}`;
754
+ if (!await docker(runtime, [
755
+ "image",
756
+ "inspect",
757
+ tag
758
+ ]).then(() => true, () => false)) await docker(runtime, [
759
+ "build",
760
+ "-q",
761
+ "-t",
762
+ tag,
763
+ "-f",
764
+ dockerfile,
765
+ context
766
+ ]);
767
+ return tag;
768
+ })();
769
+ builds.set(key, built);
770
+ built.catch(() => builds.delete(key));
771
+ }
772
+ return built;
773
+ }
774
+ //#endregion
775
+ //#region ../runner/src/shell/workspace-files.ts
776
+ /**
777
+ * A path relative to the workspace, as an absolute host path; one that
778
+ * would leave the workspace is an error.
779
+ */
780
+ function insideWorkspace(root, relative = ".") {
781
+ if (path.isAbsolute(relative)) throw new Error(`Shell paths are relative to the workspace; received ${relative}`);
782
+ return inside(root, relative, relative);
783
+ }
784
+ function inside(root, file, shown) {
785
+ const resolved = path.resolve(root, file);
786
+ const back = path.relative(root, resolved);
787
+ if (back.startsWith("..") || path.isAbsolute(back)) throw new Error(`${shown} is outside the run's workspace and home`);
788
+ return resolved;
789
+ }
790
+ /**
791
+ * A shell path as a host path: relative ones are in the workspace,
792
+ * absolute ones must be inside the workspace or the home as commands see
793
+ * them.
794
+ */
795
+ function hostPath(directories, file = ".") {
796
+ if (!path.isAbsolute(file)) return inside(directories.root, file, file);
797
+ for (const [seen, host] of [[directories.seenHome, directories.home], [directories.seenRoot, directories.root]]) {
798
+ const back = path.relative(seen, file);
799
+ if (!back.startsWith("..") && !path.isAbsolute(back)) return inside(host, back, file);
800
+ }
801
+ throw new Error(`${file} is outside the run's workspace and home`);
802
+ }
803
+ /**
804
+ * The shell's file operations, on the host's copies of the workspace and
805
+ * home: for container isolation they are bind-mounted, so the container
806
+ * sees the same files.
807
+ */
808
+ function shellFiles(directories) {
809
+ const resolve = (file) => hostPath(directories, file);
810
+ return {
811
+ home: directories.seenHome,
812
+ readFile: async (file) => readFile(resolve(file), "utf8"),
813
+ async writeFile(file, content) {
814
+ const target = resolve(file);
815
+ await mkdir(path.dirname(target), { recursive: true });
816
+ await writeFile(target, content);
817
+ },
818
+ async list(directory = ".") {
819
+ return (await readdir(resolve(directory), { withFileTypes: true })).map((entry) => entry.isDirectory() ? `${entry.name}/` : entry.name).sort();
820
+ }
821
+ };
822
+ }
823
+ //#endregion
824
+ //#region ../runner/src/shell/container-shell.ts
825
+ /** Where the candidate workspace is inside the container. */
826
+ const CONTAINER_WORKSPACE = "/workspace";
827
+ /** Where the run's private home is mounted. */
828
+ const CONTAINER_HOME = "/evalkit-home";
829
+ /** Where each command records its process ID, so `kill` can reach it. */
830
+ const PID_DIR = "/tmp/.evalkit";
831
+ /** The container's name for a run. */
832
+ function containerName(runId) {
833
+ return `evalkit-${runId.replace(/[^a-zA-Z0-9_.-]/g, "-")}`;
834
+ }
835
+ /** Removes a run's kept container, if there is one; true when it removed one. */
836
+ async function removeRunContainer(runId, runtime = "docker") {
837
+ return docker(runtime, [
838
+ "rm",
839
+ "--force",
840
+ containerName(runId)
841
+ ]).then(() => true, () => false);
842
+ }
843
+ /** Arguments for `docker run`: everything but the image and its command. */
844
+ function containerRunArgs(options) {
845
+ const { isolation } = options;
846
+ const network = isolation.network === "none" ? ["--network", "none"] : isolation.network === "fixtures" ? ["--network", options.fixturesNetwork ?? "none"] : ["--add-host", "host.docker.internal:host-gateway"];
847
+ const user = process.platform === "linux" && process.getuid && process.getgid ? [
848
+ "--user",
849
+ `${process.getuid()}:${process.getgid()}`,
850
+ "-e",
851
+ "HOME=/tmp"
852
+ ] : [];
853
+ return [
854
+ "run",
855
+ "--detach",
856
+ "--init",
857
+ "--name",
858
+ options.name,
859
+ "--label",
860
+ `evalkit.run=${options.runId}`,
861
+ "--volume",
862
+ `${options.workspace}:${CONTAINER_WORKSPACE}`,
863
+ "--volume",
864
+ `${options.home}:${CONTAINER_HOME}`,
865
+ "--workdir",
866
+ CONTAINER_WORKSPACE,
867
+ ...network,
868
+ ...user,
869
+ ...isolation.env.flatMap((name) => ["--env", name]),
870
+ ...isolation.mounts.flatMap((mount) => ["--volume", `${path.resolve(mount.host)}:${mount.path}:ro`]),
871
+ "--entrypoint",
872
+ "sh"
873
+ ];
874
+ }
875
+ /**
876
+ * The run's container, started before the session: its own process table
877
+ * and filesystem, the candidate workspace bind-mounted read-write, the
878
+ * evaluator workspace never mounted. Commands run in it with `exec`.
879
+ */
880
+ async function containerShell(options) {
881
+ const { isolation } = options;
882
+ const runtime = isolation.runtime;
883
+ await ensureRuntime(runtime);
884
+ const image = await resolveImage(runtime, isolation.image, options.projectRoot);
885
+ const name = containerName(options.runId);
886
+ const workspace = await realpath(options.root);
887
+ await docker(runtime, [
888
+ ...containerRunArgs({
889
+ isolation,
890
+ name,
891
+ runId: options.runId,
892
+ workspace,
893
+ home: await realpath(options.home),
894
+ ...options.fixturesNetwork ? { fixturesNetwork: options.fixturesNetwork } : {}
895
+ }),
896
+ image,
897
+ "-c",
898
+ `mkdir -p ${PID_DIR}; trap 'exit 0' TERM; while :; do sleep 3600 & wait $!; done`
899
+ ]);
900
+ const tracker = new ProcessTracker();
901
+ const clients = /* @__PURE__ */ new Set();
902
+ let commands = 0;
903
+ const start = (command, commandOptions = {}) => {
904
+ const id = ++commands;
905
+ const pidFile = `${PID_DIR}/${id}.pid`;
906
+ const program = typeof command === "string" ? [
907
+ "sh",
908
+ "-c",
909
+ command
910
+ ] : [...command];
911
+ if (!program.length) throw new Error("A command needs a program to run");
912
+ const cwd = path.posix.join(CONTAINER_WORKSPACE, commandOptions.cwd ?? ".");
913
+ if (cwd !== "/workspace" && !cwd.startsWith(`/workspace/`)) throw new Error(`${commandOptions.cwd} is outside the run's workspace`);
914
+ const signalInside = async (signal) => {
915
+ const name_ = signal.replace(/^SIG/, "");
916
+ await docker(runtime, [
917
+ "exec",
918
+ name,
919
+ "sh",
920
+ "-c",
921
+ `p=$(cat ${pidFile} 2>/dev/null) && { kill -s ${name_} -- -$p 2>/dev/null || kill -s ${name_} $p; }`
922
+ ]).catch(() => void 0);
923
+ };
924
+ const started = startLocalProcess([
925
+ runtime,
926
+ "exec",
927
+ "--interactive",
928
+ "--workdir",
929
+ cwd,
930
+ ...Object.entries(commandOptions.env ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]),
931
+ name,
932
+ "sh",
933
+ "-c",
934
+ `echo $$ > ${pidFile}; exec "$@"`,
935
+ "sh",
936
+ ...program
937
+ ], {
938
+ cwd: process.cwd(),
939
+ env: process.env,
940
+ signal: commandOptions.signal ?? options.signal,
941
+ ...commandOptions.timeoutMs !== void 0 ? { timeoutMs: commandOptions.timeoutMs } : {},
942
+ ...commandOptions.input !== void 0 ? { input: commandOptions.input } : {},
943
+ kill: signalInside
944
+ });
945
+ clients.add(started);
946
+ started.wait().then(() => clients.delete(started));
947
+ let pid;
948
+ (async () => {
949
+ for (let attempt = 0; attempt < 20 && pid === void 0; attempt++) {
950
+ const text = await docker(runtime, [
951
+ "exec",
952
+ name,
953
+ "cat",
954
+ pidFile
955
+ ]).catch(() => "");
956
+ const parsed = Number.parseInt(text, 10);
957
+ if (Number.isInteger(parsed)) pid = parsed;
958
+ else await new Promise((resolve) => setTimeout(resolve, 50));
959
+ }
960
+ })();
961
+ const process_ = {
962
+ get pid() {
963
+ return pid;
964
+ },
965
+ stdout: started.stdout,
966
+ stderr: started.stderr,
967
+ onOutput: started.onOutput,
968
+ wait: started.wait,
969
+ kill: started.kill
970
+ };
971
+ return tracker.track(process_);
972
+ };
973
+ return {
974
+ isolation: "container",
975
+ root: CONTAINER_WORKSPACE,
976
+ container: name,
977
+ exec: async (command, commandOptions) => collect(start(command, commandOptions)),
978
+ spawn: (command, commandOptions) => start(command, commandOptions),
979
+ ...shellFiles({
980
+ root: options.root,
981
+ seenRoot: CONTAINER_WORKSPACE,
982
+ home: options.home,
983
+ seenHome: CONTAINER_HOME
984
+ }),
985
+ async close({ keep = false } = {}) {
986
+ await docker(runtime, keep ? [
987
+ "stop",
988
+ "--time",
989
+ "2",
990
+ name
991
+ ] : [
992
+ "rm",
993
+ "--force",
994
+ name
995
+ ]).catch(() => void 0);
996
+ for (const client of clients) await client.killLocal("SIGKILL");
997
+ await tracker.close();
998
+ }
999
+ };
1000
+ }
1001
+ //#endregion
1002
+ //#region ../runner/src/shell/host-shell.ts
1003
+ /** A string runs with `sh -c`; an array runs as it is. */
1004
+ function argv(command) {
1005
+ if (typeof command === "string") return [
1006
+ "/bin/sh",
1007
+ "-c",
1008
+ command
1009
+ ];
1010
+ if (!command.length) throw new Error("A command needs a program to run");
1011
+ return [...command];
1012
+ }
1013
+ /**
1014
+ * Commands on this machine, in the run's workspace, with the host's
1015
+ * environment: what adapters did before EvalKit had a shell. Each command
1016
+ * leads its own process group, and every group is stopped when the run
1017
+ * ends, background children included.
1018
+ */
1019
+ function hostShell(options) {
1020
+ const tracker = new ProcessTracker();
1021
+ const groups = /* @__PURE__ */ new Set();
1022
+ const start = (command, commandOptions = {}) => {
1023
+ const process_ = startLocalProcess(argv(command), {
1024
+ cwd: insideWorkspace(options.root, commandOptions.cwd),
1025
+ env: {
1026
+ ...process.env,
1027
+ ...commandOptions.env
1028
+ },
1029
+ signal: commandOptions.signal ?? options.signal,
1030
+ ...commandOptions.timeoutMs !== void 0 ? { timeoutMs: commandOptions.timeoutMs } : {},
1031
+ ...commandOptions.input !== void 0 ? { input: commandOptions.input } : {},
1032
+ forgetOnExit: false
1033
+ });
1034
+ if (process_.pid !== void 0) groups.add(process_.pid);
1035
+ return tracker.track(process_);
1036
+ };
1037
+ return {
1038
+ isolation: "none",
1039
+ root: options.root,
1040
+ exec: async (command, commandOptions) => collect(start(command, commandOptions)),
1041
+ spawn: (command, commandOptions) => start(command, commandOptions),
1042
+ ...shellFiles({
1043
+ root: options.root,
1044
+ seenRoot: options.root,
1045
+ home: options.home,
1046
+ seenHome: options.home
1047
+ }),
1048
+ async close() {
1049
+ await tracker.close();
1050
+ for (const group of groups) {
1051
+ try {
1052
+ process.kill(-group, "SIGKILL");
1053
+ } catch {}
1054
+ forgetGroup(group);
1055
+ }
1056
+ }
1057
+ };
1058
+ }
1059
+ //#endregion
1060
+ //#region ../runner/src/shell/sandbox-shell.ts
1061
+ /**
1062
+ * Runs inside the sandbox and starts every command of the run as its
1063
+ * child, so all of them share one sandbox: a later command may signal a
1064
+ * server an earlier one started, and nothing outside. Plain JavaScript, so
1065
+ * Node and Bun both run it. One JSON message per line each way.
1066
+ */
1067
+ const SUPERVISOR = `import { spawn } from 'node:child_process';
1068
+ import { createInterface } from 'node:readline';
1069
+ const children = new Map();
1070
+ const send = (message) => process.stdout.write(JSON.stringify(message) + '\\n');
1071
+ const signal = (child, name) => {
1072
+ try { process.kill(-child.pid, name); } catch { try { child.kill(name); } catch {} }
1073
+ };
1074
+ createInterface({ input: process.stdin }).on('line', (line) => {
1075
+ const message = JSON.parse(line);
1076
+ if (message.op === 'kill') {
1077
+ const child = children.get(message.id);
1078
+ if (child) signal(child, message.signal);
1079
+ return;
1080
+ }
1081
+ let child;
1082
+ try {
1083
+ child = spawn(message.argv[0], message.argv.slice(1), {
1084
+ cwd: message.cwd, env: { ...message.env, PWD: message.cwd }, detached: true, stdio: ['pipe', 'pipe', 'pipe'],
1085
+ });
1086
+ } catch (error) {
1087
+ send({ id: message.id, stream: 'stderr', data: String(error.message) + '\\n' });
1088
+ send({ id: message.id, exit: 127 });
1089
+ return;
1090
+ }
1091
+ children.set(message.id, child);
1092
+ send({ id: message.id, pid: child.pid });
1093
+ child.stdout.setEncoding('utf8').on('data', (data) => send({ id: message.id, stream: 'stdout', data }));
1094
+ child.stderr.setEncoding('utf8').on('data', (data) => send({ id: message.id, stream: 'stderr', data }));
1095
+ child.on('error', (error) => {
1096
+ send({ id: message.id, stream: 'stderr', data: message.argv[0] + ': ' + error.message + '\\n' });
1097
+ send({ id: message.id, exit: 127 });
1098
+ });
1099
+ child.on('close', (code, name) => {
1100
+ children.delete(message.id);
1101
+ send({ id: message.id, exit: code, signal: name });
1102
+ });
1103
+ child.stdin.on('error', () => {});
1104
+ child.stdin.end(message.input ?? '');
1105
+ });
1106
+ process.stdin.on('end', () => {
1107
+ for (const child of children.values()) signal(child, 'SIGKILL');
1108
+ process.exit(0);
1109
+ });
1110
+ `;
1111
+ const real = (file) => {
1112
+ try {
1113
+ return realpathSync(file);
1114
+ } catch {
1115
+ return path.resolve(file);
1116
+ }
1117
+ };
1118
+ const quote = (file) => JSON.stringify(real(file));
1119
+ /**
1120
+ * The `sandbox-exec` profile for one run. Everything is allowed but
1121
+ * reading or writing the project, the home directory and the other runs'
1122
+ * workspaces, and signalling processes outside the sandbox; the run's own
1123
+ * workspace and `allow` stay usable. Later rules win.
1124
+ */
1125
+ function sandboxProfile(options) {
1126
+ const runs = path.dirname(path.dirname(options.workspace));
1127
+ const denied = [
1128
+ options.projectRoot,
1129
+ homedir(),
1130
+ runs,
1131
+ ...options.deny ?? []
1132
+ ].map((file) => `(subpath ${quote(file)})`).join(" ");
1133
+ const usable = [options.workspace, ...options.allow].map((file) => `(subpath ${quote(file)})`).join(" ");
1134
+ const readable = [...options.readable.map((file) => `(literal ${quote(file)})`), ...(options.read ?? []).map((file) => `(subpath ${quote(file)})`)].join(" ");
1135
+ return [
1136
+ "(version 1)",
1137
+ "(allow default)",
1138
+ `(deny file-read* file-write* ${denied})`,
1139
+ `(allow file-read* file-write* ${usable})`,
1140
+ ...readable ? [`(allow file-read* ${readable})`] : [],
1141
+ "(allow file-read-metadata)",
1142
+ "(deny signal)",
1143
+ "(allow signal (target same-sandbox))"
1144
+ ].join("\n");
1145
+ }
1146
+ /** Variables every run gets when the environment is scrubbed: what programs expect to find. */
1147
+ const ESSENTIAL_ENV = [
1148
+ "PATH",
1149
+ "HOME",
1150
+ "TMPDIR",
1151
+ "USER",
1152
+ "LANG",
1153
+ "TERM"
1154
+ ];
1155
+ /** The environment a sandboxed run starts from: the host's, or only the named variables. */
1156
+ function sandboxEnv(names, host = process.env) {
1157
+ if (!names) return { ...host };
1158
+ const env = {};
1159
+ for (const name of [...ESSENTIAL_ENV, ...names]) if (host[name] !== void 0) env[name] = host[name];
1160
+ return env;
1161
+ }
1162
+ const resolveFrom = (root) => (file) => path.resolve(root, file.startsWith("~/") ? path.join(homedir(), file.slice(2)) : file);
1163
+ /** Writes the supervisor script once, where the sandbox can read it. */
1164
+ async function supervisorScript() {
1165
+ const hash = createHash("sha256").update(SUPERVISOR).digest("hex").slice(0, 12);
1166
+ const file = path.join(tmpdir(), `evalkit-supervisor-${hash}.mjs`);
1167
+ await writeFile(file, SUPERVISOR);
1168
+ return file;
1169
+ }
1170
+ /**
1171
+ * macOS `sandbox-exec` isolation: one sandboxed supervisor per run, which
1172
+ * starts every command the run sends it.
1173
+ */
1174
+ async function sandboxShell(options) {
1175
+ if (process.platform !== "darwin") throw new Error("sandboxExec() isolation needs macOS sandbox-exec; use container() on other systems");
1176
+ const script = await supervisorScript();
1177
+ const baseEnv = sandboxEnv(options.isolation.env);
1178
+ const profile = sandboxProfile({
1179
+ workspace: real(options.root),
1180
+ projectRoot: real(options.projectRoot),
1181
+ allow: [real(options.home), ...options.isolation.allow.map(resolveFrom(options.projectRoot))],
1182
+ read: options.isolation.read.map(resolveFrom(options.projectRoot)),
1183
+ deny: options.isolation.deny.map(resolveFrom(options.projectRoot)),
1184
+ readable: [process.execPath, script]
1185
+ });
1186
+ const supervisor = spawn("sandbox-exec", [
1187
+ "-p",
1188
+ profile,
1189
+ process.execPath,
1190
+ script
1191
+ ], {
1192
+ cwd: options.root,
1193
+ env: baseEnv,
1194
+ stdio: [
1195
+ "pipe",
1196
+ "pipe",
1197
+ "pipe"
1198
+ ]
1199
+ });
1200
+ let supervisorError = "";
1201
+ supervisor.stderr.setEncoding("utf8").on("data", (chunk) => {
1202
+ supervisorError = (supervisorError + chunk).slice(-2e3);
1203
+ });
1204
+ const handlers = /* @__PURE__ */ new Map();
1205
+ createInterface({ input: supervisor.stdout }).on("line", (line) => {
1206
+ let message;
1207
+ try {
1208
+ message = JSON.parse(line);
1209
+ } catch {
1210
+ return;
1211
+ }
1212
+ handlers.get(message.id)?.(message);
1213
+ });
1214
+ const gone = new Promise((resolve) => supervisor.on("close", () => resolve()));
1215
+ gone.then(() => {
1216
+ for (const handler of handlers.values()) handler({
1217
+ id: -1,
1218
+ stream: "stderr",
1219
+ data: `The run's sandbox stopped: ${supervisorError.trim()}\n`
1220
+ });
1221
+ for (const [id, handler] of handlers) handler({
1222
+ id,
1223
+ exit: 126
1224
+ });
1225
+ });
1226
+ const send = (message) => {
1227
+ if (supervisor.stdin.writable) supervisor.stdin.write(`${JSON.stringify(message)}\n`);
1228
+ };
1229
+ const tracker = new ProcessTracker();
1230
+ let commands = 0;
1231
+ const start = (command, commandOptions = {}) => {
1232
+ const id = ++commands;
1233
+ const stdout = new OutputBuffer();
1234
+ const stderr = new OutputBuffer();
1235
+ const listeners = /* @__PURE__ */ new Set();
1236
+ let pid;
1237
+ let timedOut = false;
1238
+ let resolveExit;
1239
+ const exit = new Promise((resolve) => {
1240
+ resolveExit = resolve;
1241
+ });
1242
+ let exited = false;
1243
+ const kill = async (signal) => {
1244
+ if (!exited) send({
1245
+ op: "kill",
1246
+ id,
1247
+ signal
1248
+ });
1249
+ };
1250
+ const stop = async () => {
1251
+ await kill("SIGTERM");
1252
+ const timer = setTimeout(() => void kill("SIGKILL"), STOP_GRACE_MS);
1253
+ timer.unref?.();
1254
+ await exit;
1255
+ clearTimeout(timer);
1256
+ };
1257
+ const onAbort = () => void stop();
1258
+ const signal = commandOptions.signal ?? options.signal;
1259
+ const timer = commandOptions.timeoutMs === void 0 ? void 0 : setTimeout(() => {
1260
+ timedOut = true;
1261
+ stop();
1262
+ }, commandOptions.timeoutMs);
1263
+ timer?.unref?.();
1264
+ handlers.set(id, (message) => {
1265
+ if ("pid" in message) pid = message.pid;
1266
+ else if ("stream" in message) {
1267
+ (message.stream === "stdout" ? stdout : stderr).append(message.data);
1268
+ for (const listener of listeners) listener(message.data, message.stream);
1269
+ } else if ("exit" in message && !exited) {
1270
+ exited = true;
1271
+ handlers.delete(id);
1272
+ signal.removeEventListener("abort", onAbort);
1273
+ if (timer) clearTimeout(timer);
1274
+ const result = message.signal ? {
1275
+ exitCode: signalExitCode(message.signal),
1276
+ signal: message.signal
1277
+ } : { exitCode: message.exit ?? 0 };
1278
+ resolveExit(timedOut ? {
1279
+ ...result,
1280
+ exitCode: 124,
1281
+ timedOut: true
1282
+ } : result);
1283
+ }
1284
+ });
1285
+ send({
1286
+ op: "spawn",
1287
+ id,
1288
+ argv: argv(command),
1289
+ cwd: insideWorkspace(options.root, commandOptions.cwd),
1290
+ env: {
1291
+ ...baseEnv,
1292
+ ...commandOptions.env
1293
+ },
1294
+ ...commandOptions.input !== void 0 ? { input: commandOptions.input } : {}
1295
+ });
1296
+ if (signal.aborted) onAbort();
1297
+ else signal.addEventListener("abort", onAbort, { once: true });
1298
+ return tracker.track({
1299
+ get pid() {
1300
+ return pid;
1301
+ },
1302
+ stdout: () => stdout.toString(),
1303
+ stderr: () => stderr.toString(),
1304
+ onOutput(listener) {
1305
+ listeners.add(listener);
1306
+ return () => listeners.delete(listener);
1307
+ },
1308
+ wait: () => exit,
1309
+ kill: async (name = "SIGTERM") => kill(name)
1310
+ });
1311
+ };
1312
+ return {
1313
+ isolation: "sandbox-exec",
1314
+ root: options.root,
1315
+ exec: async (command, commandOptions) => {
1316
+ const started = start(command, commandOptions);
1317
+ return {
1318
+ ...await started.wait(),
1319
+ stdout: started.stdout(),
1320
+ stderr: started.stderr()
1321
+ };
1322
+ },
1323
+ spawn: (command, commandOptions) => start(command, commandOptions),
1324
+ ...shellFiles({
1325
+ root: options.root,
1326
+ seenRoot: options.root,
1327
+ home: options.home,
1328
+ seenHome: options.home
1329
+ }),
1330
+ async close() {
1331
+ await tracker.close();
1332
+ supervisor.stdin.end();
1333
+ const timer = setTimeout(() => supervisor.kill("SIGKILL"), STOP_GRACE_MS);
1334
+ timer.unref?.();
1335
+ await gone;
1336
+ clearTimeout(timer);
1337
+ }
1338
+ };
1339
+ }
1340
+ //#endregion
1341
+ //#region ../runner/src/shell/fixtures.ts
1342
+ /** How long a fixture has to answer `ready`. */
1343
+ const FIXTURE_READY_MS = 6e4;
1344
+ const POLL_MS = 250;
1345
+ /** Forwards a port to the host; busybox's nc serves each connection in its own process. */
1346
+ const PROXY_IMAGE = "busybox:stable";
1347
+ const NAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
1348
+ /** The port a fixture listens on: its own, or its `ready` URL's. */
1349
+ function fixturePort(fixture) {
1350
+ if (fixture.port !== void 0) return fixture.port;
1351
+ const url = readyUrl(fixture.ready);
1352
+ if (!url) return void 0;
1353
+ if (url.port) return Number(url.port);
1354
+ return url.protocol === "https:" ? 443 : 80;
1355
+ }
1356
+ function readyUrl(ready) {
1357
+ if (!ready || !/^https?:\/\//.test(ready)) return void 0;
1358
+ try {
1359
+ return new URL(ready);
1360
+ } catch {
1361
+ return;
1362
+ }
1363
+ }
1364
+ /** Checks fixture definitions from the config. */
1365
+ function parseFixtures(fixtures) {
1366
+ for (const [name, fixture] of Object.entries(fixtures)) {
1367
+ if (!NAME.test(name)) throw new Error(`Fixture names are lowercase letters, digits and dashes, so runs can reach them as fixtures.<name>; received ${JSON.stringify(name)}`);
1368
+ if (typeof fixture?.start !== "string" || !fixture.start.trim()) throw new Error(`Fixture ${name} needs a start command`);
1369
+ if (fixture.port !== void 0 && (!Number.isInteger(fixture.port) || fixture.port < 1 || fixture.port > 65535)) throw new Error(`Fixture ${name} port must be from 1 to 65535`);
1370
+ }
1371
+ return { ...fixtures };
1372
+ }
1373
+ /**
1374
+ * The project's fixtures for one invocation: started on the host, in the
1375
+ * project root, the first time a run needs them, and stopped after its last.
1376
+ */
1377
+ function fixtureSet(fixtures, options) {
1378
+ const running = Object.entries(parseFixtures(fixtures)).map(([name, fixture]) => ({
1379
+ name,
1380
+ fixture,
1381
+ port: fixturePort(fixture),
1382
+ output: new OutputBuffer()
1383
+ }));
1384
+ const id = randomUUID().slice(0, 8);
1385
+ let starting;
1386
+ let networking;
1387
+ const proxies = [];
1388
+ const shell = (command, output) => {
1389
+ const started = startLocalProcess([
1390
+ "/bin/sh",
1391
+ "-c",
1392
+ command
1393
+ ], {
1394
+ cwd: options.root,
1395
+ env: process.env
1396
+ });
1397
+ if (output) started.onOutput((chunk) => output.append(chunk));
1398
+ return started;
1399
+ };
1400
+ const waitReady = async (entry) => {
1401
+ const { ready } = entry.fixture;
1402
+ if (!ready) return;
1403
+ const url = readyUrl(ready);
1404
+ const deadline = Date.now() + FIXTURE_READY_MS;
1405
+ let exited = false;
1406
+ entry.process?.wait().then(() => {
1407
+ exited = true;
1408
+ });
1409
+ while (Date.now() < deadline) {
1410
+ if (exited) throw new Error(`Fixture ${entry.name} exited before it was ready: ${entry.output.toString().trim().slice(-500)}`);
1411
+ if (url ? await fetch(url, { signal: AbortSignal.timeout(1e3) }).then(() => true, () => false) : (await collect(shell(ready))).exitCode === 0) return;
1412
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
1413
+ }
1414
+ throw new Error(`Fixture ${entry.name} was not ready after ${FIXTURE_READY_MS / 1e3}s (${ready}): ${entry.output.toString().trim().slice(-500)}`);
1415
+ };
1416
+ return {
1417
+ get started() {
1418
+ return starting !== void 0;
1419
+ },
1420
+ ensure() {
1421
+ starting ??= (async () => {
1422
+ for (const entry of running) entry.process = shell(entry.fixture.start, entry.output);
1423
+ await Promise.all(running.map(waitReady));
1424
+ })();
1425
+ return starting;
1426
+ },
1427
+ addresses(isolation) {
1428
+ const addresses = {};
1429
+ for (const entry of running) {
1430
+ const scheme = readyUrl(entry.fixture.ready)?.protocol ?? "http:";
1431
+ if (isolation.kind !== "container") {
1432
+ if (entry.port !== void 0) addresses[entry.name] = { url: `${scheme}//localhost:${entry.port}` };
1433
+ continue;
1434
+ }
1435
+ if (entry.port === void 0 || isolation.network === "none") continue;
1436
+ addresses[entry.name] = { url: isolation.network === "fixtures" ? `${scheme}//fixtures.${entry.name}:${entry.port}` : `${scheme}//host.docker.internal:${entry.port}` };
1437
+ }
1438
+ return addresses;
1439
+ },
1440
+ network(runtime) {
1441
+ if (networking && networking.runtime !== runtime) throw new Error(`Fixtures are on ${networking.runtime}'s network; one invocation uses one container runtime`);
1442
+ networking ??= {
1443
+ runtime,
1444
+ network: (async () => {
1445
+ await ensureRuntime(runtime);
1446
+ const network = `evalkit-fixtures-${id}`;
1447
+ await docker(runtime, [
1448
+ "network",
1449
+ "create",
1450
+ "--internal",
1451
+ "--label",
1452
+ "evalkit.fixtures=true",
1453
+ network
1454
+ ]);
1455
+ for (const entry of running) {
1456
+ if (entry.port === void 0) continue;
1457
+ const proxy = `${network}-${entry.name}`;
1458
+ await docker(runtime, [
1459
+ "run",
1460
+ "--detach",
1461
+ "--rm",
1462
+ "--name",
1463
+ proxy,
1464
+ "--label",
1465
+ "evalkit.fixtures=true",
1466
+ "--add-host",
1467
+ "host.docker.internal:host-gateway",
1468
+ PROXY_IMAGE,
1469
+ "nc",
1470
+ "-lk",
1471
+ "-p",
1472
+ String(entry.port),
1473
+ "-e",
1474
+ "nc",
1475
+ "host.docker.internal",
1476
+ String(entry.port)
1477
+ ]);
1478
+ proxies.push({
1479
+ runtime,
1480
+ name: proxy
1481
+ });
1482
+ await docker(runtime, [
1483
+ "network",
1484
+ "connect",
1485
+ "--alias",
1486
+ `fixtures.${entry.name}`,
1487
+ network,
1488
+ proxy
1489
+ ]);
1490
+ }
1491
+ return network;
1492
+ })()
1493
+ };
1494
+ return networking.network;
1495
+ },
1496
+ logs: () => running.filter((entry) => entry.process).map((entry) => ({
1497
+ name: entry.name,
1498
+ content: entry.output.toString()
1499
+ })),
1500
+ async stop() {
1501
+ for (const proxy of proxies.splice(0)) await docker(proxy.runtime, [
1502
+ "rm",
1503
+ "--force",
1504
+ proxy.name
1505
+ ]).catch(() => void 0);
1506
+ if (networking) {
1507
+ const { runtime, network } = networking;
1508
+ networking = void 0;
1509
+ const name = await network.catch(() => void 0);
1510
+ if (name) await docker(runtime, [
1511
+ "network",
1512
+ "rm",
1513
+ name
1514
+ ]).catch(() => void 0);
1515
+ }
1516
+ await Promise.all(running.map(async (entry) => {
1517
+ if (!entry.process) return;
1518
+ if (entry.fixture.stop) await collect(shell(entry.fixture.stop, entry.output));
1519
+ await entry.process.killLocal("SIGTERM");
1520
+ const timer = setTimeout(() => void entry.process?.killLocal("SIGKILL"), STOP_GRACE_MS);
1521
+ timer.unref?.();
1522
+ await entry.process.wait();
1523
+ clearTimeout(timer);
1524
+ }));
1525
+ }
1526
+ };
1527
+ }
1528
+ //#endregion
1529
+ //#region ../runner/src/shell/index.ts
1530
+ /**
1531
+ * Opens a run's shell where its isolation puts it, starting the project's
1532
+ * fixtures first when there are any.
1533
+ */
1534
+ async function openRunShell(options) {
1535
+ const { isolation } = options;
1536
+ const home = path.join(path.dirname(options.root), "home");
1537
+ await mkdir(home, { recursive: true });
1538
+ await options.fixtures?.ensure();
1539
+ const fixtures = options.fixtures?.addresses(isolation) ?? {};
1540
+ if (isolation.kind === "container") {
1541
+ const fixturesNetwork = isolation.network === "fixtures" && options.fixtures ? await options.fixtures.network(isolation.runtime) : void 0;
1542
+ const shell = await containerShell({
1543
+ isolation,
1544
+ runId: options.runId,
1545
+ root: options.root,
1546
+ home,
1547
+ projectRoot: options.projectRoot,
1548
+ signal: options.signal,
1549
+ ...fixturesNetwork ? { fixturesNetwork } : {}
1550
+ });
1551
+ return {
1552
+ shell,
1553
+ fixtures,
1554
+ close: ({ keep }) => shell.close({ keep })
1555
+ };
1556
+ }
1557
+ const shell = isolation.kind === "sandbox-exec" ? await sandboxShell({
1558
+ isolation,
1559
+ root: options.root,
1560
+ home,
1561
+ projectRoot: options.projectRoot,
1562
+ signal: options.signal
1563
+ }) : hostShell({
1564
+ root: options.root,
1565
+ home,
1566
+ signal: options.signal
1567
+ });
1568
+ return {
1569
+ shell,
1570
+ fixtures,
1571
+ close: () => shell.close()
1572
+ };
1573
+ }
1574
+ //#endregion
1575
+ //#region ../core/src/glob.ts
1576
+ /**
1577
+ * A glob matcher for discovery: `*` and `?` stay within a path segment, `**`
1578
+ * spans segments, and `{a,b}` is an alternation.
1579
+ */
1580
+ function globMatcher(pattern) {
1581
+ let source = "";
1582
+ for (let index = 0; index < pattern.length; index++) {
1583
+ const char = pattern[index];
1584
+ if (char === "*" && pattern[index + 1] === "*") {
1585
+ index++;
1586
+ if (pattern[index + 1] === "/") {
1587
+ index++;
1588
+ source += "(?:.*/)?";
1589
+ } else source += ".*";
1590
+ } else if (char === "*") source += "[^/]*";
1591
+ else if (char === "?") source += "[^/]";
1592
+ else if (char === "{") {
1593
+ const end = pattern.indexOf("}", index);
1594
+ if (end === -1) source += "\\{";
1595
+ else {
1596
+ source += `(?:${pattern.slice(index + 1, end).split(",").map((part) => part.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")).join("|")})`;
1597
+ index = end;
1598
+ }
1599
+ } else source += char.replace(/[.+^$()|[\]\\]/g, "\\$&");
1600
+ }
1601
+ const expression = new RegExp(`^${source}$`);
1602
+ return (path) => expression.test(path);
1603
+ }
1604
+ //#endregion
1605
+ //#region ../runner/src/snapshot.ts
1606
+ const GLOB_CHARS = /[*?{]/;
1607
+ function ignoreRule(pattern) {
1608
+ const negated = pattern.startsWith("!");
1609
+ const trimmed = (negated ? pattern.slice(1) : pattern).replace(/^\.\//, "").replace(/\/$/, "");
1610
+ const match = globMatcher(trimmed);
1611
+ if (!trimmed.includes("/")) return {
1612
+ negated,
1613
+ match: (relativePath) => match(path.posix.basename(relativePath)),
1614
+ mayMatchBelow: () => true
1615
+ };
1616
+ const segments = trimmed.split("/");
1617
+ const firstGlob = segments.findIndex((segment) => GLOB_CHARS.test(segment));
1618
+ const prefix = segments.slice(0, firstGlob === -1 ? segments.length : firstGlob).join("/");
1619
+ return {
1620
+ negated,
1621
+ match,
1622
+ mayMatchBelow: (directory) => prefix === "" || prefix.startsWith(`${directory}/`) || prefix === directory || directory.startsWith(`${prefix}/`)
1623
+ };
1624
+ }
1625
+ /**
1626
+ * Ignore rules for candidate snapshots, read like `.gitignore`: globs relative
1627
+ * to the workspace root, where a pattern without `/` matches that name at any
1628
+ * depth, and later patterns win. `!pattern` re-includes a path; unlike Git, it
1629
+ * also works below an ignored directory (`_build` then `!_build/doc/**`).
1630
+ */
1631
+ function ignoreRules(patterns) {
1632
+ const rules = patterns.map(ignoreRule);
1633
+ return {
1634
+ /** Whether a path is ignored, given whether its directory was. */
1635
+ ignored(relativePath, parentIgnored = false) {
1636
+ let ignored = parentIgnored;
1637
+ for (const rule of rules) if (rule.match(relativePath)) ignored = !rule.negated;
1638
+ return ignored;
1639
+ },
1640
+ /** Whether an ignored directory must still be walked for re-included paths. */
1641
+ mayReinclude(directory) {
1642
+ return rules.some((rule) => rule.negated && rule.mayMatchBelow(directory));
1643
+ }
1644
+ };
1645
+ }
1646
+ /**
1647
+ * Copies the candidate workspace's regular files into the run report,
1648
+ * leaving out ignored paths. Symbolic links and special files are skipped,
1649
+ * never followed, so a snapshot cannot read outside the run. An ignored
1650
+ * directory is walked only when a `!` pattern could re-include something in
1651
+ * it, and is listed only if it ends up containing a snapshotted file.
1652
+ */
1653
+ async function snapshotCandidateWorkspace(candidateRoot, writer, ignore = DEFAULT_ARTIFACT_EXCLUDE) {
1654
+ const artifacts = [];
1655
+ const rules = ignoreRules(ignore);
1656
+ /** Returns whether anything below `directory` was snapshotted. */
1657
+ async function visit(directory, relativeDirectory, directoryIgnored) {
1658
+ const entries = await readdir(directory, { withFileTypes: true });
1659
+ entries.sort((left, right) => left.name.localeCompare(right.name));
1660
+ let kept = false;
1661
+ for (const entry of entries) {
1662
+ const absolutePath = path.join(directory, entry.name);
1663
+ const relativePath = path.join(relativeDirectory, entry.name);
1664
+ const posixPath = relativePath.split(path.sep).join("/");
1665
+ const ignored = rules.ignored(posixPath, directoryIgnored);
1666
+ const stat = await lstat(absolutePath);
1667
+ if (stat.isSymbolicLink()) continue;
1668
+ if (stat.isDirectory()) {
1669
+ if (ignored && !rules.mayReinclude(posixPath)) continue;
1670
+ const entryIndex = artifacts.length;
1671
+ const keptBelow = await visit(absolutePath, relativePath, ignored);
1672
+ if (!ignored || keptBelow) {
1673
+ artifacts.splice(entryIndex, 0, {
1674
+ path: path.join("candidate", relativePath),
1675
+ kind: "directory"
1676
+ });
1677
+ kept = true;
1678
+ }
1679
+ continue;
1680
+ }
1681
+ if (ignored || !stat.isFile()) continue;
1682
+ const data = await readFile(absolutePath);
1683
+ const artifactPath = path.join("candidate", relativePath);
1684
+ await writer.writeArtifact(artifactPath, data);
1685
+ artifacts.push({
1686
+ path: artifactPath,
1687
+ kind: "file",
1688
+ size: data.byteLength
1689
+ });
1690
+ kept = true;
1691
+ }
1692
+ return kept;
1693
+ }
1694
+ await visit(candidateRoot, "", false);
1695
+ return artifacts;
1696
+ }
1697
+ //#endregion
1698
+ //#region ../runner/src/datasets.ts
1699
+ const loads = /* @__PURE__ */ new WeakMap();
1700
+ /**
1701
+ * Loads a dataset at most once per process and project root. A failed load is
1702
+ * forgotten, so the next run that needs the dataset tries again.
1703
+ */
1704
+ function loadDataset(dataset, root, signal) {
1705
+ let byRoot = loads.get(dataset);
1706
+ if (!byRoot) loads.set(dataset, byRoot = /* @__PURE__ */ new Map());
1707
+ const cached = byRoot.get(root);
1708
+ if (cached) return cached;
1709
+ const loading = loadUncached(dataset, root, signal);
1710
+ byRoot.set(root, loading);
1711
+ loading.catch(() => {
1712
+ if (byRoot.get(root) === loading) byRoot.delete(root);
1713
+ });
1714
+ return loading;
1715
+ }
1716
+ async function loadUncached(dataset, root, signal) {
1717
+ const { source } = dataset;
1718
+ if (source.kind === "computed") {
1719
+ const loaded = await source.onLoad({
1720
+ root,
1721
+ signal
1722
+ });
1723
+ if (typeof loaded !== "object" || loaded === null) throw new Error(`Dataset ${dataset.id} onLoad must return { files?, value? }`);
1724
+ return loaded;
1725
+ }
1726
+ return {
1727
+ ...source.files !== void 0 ? { files: source.files } : {},
1728
+ ...source.valueFile !== void 0 ? { value: await readValueFile(dataset.id, path.resolve(root, source.valueFile)) } : source.value !== void 0 ? { value: source.value } : {}
1729
+ };
1730
+ }
1731
+ /** `.json` is one value; `.jsonl` is an array of one JSON object per non-blank line. */
1732
+ async function readValueFile(id, file) {
1733
+ const text = await readFile(file, "utf8").catch((cause) => {
1734
+ throw new Error(`Dataset ${id} cannot read ${file}: ${String(cause)}`);
1735
+ });
1736
+ if (file.endsWith(".jsonl")) return text.split("\n").flatMap((line, index) => {
1737
+ if (!line.trim()) return [];
1738
+ try {
1739
+ const row = JSON.parse(line);
1740
+ if (typeof row !== "object" || row === null || Array.isArray(row)) throw new Error("expected a JSON object");
1741
+ return [row];
1742
+ } catch (cause) {
1743
+ throw new Error(`Dataset ${id} ${file}:${index + 1}: ${cause instanceof Error ? cause.message : String(cause)}`);
1744
+ }
1745
+ });
1746
+ if (file.endsWith(".json")) try {
1747
+ return JSON.parse(text);
1748
+ } catch (cause) {
1749
+ throw new Error(`Dataset ${id} ${file}: ${cause instanceof Error ? cause.message : String(cause)}`);
1750
+ }
1751
+ throw new Error(`Dataset ${id} valueFile must be a .json or .jsonl file: ${file}`);
1752
+ }
1753
+ /** Loads (cached), derives this run's content with `onRun`, and resolves it. */
1754
+ async function prepareDatasets(datasets, root, run) {
1755
+ const prepared = [];
1756
+ for (const dataset of datasets) {
1757
+ const loaded = await loadDataset(dataset, root, run.signal);
1758
+ const content = dataset.source.kind === "computed" && dataset.source.onRun ? await dataset.source.onRun(loaded, run) : loaded;
1759
+ if (typeof content !== "object" || content === null) throw new Error(`Dataset ${dataset.id} onRun must return { files?, value? }`);
1760
+ prepared.push(await resolveContent(dataset, content, root));
1761
+ }
1762
+ return prepared;
1763
+ }
1764
+ async function resolveContent(dataset, content, root) {
1765
+ const label = `Dataset ${dataset.id}`;
1766
+ const value = content.value === void 0 ? void 0 : toJson(content.value, `datasets.${dataset.id}.value`);
1767
+ let files = { kind: "none" };
1768
+ if (typeof content.files === "string") {
1769
+ const source = path.resolve(root, content.files);
1770
+ if (!(await stat(source).catch(() => void 0))?.isDirectory()) throw new Error(`${label} files directory does not exist: ${content.files}`);
1771
+ files = {
1772
+ kind: "directory",
1773
+ source
1774
+ };
1775
+ } else if (content.files !== void 0) files = {
1776
+ kind: "entries",
1777
+ entries: Object.entries(content.files).map(([file, source]) => preparedFile(relativeDatasetPath(file, `${label} file`), source, root, label))
1778
+ };
1779
+ return {
1780
+ dataset,
1781
+ files,
1782
+ ...value === void 0 ? {} : { value },
1783
+ digest: await digest(files, value)
1784
+ };
1785
+ }
1786
+ function preparedFile(file, source, root, label) {
1787
+ if (typeof source === "string") return {
1788
+ path: file,
1789
+ kind: "bytes",
1790
+ data: new TextEncoder().encode(source)
1791
+ };
1792
+ if (source instanceof Uint8Array) return {
1793
+ path: file,
1794
+ kind: "bytes",
1795
+ data: source
1796
+ };
1797
+ if (typeof source === "object" && source !== null && typeof source.file === "string") return {
1798
+ path: file,
1799
+ kind: "file",
1800
+ source: path.resolve(root, source.file)
1801
+ };
1802
+ throw new Error(`${label} file ${file} must be a string, bytes, or { file: 'path' }`);
1803
+ }
1804
+ /** Lists a directory's files as sorted relative POSIX paths. */
1805
+ async function listFiles(directory, prefix = "") {
1806
+ const entries = await readdir(path.join(directory, prefix), { withFileTypes: true });
1807
+ const files = [];
1808
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
1809
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
1810
+ if (entry.isDirectory()) files.push(...await listFiles(directory, relative));
1811
+ else if (entry.isFile()) files.push(relative);
1812
+ }
1813
+ return files;
1814
+ }
1815
+ async function digest(files, value) {
1816
+ const hash = createHash("sha256");
1817
+ const addFile = (file, data) => {
1818
+ hash.update(`file ${file} ${data.byteLength}\n`);
1819
+ hash.update(data);
1820
+ };
1821
+ if (files.kind === "directory") for (const file of await listFiles(files.source)) addFile(file, await readFile(path.join(files.source, file)));
1822
+ else if (files.kind === "entries") for (const entry of [...files.entries].sort((a, b) => a.path.localeCompare(b.path))) addFile(entry.path, entry.kind === "bytes" ? entry.data : await readFile(entry.source));
1823
+ if (value !== void 0) hash.update(`value ${canonicalParameters(value)}\n`);
1824
+ return `sha256:${hash.digest("hex")}`;
1825
+ }
1826
+ function runDatasets(prepared) {
1827
+ return prepared.map(({ dataset, digest }) => ({
1828
+ id: dataset.id,
1829
+ visibility: dataset.visibility,
1830
+ source: dataset.source.kind,
1831
+ digest
1832
+ }));
1833
+ }
1834
+ /**
1835
+ * The files a static dataset places in every workspace, relative to its
1836
+ * mount. Computed datasets return `undefined`: their files only exist once
1837
+ * `onLoad` runs, and browsing must never run project code.
1838
+ */
1839
+ async function staticDatasetFiles(dataset, root) {
1840
+ const { source } = dataset;
1841
+ if (source.kind === "computed") return void 0;
1842
+ if (source.files === void 0) return [];
1843
+ if (typeof source.files === "string") {
1844
+ const directory = path.resolve(root, source.files);
1845
+ return Promise.all((await listFiles(directory)).map(async (file) => ({
1846
+ path: file,
1847
+ size: (await stat(path.join(directory, file))).size
1848
+ })));
1849
+ }
1850
+ return Promise.all(Object.entries(source.files).sort(([a], [b]) => a.localeCompare(b)).map(async ([file, content]) => ({
1851
+ path: file,
1852
+ size: typeof content === "string" ? new TextEncoder().encode(content).byteLength : content instanceof Uint8Array ? content.byteLength : (await stat(path.resolve(root, content.file))).size
1853
+ })));
1854
+ }
1855
+ /** One file of a static dataset, by its path below the mount. */
1856
+ async function staticDatasetFile(dataset, root, file) {
1857
+ const { source } = dataset;
1858
+ const missing = () => /* @__PURE__ */ new Error(`Dataset ${dataset.id} has no file ${JSON.stringify(file)}`);
1859
+ if (source.kind === "computed" || source.files === void 0) throw missing();
1860
+ if (typeof source.files === "string") {
1861
+ const directory = path.resolve(root, source.files);
1862
+ const resolved = path.resolve(directory, file);
1863
+ if (!resolved.startsWith(`${directory}${path.sep}`)) throw missing();
1864
+ if (!(await stat(resolved).catch(() => void 0))?.isFile()) throw missing();
1865
+ return readFile(resolved);
1866
+ }
1867
+ if (!Object.hasOwn(source.files, file)) throw missing();
1868
+ const content = source.files[file];
1869
+ if (typeof content === "string") return new TextEncoder().encode(content);
1870
+ if (content instanceof Uint8Array) return new Uint8Array(content);
1871
+ return readFile(path.resolve(root, content.file));
1872
+ }
1873
+ //#endregion
1874
+ //#region ../runner/src/workspace.ts
1875
+ /** Where a run's persistent workspace lives below `root`. */
1876
+ function runWorkspacePath(root, runId) {
1877
+ return path.join(path.resolve(root), runId.replace(/[^a-zA-Z0-9._-]/g, "_"));
1878
+ }
1879
+ /**
1880
+ * Creates isolated candidate and evaluator directories and writes each
1881
+ * dataset's files below its mount, on the side its visibility allows.
1882
+ * Two datasets writing the same file is an error, never an overwrite.
1883
+ */
1884
+ async function createRunWorkspace(runId, datasets = [], persistentRoot) {
1885
+ const root = persistentRoot ? runWorkspacePath(persistentRoot, runId) : await mkdtemp(path.join(os.tmpdir(), "evalkit-run-"));
1886
+ const persistent = persistentRoot !== void 0;
1887
+ const artifacts = {
1888
+ candidate: { root: path.join(root, "candidate") },
1889
+ evaluator: { root: path.join(root, "evaluator") }
1890
+ };
1891
+ try {
1892
+ await Promise.all([mkdir(artifacts.candidate.root, { recursive: true }), mkdir(artifacts.evaluator.root, { recursive: true })]);
1893
+ for (const prepared of datasets) {
1894
+ const side = prepared.dataset.visibility === "candidate" ? artifacts.candidate.root : artifacts.evaluator.root;
1895
+ await writeDatasetFiles(prepared, path.join(side, prepared.dataset.mount));
1896
+ }
1897
+ return {
1898
+ artifacts,
1899
+ cleanup: async (keep = false) => {
1900
+ if (persistent && keep) return;
1901
+ await rm(root, {
1902
+ recursive: true,
1903
+ force: true
1904
+ });
1905
+ }
1906
+ };
1907
+ } catch (error) {
1908
+ await rm(root, {
1909
+ recursive: true,
1910
+ force: true
1911
+ });
1912
+ throw error;
1913
+ }
1914
+ }
1915
+ async function writeDatasetFiles(prepared, target) {
1916
+ const { files, dataset } = prepared;
1917
+ if (files.kind === "none") return;
1918
+ await mkdir(target, { recursive: true });
1919
+ const entries = files.kind === "directory" ? (await listFiles(files.source)).map((file) => ({
1920
+ path: file,
1921
+ kind: "file",
1922
+ source: path.join(files.source, file)
1923
+ })) : files.entries;
1924
+ for (const entry of entries) await writeEntry(dataset.id, target, entry);
1925
+ }
1926
+ async function writeEntry(datasetId, target, entry) {
1927
+ const destination = path.join(target, ...entry.path.split("/"));
1928
+ await mkdir(path.dirname(destination), { recursive: true });
1929
+ await (entry.kind === "bytes" ? writeFile(destination, entry.data, { flag: "wx" }) : copyFile(entry.source, destination, 1)).catch((cause) => {
1930
+ throw collision(datasetId, entry.path, cause);
1931
+ });
1932
+ }
1933
+ function collision(datasetId, file, cause) {
1934
+ return cause.code === "EEXIST" ? /* @__PURE__ */ new Error(`Dataset ${datasetId} would overwrite ${file}, which another dataset already wrote`) : cause instanceof Error ? cause : new Error(String(cause));
1935
+ }
1936
+ //#endregion
1937
+ //#region ../runner/src/checkpoint-lookup.ts
1938
+ /**
1939
+ * `checkpoint(rule)` for a scoring context: the latest result of the steps
1940
+ * that are this very rule. A rule outside the transcript is a mistake in the
1941
+ * scenario, so it throws rather than reading as "not run".
1942
+ */
1943
+ function checkpointLookup(transcript, results) {
1944
+ return ((rule) => {
1945
+ const steps = new Set(transcript.flatMap((step, index) => step === rule ? [index] : []));
1946
+ if (!steps.size) throw new Error(`checkpoint(${JSON.stringify(rule.name)}) is not a checkpoint in this scenario's transcript; pass the same rule object the transcript uses`);
1947
+ for (let index = results.length - 1; index >= 0; index--) if (steps.has(results[index].step)) return results[index];
1948
+ });
1949
+ }
1950
+ //#endregion
1951
+ //#region ../runner/src/score.ts
1952
+ const inUnitInterval = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
1953
+ /**
1954
+ * Criteria must be well formed before they can stand for the score. With
1955
+ * declared criteria, each takes its declared weight unless it sets one, a
1956
+ * declared one left out is not reached, and an undeclared one is an error,
1957
+ * so the checks shown before a run are the checks recorded after it.
1958
+ */
1959
+ function checkedCriteria(criteria, declared) {
1960
+ if (!Array.isArray(criteria) || !criteria.length && !declared?.length) throw new Error("criteria must be a non-empty array");
1961
+ const seen = /* @__PURE__ */ new Set();
1962
+ const results = criteria.map((entry, index) => {
1963
+ if (typeof entry?.name !== "string" || !entry.name.trim()) throw new Error(`criteria[${index}] needs a name`);
1964
+ const { name } = entry;
1965
+ if (seen.has(name)) throw new Error(`Criterion ${name} is listed twice`);
1966
+ seen.add(name);
1967
+ const declaration = declared?.find((criterion) => criterion.name === name);
1968
+ if (declared && !declaration) throw new Error(`Criterion ${name} is not declared; declared: ${declared.map((criterion) => criterion.name).join(", ")}`);
1969
+ const weight = entry.weight ?? declaration?.weight;
1970
+ if (weight !== void 0 && !(Number.isFinite(weight) && weight > 0)) throw new Error(`Criterion ${name} weight must be a positive number; received ${String(weight)}`);
1971
+ const explanation = entry.explanation;
1972
+ if (explanation !== void 0 && typeof explanation !== "string") throw new Error(`Criterion ${name} explanation must be a string`);
1973
+ const common = {
1974
+ name,
1975
+ ...weight !== void 0 ? { weight } : {},
1976
+ ...explanation?.trim() ? { explanation: explanation.trim() } : {}
1977
+ };
1978
+ if ("reached" in entry) {
1979
+ if (entry.reached !== false) throw new Error(`Criterion ${name}: reached can only be false; leave it out for a check that ran`);
1980
+ return {
1981
+ ...common,
1982
+ reached: false
1983
+ };
1984
+ }
1985
+ const value = entry.value;
1986
+ if (!inUnitInterval(value)) throw new Error(`Criterion ${name} value must be a number from 0 to 1; received ${String(value)}`);
1987
+ const evidence = entry.evidence;
1988
+ return {
1989
+ ...common,
1990
+ value,
1991
+ ...evidence !== void 0 ? { evidence: toJson(evidence, `criteria.${name}.evidence`) } : {}
1992
+ };
1993
+ });
1994
+ const missing = (declared ?? []).filter((criterion) => !seen.has(criterion.name)).map((criterion) => ({
1995
+ name: criterion.name,
1996
+ weight: criterion.weight,
1997
+ reached: false
1998
+ }));
1999
+ return [...results, ...missing];
2000
+ }
2001
+ /**
2002
+ * Shared normalization for inline and final predicate and judge rules.
2003
+ * Without a `value`, the weighted mean of `criteria` is the value. `passAt`
2004
+ * turns the value into a verdict, and is recorded with it; an explicit
2005
+ * `passed` wins.
2006
+ */
2007
+ function normalizeScore(value, passAt, declared) {
2008
+ if (typeof value === "object" && value !== null && "skipped" in value && value.skipped === true) return {
2009
+ skipped: true,
2010
+ explanation: value.reason
2011
+ };
2012
+ const normalized = typeof value === "boolean" ? { value: Number(value) } : typeof value === "number" ? { value } : value;
2013
+ if (!normalized || typeof normalized !== "object") throw new Error(`A scorer must return a boolean, a number from 0 to 1, or a score object; received ${String(value)}`);
2014
+ const criteria = normalized.criteria === void 0 ? void 0 : checkedCriteria(normalized.criteria, declared);
2015
+ const score = normalized.value ?? (criteria ? weightedMean(criteria) : void 0);
2016
+ if (!inUnitInterval(score)) throw new Error(`Score value must be a finite number from 0 to 1; received ${String(score)}`);
2017
+ if (normalized.judge?.usage) {
2018
+ const { usage } = normalized.judge;
2019
+ if (Object.values(usage).some((tokens) => !Number.isSafeInteger(tokens) || tokens < 0)) throw new Error("Judge token usage must contain nonnegative integers");
2020
+ }
2021
+ const mark = passAt ?? 1;
2022
+ return {
2023
+ value: score,
2024
+ passed: normalized.passed ?? score >= mark,
2025
+ passAt: mark,
2026
+ ...normalized.explanation ? { explanation: normalized.explanation } : {},
2027
+ ...normalized.evidence !== void 0 ? { evidence: toJson(normalized.evidence, "evidence") } : {},
2028
+ ...normalized.choice ? { choice: normalized.choice } : {},
2029
+ ...criteria ? { criteria } : {},
2030
+ ...normalized.judge !== void 0 ? { judge: normalized.judge } : {}
2031
+ };
2032
+ }
2033
+ //#endregion
2034
+ //#region ../runner/src/judge-agent.ts
2035
+ const failure$2 = (name, cause) => new RuleExecutionError({
2036
+ name,
2037
+ cause,
2038
+ message: cause instanceof Error ? cause.message : String(cause)
2039
+ });
2040
+ /** A long tool result or reply is cut to this many characters in a judge's conversation. */
2041
+ const MAX_PART = 4e3;
2042
+ /**
2043
+ * Drives one judge agent session for one assessment: builds the prompt from
2044
+ * the rule, sends it, reads the answer in the rule's format, and asks once
2045
+ * more if it cannot. Its session is its own transcript, never the agent's.
2046
+ */
2047
+ function runJudgeAgent(rule, agent, input, placement) {
2048
+ return Effect.tryPromise({
2049
+ try: () => judgeOnce(rule, agent, input, placement),
2050
+ catch: (cause) => cause instanceof RuleExecutionError ? cause : failure$2(rule.name, cause)
2051
+ });
2052
+ }
2053
+ let judgeSessions = 0;
2054
+ async function judgeOnce(rule, agent, input, placement) {
2055
+ const inputs = judgeInputNames(rule);
2056
+ const values = await inputValues(rule, input, placement);
2057
+ const prompt = judgePrompt({
2058
+ rubric: rule.rubric,
2059
+ answer: rule.answer,
2060
+ inputs,
2061
+ values
2062
+ });
2063
+ const now = () => /* @__PURE__ */ new Date();
2064
+ const transcript = new RunTranscript({
2065
+ id: `${input.context.runId}-judge-${++judgeSessions}`,
2066
+ agent: {
2067
+ id: agent.identity?.id ?? "judge",
2068
+ ...agent.identity?.version ? { version: agent.identity.version } : {}
2069
+ },
2070
+ runId: input.context.runId,
2071
+ scenarioId: input.context.evalId,
2072
+ now
2073
+ });
2074
+ const context = {
2075
+ ...input.context,
2076
+ data: input.data,
2077
+ workspace: input.artifacts.evaluator,
2078
+ evaluatorWorkspace: input.artifacts.evaluator
2079
+ };
2080
+ const session = await agent.start({
2081
+ context,
2082
+ attach: input.attach,
2083
+ transcript: transcript.writer()
2084
+ });
2085
+ let reading;
2086
+ let reply;
2087
+ let attempts = 0;
2088
+ try {
2089
+ let message = prompt;
2090
+ let problem = "";
2091
+ while (attempts < 2 && !reading) {
2092
+ attempts += 1;
2093
+ const from = transcript.length;
2094
+ transcript.scriptUser(message);
2095
+ const result = await session.send(message);
2096
+ if (result !== void 0) transcript.writeTurnResult(result, attempts);
2097
+ transcript.endTurn();
2098
+ reply = replyIn(transcript.since(from));
2099
+ if (reply === void 0) problem = "it did not reply";
2100
+ else try {
2101
+ reading = readJudgeAnswer(rule.answer, reply);
2102
+ } catch (error) {
2103
+ if (!(error instanceof UnreadableAnswer)) throw error;
2104
+ problem = error.message;
2105
+ }
2106
+ message = `Your answer could not be read: ${problem}. Answer again, only in this format.\n\n${answerInstruction(rule.answer)}`;
2107
+ }
2108
+ if (!reading) throw new Error(`Judge ${rule.name}'s answer could not be read after ${attempts} tries: ${problem}${reply === void 0 ? "" : `. Its last reply: ${preview(reply)}`}`);
2109
+ } finally {
2110
+ await session.close?.();
2111
+ }
2112
+ transcript.seal();
2113
+ const problems = transcript.problems();
2114
+ if (problems.length) throw new Error(`Judge ${rule.name}'s transcript is not valid ATXF: ${problems.slice(0, 3).join("; ")}`);
2115
+ const recorded = transcript.transcript;
2116
+ const model = lastOf(recorded.records, (record) => record.type === "message" && record.role === "assistant" && record.model !== void 0);
2117
+ const usage = transcriptUsage(recorded);
2118
+ const judge = {
2119
+ ...agent.identity ? { agent: agent.identity } : {},
2120
+ ...model?.type === "message" && model.role === "assistant" && model.model ? { model: modelName(model.model) } : {},
2121
+ ...usage ? { usage } : {},
2122
+ prompt,
2123
+ ...reply !== void 0 ? { reply } : {},
2124
+ ...attempts > 1 ? { attempts } : {},
2125
+ transcript: recorded
2126
+ };
2127
+ return { value: {
2128
+ ...reading,
2129
+ judge
2130
+ } };
2131
+ }
2132
+ /** Each input's text: what the rule's function returned, or the defaults. */
2133
+ async function inputValues(rule, input, placement) {
2134
+ if (!rule.inputs) return {
2135
+ conversation: conversationText(input.transcript, placement),
2136
+ reply: text(input.output)
2137
+ };
2138
+ const values = {};
2139
+ for (const [name, read] of Object.entries(rule.inputs)) values[name] = text(await read(input));
2140
+ return values;
2141
+ }
2142
+ /**
2143
+ * The agent's conversation as plain lines, for a judge to read: this turn
2144
+ * (from the last user message) at a checkpoint, else all of it.
2145
+ */
2146
+ function conversationText(transcript, placement) {
2147
+ let path = activePath(transcript);
2148
+ if (placement === "transcript") {
2149
+ let start = 0;
2150
+ path.forEach((record, index) => {
2151
+ if (record.type === "message" && record.role === "user") start = index;
2152
+ });
2153
+ path = path.slice(start);
2154
+ }
2155
+ const lines = [];
2156
+ for (const record of path) {
2157
+ if (record.type !== "message") continue;
2158
+ if (record.role === "tool") lines.push(`Tool result ${record.toolName ?? record.toolCallId}: ${cut(contentText(record.content))}`);
2159
+ else if (record.role === "assistant") {
2160
+ const said = contentText(record.content);
2161
+ if (said) lines.push(`Assistant: ${cut(said)}`);
2162
+ for (const block of record.content) if (block.type === "tool_call") lines.push(`Tool call ${block.name}: ${cut(block.input ?? JSON.stringify(block.arguments ?? {}, null, 2))}`);
2163
+ } else lines.push(`${capitalize(record.role)}: ${cut(contentText(record.content))}`);
2164
+ }
2165
+ return lines.join("\n") || "(nothing happened)";
2166
+ }
2167
+ /** The judge's answer in one attempt: the text of its last reply. */
2168
+ function replyIn(records) {
2169
+ const assistant = lastOf(records, (record) => record.type === "message" && record.role === "assistant");
2170
+ if (!assistant) return void 0;
2171
+ return contentText(assistant.content) || void 0;
2172
+ }
2173
+ function text(value) {
2174
+ if (value === void 0) return "(none)";
2175
+ return typeof value === "string" ? value : JSON.stringify(value, null, 2);
2176
+ }
2177
+ function cut(value) {
2178
+ return value.length > MAX_PART ? `${value.slice(0, MAX_PART)}… (${value.length - MAX_PART} more characters)` : value;
2179
+ }
2180
+ function preview(value) {
2181
+ const shown = text(value).replace(/\s+/g, " ");
2182
+ return shown.length > 300 ? `${shown.slice(0, 300)}…` : shown;
2183
+ }
2184
+ /** The last element matching, scanning from the end (no ES2023 lib here). */
2185
+ function lastOf(items, match) {
2186
+ for (let index = items.length - 1; index >= 0; index -= 1) if (match(items[index])) return items[index];
2187
+ }
2188
+ const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1);
2189
+ //#endregion
2190
+ //#region ../runner/src/evaluate-rule.ts
2191
+ const failure$1 = (name, cause) => new RuleExecutionError({
2192
+ name,
2193
+ cause,
2194
+ message: cause instanceof Error ? cause.message : String(cause)
2195
+ });
2196
+ /** Both placements call the same rule executor; only input timing and persistence differ. */
2197
+ function evaluateRule(rule, input, defaultJudge, placement) {
2198
+ return Effect.gen(function* () {
2199
+ const judgeAgent = rule.kind === "judge" ? rule.agent ?? defaultJudge : void 0;
2200
+ if (rule.kind === "judge" && !judgeAgent) return yield* Effect.fail(failure$1(rule.name, /* @__PURE__ */ new Error("This judge has no agent: pass judge(name, { agent }) or set judge in evalkit.config.ts")));
2201
+ const evaluated = rule.kind === "predicate" ? yield* Effect.tryPromise({
2202
+ try: async () => rule.run(input),
2203
+ catch: (cause) => failure$1(rule.name, cause)
2204
+ }) : (yield* runJudgeAgent(rule, judgeAgent, input, placement)).value;
2205
+ const score = yield* Effect.try({
2206
+ try: () => normalizeScore(evaluated, rule.passAt, rule.kind === "predicate" ? rule.criteria : void 0),
2207
+ catch: (cause) => failure$1(rule.name, cause)
2208
+ });
2209
+ const validated = yield* Schema.decodeUnknown(ScoreResultSchema)({
2210
+ name: rule.name,
2211
+ kind: rule.kind,
2212
+ durationMs: 0,
2213
+ ...score
2214
+ }).pipe(Effect.mapError((cause) => failure$1(rule.name, cause)));
2215
+ if (validated.skipped) return {
2216
+ skipped: true,
2217
+ ...validated.explanation === void 0 ? {} : { explanation: validated.explanation }
2218
+ };
2219
+ const { name: _name, kind: _kind, durationMs: _duration, ...result } = validated;
2220
+ return result;
2221
+ });
2222
+ }
2223
+ //#endregion
2224
+ //#region ../runner/src/turn.ts
2225
+ /** Freeze one turn's records at send() completion, before checkpoint events arrive. */
2226
+ function turnView(records, userStepIndex) {
2227
+ const tree = records.filter(isTreeRecord);
2228
+ const assistantMessages = tree.filter((record) => record.type === "message" && record.role === "assistant");
2229
+ const calls = callsIn(tree);
2230
+ const resultCount = /* @__PURE__ */ new Map();
2231
+ for (const record of tree) if (record.type === "message" && record.role === "tool") resultCount.set(record.toolCallId, (resultCount.get(record.toolCallId) ?? 0) + 1);
2232
+ const last = assistantMessages.at(-1);
2233
+ const lastText = last ? contentText(last.content) : void 0;
2234
+ return {
2235
+ userStepIndex,
2236
+ records: tree,
2237
+ assistantMessages,
2238
+ toolCalls: calls.map(({ message, call, result }) => {
2239
+ const ambiguous = calls.filter((entry) => entry.call.id === call.id).length !== 1 || (resultCount.get(call.id) ?? 0) > 1;
2240
+ return {
2241
+ messageId: message.id,
2242
+ id: call.id,
2243
+ name: call.name,
2244
+ arguments: toJson(call.arguments ?? {}, "tool arguments"),
2245
+ resultObservation: ambiguous ? "ambiguous" : result ? "observed" : "absent",
2246
+ ...!ambiguous && result ? { result: toJson(toolResultValue(result), "tool result") } : {}
2247
+ };
2248
+ }),
2249
+ ...lastText ? { lastAssistantText: lastText } : {}
2250
+ };
2251
+ }
2252
+ /** Exact JSON matching: object keys do not matter, array order does. */
2253
+ function matchingToolCall(turn, name, args) {
2254
+ const expected = args === void 0 ? void 0 : canonicalParameters(args);
2255
+ return turn.toolCalls.find((call) => call.name === name && (expected === void 0 || canonicalParameters(call.arguments) === expected));
2256
+ }
2257
+ //#endregion
2258
+ //#region ../runner/src/checkpoint.ts
2259
+ const failure = (step, name, cause) => new CheckpointExecutionError({
2260
+ step,
2261
+ name,
2262
+ cause,
2263
+ message: cause instanceof Error ? cause.message : String(cause)
2264
+ });
2265
+ /** An assertion failure is a successful Effect containing a failed result; only execution errors enter the error channel. */
2266
+ function evaluateCheckpoint(step, index, input, now, judgeAgent) {
2267
+ const annotations = {
2268
+ evalId: input.context.evalId,
2269
+ runId: input.context.runId,
2270
+ step: index,
2271
+ checkpoint: step.name
2272
+ };
2273
+ return Effect.gen(function* () {
2274
+ const started = now();
2275
+ yield* Effect.logDebug("checkpoint started");
2276
+ const match = step.kind === "expect-tool-call" ? yield* Effect.try({
2277
+ try: () => matchingToolCall(input.turn, step.expected.name, step.expected.arguments),
2278
+ catch: (cause) => failure(index, step.name, cause)
2279
+ }) : void 0;
2280
+ const score = step.kind === "expect-tool-call" ? yield* Effect.try({
2281
+ try: () => normalizeScore(Boolean(match)),
2282
+ catch: (cause) => failure(index, step.name, cause)
2283
+ }) : yield* evaluateRule(step, input, judgeAgent, "transcript").pipe(Effect.mapError((cause) => failure(index, step.name, cause)));
2284
+ const status = score.skipped ? "skipped" : score.passed ? "passed" : "failed";
2285
+ const { skipped: _, ...recorded } = score;
2286
+ const result = yield* Schema.decodeUnknown(CheckpointResultSchema)({
2287
+ step: index,
2288
+ kind: step.kind,
2289
+ name: step.name,
2290
+ status,
2291
+ durationMs: now().getTime() - started.getTime(),
2292
+ ...recorded,
2293
+ ...match ? { matchedToolCall: {
2294
+ messageId: match.messageId,
2295
+ id: match.id
2296
+ } } : {}
2297
+ }).pipe(Effect.mapError((cause) => failure(index, step.name, cause)));
2298
+ yield* Effect.logDebug("checkpoint completed").pipe(Effect.annotateLogs({ status: result.status }));
2299
+ return {
2300
+ ...result,
2301
+ status
2302
+ };
2303
+ }).pipe(Effect.tapError((error) => Effect.logDebug("checkpoint execution error").pipe(Effect.annotateLogs({ errorName: error._tag }))), Effect.annotateLogs(annotations));
2304
+ }
2305
+ //#endregion
2306
+ //#region ../runner/src/effect-boundary.ts
2307
+ /** Keep tagged Effect failures intact when bridging into the legacy async reporting lifecycle. Defects still reject. */
2308
+ async function runBoundary(effect) {
2309
+ const outcome = await Effect.runPromise(Effect.either(effect));
2310
+ if (Either.isLeft(outcome)) throw outcome.left;
2311
+ return outcome.right;
2312
+ }
2313
+ //#endregion
2314
+ //#region ../runner/src/transcript.ts
2315
+ async function executeTranscript(definition, session, input) {
2316
+ const { now, emitRunner, progress } = input;
2317
+ let turn = 0;
2318
+ for (const [index, step] of definition.transcript.entries()) {
2319
+ if (input.signal.aborted) throw abortError(input.signal);
2320
+ const message = step.kind === "user" ? userMessage(step, index, input) : void 0;
2321
+ await emitRunner({
2322
+ kind: "transcript-step-started",
2323
+ step: index,
2324
+ ...message === void 0 ? {} : { message },
2325
+ timestamp: now().toISOString()
2326
+ });
2327
+ if (message !== void 0) {
2328
+ const cursor = input.transcript.length;
2329
+ turn++;
2330
+ input.transcript.scriptUser(message);
2331
+ const result = await sendUserTurn(session, message, input.signal);
2332
+ if (result !== void 0) input.transcript.writeTurnResult(result, turn);
2333
+ input.transcript.endTurn();
2334
+ progress.lastTurn = turnView(input.transcript.since(cursor), index);
2335
+ } else if (step.kind === "predicate" || step.kind === "judge" || step.kind === "expect-tool-call") {
2336
+ const result = await runCheckpoint(definition, step, index, input);
2337
+ progress.checkpoints.push(result);
2338
+ await emitRunner({
2339
+ kind: "checkpoint-completed",
2340
+ step: index,
2341
+ status: result.status,
2342
+ timestamp: now().toISOString()
2343
+ });
2344
+ await emitRunner({
2345
+ kind: "transcript-step-completed",
2346
+ step: index,
2347
+ timestamp: now().toISOString()
2348
+ });
2349
+ if (result.status === "failed" && definition.policy?.failfast) {
2350
+ progress.stoppedEarly = true;
2351
+ await skipRemainingSteps(definition, index, input);
2352
+ break;
2353
+ }
2354
+ continue;
2355
+ } else throw new Error(`Transcript step kind "${step.kind}" is not executable yet`);
2356
+ await emitRunner({
2357
+ kind: "transcript-step-completed",
2358
+ step: index,
2359
+ timestamp: now().toISOString()
2360
+ });
2361
+ }
2362
+ }
2363
+ /** A computed message sees the parameters and candidate data the agent also sees. */
2364
+ function userMessage(step, index, { context, randomSeed }) {
2365
+ if (typeof step.message === "string") return step.message;
2366
+ const message = step.message({
2367
+ parameters: context.parameters ?? {},
2368
+ data: context.data,
2369
+ seed: randomSeed,
2370
+ ...context.batch ? { batch: context.batch } : {}
2371
+ });
2372
+ if (typeof message !== "string" || !message.trim()) throw new Error(`The user message at step ${index + 1} must compute a non-empty string`);
2373
+ return message;
2374
+ }
2375
+ async function sendUserTurn(session, message, signal) {
2376
+ return runBoundary(Effect.tryPromise({
2377
+ try: () => unlessAborted(() => session.send(message), signal, LIMIT_GRACE_MS),
2378
+ catch: (cause) => cause instanceof ReportError || cause instanceof CancelledError || cause instanceof LimitReachedError ? cause : new AutExecutionError({
2379
+ cause,
2380
+ message: cause instanceof Error ? cause.message : String(cause)
2381
+ })
2382
+ }));
2383
+ }
2384
+ async function runCheckpoint(definition, step, index, input) {
2385
+ const { now, emitRunner, progress } = input;
2386
+ if (!progress.lastTurn) throw new Error("Checkpoint requires a preceding user step");
2387
+ const started = now();
2388
+ await emitRunner({
2389
+ kind: "checkpoint-started",
2390
+ step: index,
2391
+ name: step.name,
2392
+ timestamp: started.toISOString()
2393
+ });
2394
+ const tracked = trackAttachments(input.attach);
2395
+ return runBoundary(evaluateCheckpoint(step, index, {
2396
+ context: input.context,
2397
+ artifacts: input.artifacts,
2398
+ transcript: input.transcript.transcript,
2399
+ subagents: input.transcript.subagents,
2400
+ runEvents: [...input.runEvents],
2401
+ turn: progress.lastTurn,
2402
+ ...turnOutput(progress.lastTurn),
2403
+ data: input.data,
2404
+ checkpoints: [...progress.checkpoints],
2405
+ checkpoint: checkpointLookup(definition.transcript, [...progress.checkpoints]),
2406
+ attach: tracked.attach
2407
+ }, now, input.defaultJudge)).then((result) => ({
2408
+ ...result,
2409
+ ...attachedFields(tracked.attached)
2410
+ })).catch(async (error) => {
2411
+ const recorded = recordError(error);
2412
+ progress.checkpoints.push({
2413
+ step: index,
2414
+ kind: step.kind,
2415
+ name: step.name,
2416
+ status: "error",
2417
+ durationMs: now().getTime() - started.getTime(),
2418
+ error: recorded,
2419
+ ...attachedFields(tracked.attached)
2420
+ });
2421
+ try {
2422
+ await emitRunner({
2423
+ kind: "checkpoint-error",
2424
+ step: index,
2425
+ error: recorded,
2426
+ timestamp: now().toISOString()
2427
+ });
2428
+ } catch {}
2429
+ throw error;
2430
+ });
2431
+ }
2432
+ async function skipRemainingSteps(definition, after, { now, emitRunner, progress }) {
2433
+ for (let index = after + 1; index < definition.transcript.length; index++) {
2434
+ await emitRunner({
2435
+ kind: "transcript-step-skipped",
2436
+ step: index,
2437
+ timestamp: now().toISOString()
2438
+ });
2439
+ const step = definition.transcript[index];
2440
+ if (step.kind === "predicate" || step.kind === "judge" || step.kind === "expect-tool-call") progress.checkpoints.push({
2441
+ step: index,
2442
+ kind: step.kind,
2443
+ name: step.name,
2444
+ status: "skipped"
2445
+ });
2446
+ }
2447
+ }
2448
+ /** The scorer-facing `output`: the text of the turn's last assistant message. */
2449
+ function turnOutput(turn) {
2450
+ return turn?.lastAssistantText === void 0 ? {} : { output: turn.lastAssistantText };
2451
+ }
2452
+ //#endregion
2453
+ //#region ../runner/src/final-scoring.ts
2454
+ function scoreSummary(results, checkpoints, skippedScorers, metrics = []) {
2455
+ const valid = results.filter((result) => result.value !== void 0 && !result.skipped);
2456
+ const hasError = results.some((result) => result.error !== void 0);
2457
+ return {
2458
+ results,
2459
+ ...metrics.length ? { metrics } : {},
2460
+ ...checkpoints.length ? { checkpoints } : {},
2461
+ ...skippedScorers.length ? { skippedScorers } : {},
2462
+ ...valid.length === 0 ? {} : { overall: valid.reduce((sum, result) => sum + (result.value ?? 0), 0) / valid.length },
2463
+ passed: !hasError && results.every((result) => result.skipped || result.passed === true) && checkpoints.every((result) => result.status === "passed" || result.status === "skipped") && !skippedScorers.length
2464
+ };
2465
+ }
2466
+ /** What final scorers and metrics receive. */
2467
+ function scoringContext(input) {
2468
+ return {
2469
+ context: input.context,
2470
+ artifacts: input.workspace.artifacts,
2471
+ transcript: input.agentTranscript.transcript,
2472
+ subagents: input.agentTranscript.subagents,
2473
+ runEvents: [...input.runEvents],
2474
+ ...input.lastTurn ? { turn: input.lastTurn } : {},
2475
+ ...turnOutput(input.lastTurn),
2476
+ data: input.data,
2477
+ checkpoints: input.checkpoints,
2478
+ checkpoint: checkpointLookup(input.transcript, input.checkpoints),
2479
+ attach: input.attach
2480
+ };
2481
+ }
2482
+ /** Append completed scores to the supplied arrays, even when a later scorer or report write fails. */
2483
+ async function evaluateFinalScorers(definition, input) {
2484
+ const { context, workspace, results, skippedScorers, now, emitRunner } = input;
2485
+ for (const scorer of definition.scoring) {
2486
+ if (input.cancelled || input.reportingFailed || !context || !workspace || (input.primaryError || input.stoppedEarly || input.limited) && (!("supportsPartial" in scorer) || !scorer.supportsPartial)) {
2487
+ skippedScorers.push(scorer.name);
2488
+ continue;
2489
+ }
2490
+ const started = now();
2491
+ await emitRunner({
2492
+ kind: "scorer-started",
2493
+ scorer: scorer.name,
2494
+ timestamp: started.toISOString()
2495
+ });
2496
+ let result;
2497
+ const tracked = trackAttachments(input.attach);
2498
+ try {
2499
+ const score = await runBoundary(evaluateRule(scorer, scoringContext({
2500
+ ...input,
2501
+ attach: tracked.attach,
2502
+ context,
2503
+ workspace,
2504
+ transcript: definition.transcript
2505
+ }), input.defaultJudge, "scoring").pipe(Effect.mapError((cause) => new ScoringError({
2506
+ cause,
2507
+ message: cause.message
2508
+ }))));
2509
+ result = {
2510
+ name: scorer.name,
2511
+ kind: scorer.kind,
2512
+ durationMs: now().getTime() - started.getTime(),
2513
+ ...score,
2514
+ ...attachedFields(tracked.attached)
2515
+ };
2516
+ results.push(result);
2517
+ } catch (error) {
2518
+ const recorded = recordError(error);
2519
+ results.push({
2520
+ name: scorer.name,
2521
+ kind: scorer.kind,
2522
+ durationMs: now().getTime() - started.getTime(),
2523
+ error: recorded,
2524
+ passed: false,
2525
+ ...attachedFields(tracked.attached)
2526
+ });
2527
+ await emitRunner({
2528
+ kind: "scorer-failed",
2529
+ scorer: scorer.name,
2530
+ error: recorded,
2531
+ timestamp: now().toISOString()
2532
+ });
2533
+ continue;
2534
+ }
2535
+ await emitRunner({
2536
+ kind: "scorer-completed",
2537
+ scorer: scorer.name,
2538
+ ...result.skipped ? { skipped: true } : { value: result.value ?? 0 },
2539
+ timestamp: now().toISOString()
2540
+ });
2541
+ }
2542
+ }
2543
+ //#endregion
2544
+ //#region ../runner/src/metrics.ts
2545
+ /**
2546
+ * What EvalKit measures for every run: from its transcript and events, the
2547
+ * agent's time (from the first record or event to the last one before
2548
+ * final scoring), its token metrics when it reported usage (`tokens`
2549
+ * without cache reads, `cached tokens`, `output tokens`, `cache hit
2550
+ * rate`), its steps (assistant messages) and tool calls, sub-agents'
2551
+ * included; from its scores and checkpoints, the tokens its judges used,
2552
+ * kept apart from the agent's.
2553
+ */
2554
+ function builtInMetrics(transcript, runEvents, verdicts = [], subagents = []) {
2555
+ const times = [...transcript.records.map((record) => record.time), ...runEvents.filter((event) => !event.kind.startsWith("scorer-")).map((event) => event.timestamp)].map((time) => Date.parse(time)).filter((time) => !Number.isNaN(time));
2556
+ const metrics = [];
2557
+ if (times.length) metrics.push({
2558
+ name: "agent time",
2559
+ value: Math.max(...times) - Math.min(...times),
2560
+ unit: "ms",
2561
+ better: "lower",
2562
+ builtIn: true
2563
+ });
2564
+ const all = [transcript, ...subagents];
2565
+ metrics.push(...usageMetrics(all.flatMap(reportedUsages)));
2566
+ let steps = 0;
2567
+ let calls = 0;
2568
+ for (const record of all.flatMap((entry) => entry.records)) if (record.type === "message" && record.role === "assistant") {
2569
+ steps += 1;
2570
+ calls += record.content.filter((block) => block.type === "tool_call").length;
2571
+ }
2572
+ metrics.push({
2573
+ name: "steps",
2574
+ value: steps,
2575
+ better: "lower",
2576
+ builtIn: true
2577
+ }, {
2578
+ name: "tool calls",
2579
+ value: calls,
2580
+ builtIn: true
2581
+ });
2582
+ const judgeUsages = verdicts.flatMap((verdict) => verdict.judge?.transcript ? reportedUsages(verdict.judge.transcript) : verdict.judge?.usage ? [verdict.judge.usage] : []);
2583
+ const judgeTokens = usageMetrics(judgeUsages).find((metric) => metric.name === "tokens");
2584
+ if (judgeTokens) metrics.push({
2585
+ ...judgeTokens,
2586
+ name: "judge tokens"
2587
+ });
2588
+ return metrics;
2589
+ }
2590
+ /** Runs the scenario's metrics; a failing metric records its error and the rest still run. */
2591
+ async function evaluateMetrics(definition, context) {
2592
+ const results = [];
2593
+ for (const entry of definition.metrics ?? []) {
2594
+ const shape = {
2595
+ name: entry.name,
2596
+ ...entry.unit ? { unit: entry.unit } : {},
2597
+ ...entry.better ? { better: entry.better } : {}
2598
+ };
2599
+ try {
2600
+ const value = await entry.run(context);
2601
+ if (value !== void 0 && !Number.isFinite(value)) throw new Error(`Metric ${entry.name} must return a finite number or undefined; got ${String(value)}`);
2602
+ results.push({
2603
+ ...shape,
2604
+ ...value === void 0 ? {} : { value }
2605
+ });
2606
+ } catch (error) {
2607
+ results.push({
2608
+ ...shape,
2609
+ error: recordError(error)
2610
+ });
2611
+ }
2612
+ }
2613
+ return results;
2614
+ }
2615
+ //#endregion
2616
+ //#region ../runner/src/local-report-store.ts
2617
+ const MANIFEST_FILE = "manifest.json";
2618
+ /** The agent's session, ATXF 0.1 (RFD0009). */
2619
+ const TRANSCRIPT_FILE = "transcript.atxf.jsonl";
2620
+ /** EvalKit's events, each placed in the transcript by `at`. */
2621
+ const RUN_EVENTS_FILE = "run-events.jsonl";
2622
+ /** Sub-agent transcripts, one file each, linked from the agent's. */
2623
+ const SUBAGENTS_DIR = "transcripts";
2624
+ /** A sub-agent transcript's file name: its ID, made safe for any file system. */
2625
+ function subagentFile(id) {
2626
+ return `${encodeURIComponent(id).replace(/\./g, "%2E")}.atxf.jsonl`;
2627
+ }
2628
+ /** The project's evals and suites when the run started. */
2629
+ const CATALOG_FILE = "catalog.json";
2630
+ async function writeJson(filePath, value) {
2631
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
2632
+ }
2633
+ async function replaceJson(filePath, value) {
2634
+ const temporary = `${filePath}.${randomUUID()}.tmp`;
2635
+ await writeJson(temporary, value);
2636
+ await rename(temporary, filePath);
2637
+ }
2638
+ function assertPathSegment(value, name) {
2639
+ if (!value || value === "." || value === ".." || value.includes("/") || value.includes("\\")) throw new Error(`${name} must be a single relative path segment`);
2640
+ }
2641
+ function artifactPath(root, value) {
2642
+ if (!value || path.isAbsolute(value)) throw new Error(`Artifact path must be a non-empty relative path: ${value}`);
2643
+ const normalized = path.normalize(value);
2644
+ if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) throw new Error(`Artifact path escapes its root: ${value}`);
2645
+ return path.join(root, normalized);
2646
+ }
2647
+ var LocalRunWriter = class {
2648
+ location;
2649
+ directory;
2650
+ metadata;
2651
+ constructor(location, directory, metadata) {
2652
+ this.location = location;
2653
+ this.directory = directory;
2654
+ this.metadata = metadata;
2655
+ }
2656
+ appendEvent(event) {
2657
+ return appendFile(path.join(this.directory, RUN_EVENTS_FILE), `${JSON.stringify(Schema.encodeSync(RunEventSchema)(event))}\n`);
2658
+ }
2659
+ appendTranscript(line) {
2660
+ return appendFile(path.join(this.directory, TRANSCRIPT_FILE), `${JSON.stringify(line)}\n`);
2661
+ }
2662
+ /** Written whole, to a temporary file first, so a reader never sees half of one. */
2663
+ async writeSubagent(id, text) {
2664
+ const directory = path.join(this.directory, SUBAGENTS_DIR);
2665
+ await mkdir(directory, { recursive: true });
2666
+ const file = path.join(directory, subagentFile(id));
2667
+ const temporary = `${file}.${randomUUID()}.tmp`;
2668
+ await writeFile(temporary, text);
2669
+ await rename(temporary, file);
2670
+ }
2671
+ writeScores(scoring) {
2672
+ return writeJson(path.join(this.directory, "scoring.json"), Schema.encodeSync(RunScoringSchema)(scoring));
2673
+ }
2674
+ async writeArtifact(relativePath, data) {
2675
+ const target = artifactPath(path.join(this.directory, "artifacts"), relativePath);
2676
+ await mkdir(path.dirname(target), { recursive: true });
2677
+ await writeFile(target, data);
2678
+ }
2679
+ async finalize(summary) {
2680
+ await writeJson(path.join(this.directory, "summary.json"), Schema.encodeSync(RunSummarySchema)(summary));
2681
+ await replaceJson(path.join(this.directory, MANIFEST_FILE), {
2682
+ ...Schema.encodeSync(RunMetadataSchema)(this.metadata),
2683
+ status: summary.status
2684
+ });
2685
+ }
2686
+ };
2687
+ /** Persists each run as a local tree rooted below `rootDirectory`. */
2688
+ function localReportStore(rootDirectory) {
2689
+ return { async startRun(metadata, context) {
2690
+ assertPathSegment(metadata.runId, "runId");
2691
+ const directory = path.resolve(rootDirectory, metadata.runId);
2692
+ await mkdir(directory, { recursive: true });
2693
+ if (context?.catalog) await writeJson(path.join(directory, CATALOG_FILE), context.catalog);
2694
+ await writeJson(path.join(directory, MANIFEST_FILE), {
2695
+ ...Schema.encodeSync(RunMetadataSchema)(metadata),
2696
+ status: "running"
2697
+ });
2698
+ return new LocalRunWriter(path.relative(process.cwd(), directory), directory, metadata);
2699
+ } };
2700
+ }
2701
+ //#endregion
2702
+ //#region ../core/src/run-events.ts
2703
+ /** Decodes a run's `run-events.jsonl`; a malformed line is an error naming its line number. */
2704
+ function parseRunEventsJsonl(contents) {
2705
+ const events = [];
2706
+ for (const [index, line] of contents.split(/\r?\n/).entries()) {
2707
+ if (!line.trim()) continue;
2708
+ try {
2709
+ events.push(Schema.decodeUnknownSync(RunEventSchema)(JSON.parse(line)));
2710
+ } catch (cause) {
2711
+ throw new Error(`Invalid run event at line ${index + 1}`, { cause });
2712
+ }
2713
+ }
2714
+ return events;
2715
+ }
2716
+ //#endregion
2717
+ //#region ../runner/src/local-report-reader.ts
2718
+ /** The local report boundary for CLI, dashboard, and CI consumers (schema version 7). */
2719
+ async function decode(file, schema) {
2720
+ return Schema.decodeUnknownSync(schema)(JSON.parse(await readFile(file, "utf8")));
2721
+ }
2722
+ const readRunManifest = (root, runId) => decode(join(root, runId, "manifest.json"), RunManifestSchema);
2723
+ const readRunSummary = (root, runId) => decode(join(root, runId, "summary.json"), RunSummarySchema);
2724
+ const readRunScoring = (root, runId) => decode(join(root, runId, "scoring.json"), RunScoringSchema);
2725
+ /** EvalKit's events for a run, in order. */
2726
+ async function readRunEvents(root, runId) {
2727
+ return parseRunEventsJsonl(await readFile(join(root, runId, RUN_EVENTS_FILE), "utf8"));
2728
+ }
2729
+ /** A run's sub-agent transcripts, by transcript ID; none when it kept none. */
2730
+ async function readRunSubagents(root, runId) {
2731
+ const directory = join(root, runId, SUBAGENTS_DIR);
2732
+ let files;
2733
+ try {
2734
+ files = await readdir(directory);
2735
+ } catch {
2736
+ return {};
2737
+ }
2738
+ const subagents = {};
2739
+ for (const file of files.filter((name) => name.endsWith(".atxf.jsonl"))) {
2740
+ const transcript = parseTranscript(await readFile(join(directory, file), "utf8"));
2741
+ subagents[transcript.header.id] = transcript;
2742
+ }
2743
+ return subagents;
2744
+ }
2745
+ /**
2746
+ * The agent's ATXF transcript for a run. A running run's last line may be
2747
+ * half written; it is left out until it is complete.
2748
+ */
2749
+ async function readRunTranscript(root, runId) {
2750
+ const text = await readFile(join(root, runId, TRANSCRIPT_FILE), "utf8");
2751
+ return parseTranscript(text.slice(0, text.lastIndexOf("\n") + 1));
2752
+ }
2753
+ /**
2754
+ * True when a run's owning process is known to be gone: it ran on this host
2755
+ * and its PID no longer exists. Runs from other hosts are never judged.
2756
+ */
2757
+ function ownerIsGone(owner) {
2758
+ if (!owner || owner.hostname !== os.hostname()) return false;
2759
+ if (owner.pid === process.pid) return false;
2760
+ try {
2761
+ process.kill(owner.pid, 0);
2762
+ return false;
2763
+ } catch (error) {
2764
+ return error.code === "ESRCH";
2765
+ }
2766
+ }
2767
+ //#endregion
2768
+ //#region ../runner/src/matrix.ts
2769
+ /** Bounded workers pull cells from the iterator; no Cartesian job array is allocated. */
2770
+ function runMatrix(matrix, options) {
2771
+ return Effect.gen(function* () {
2772
+ const concurrency = options.concurrency ?? 4;
2773
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1) throw new Error("Concurrency must be a positive integer");
2774
+ const count = matrix.count(options.selection);
2775
+ const iterator = matrix.cells(options.selection);
2776
+ const summary = {
2777
+ cells: 0,
2778
+ runs: 0,
2779
+ passed: 0,
2780
+ failed: 0,
2781
+ limited: 0,
2782
+ cancelled: 0
2783
+ };
2784
+ yield* Effect.all(Array.from({ length: Math.min(count, concurrency) }, () => Effect.gen(function* () {
2785
+ while (!options.signal?.aborted) {
2786
+ const next = iterator.next();
2787
+ if (next.done) return;
2788
+ const cell = next.value;
2789
+ const runtime = options.runtime ?? [
2790
+ "local",
2791
+ "remote",
2792
+ "sandbox"
2793
+ ].find((name) => agentRuntimes(cell.eval.agent)[name]);
2794
+ const suiteId = options.suiteId ?? options.suiteFor?.(cell);
2795
+ const runs = yield* runEval(cell.eval, {
2796
+ ...options,
2797
+ ...suiteId ? { suiteId } : {},
2798
+ concurrency: 1,
2799
+ runtime,
2800
+ parameters: cell.parameters,
2801
+ matrix: {
2802
+ id: matrix.id,
2803
+ cellKey: cell.key
2804
+ }
2805
+ });
2806
+ summary.cells++;
2807
+ for (const run of runs) {
2808
+ summary.runs++;
2809
+ if (run.status === "cancelled") summary.cancelled++;
2810
+ else if (run.status === "passed") summary.passed++;
2811
+ else if (run.status === "limited") summary.limited++;
2812
+ else summary.failed++;
2813
+ }
2814
+ if (options.onResult) yield* Effect.tryPromise(async () => options.onResult(cell, runs));
2815
+ }
2816
+ })), { concurrency });
2817
+ return summary;
2818
+ });
2819
+ }
2820
+ //#endregion
2821
+ //#region ../runner/src/upgrade.ts
2822
+ /** A new run being written. */
2823
+ const TEMPORARY = ".upgrade-";
2824
+ /** An old run set aside while its replacement, with the same ID, moves in. */
2825
+ const RETIRED = ".upgraded-";
2826
+ const PER_RUN_FILES = ["archived.json"];
2827
+ /**
2828
+ * Rewrites results from report schema versions 2 to 5 into version 6, in
2829
+ * place (RFD0008). An old run of one trial (most of them) becomes a run with
2830
+ * the same ID, so links to it keep working. An old run of several trials
2831
+ * becomes runs with the trials' IDs, in a batch with the old run's ID.
2832
+ *
2833
+ * Each new run is written under a temporary name and renamed into place;
2834
+ * artifacts are moved, not copied, as the last step before that rename. An
2835
+ * old run is removed only after its new runs exist, and one replaced under
2836
+ * the same ID is set aside first, so a crash at any point leaves the old run
2837
+ * or complete new ones, and re-running finishes the job. Version 6 runs are
2838
+ * left alone. Then every version 6 run is converted to version 7, its
2839
+ * trajectory into an ATXF transcript (RFD0009).
2840
+ */
2841
+ async function upgradeReports(root, options = {}) {
2842
+ const report = {
2843
+ upgraded: 0,
2844
+ runs: 0,
2845
+ batches: 0,
2846
+ running: [],
2847
+ transcripts: 0,
2848
+ failed: []
2849
+ };
2850
+ let entries;
2851
+ try {
2852
+ entries = await readdir(root);
2853
+ } catch {
2854
+ return report;
2855
+ }
2856
+ if (!options.dryRun) await finishReplacements(root, entries);
2857
+ for (const entry of entries) {
2858
+ if (entry.startsWith(".")) continue;
2859
+ const directory = join(root, entry);
2860
+ const manifest = await readJson(join(directory, "manifest.json"));
2861
+ if (!manifest || !UPGRADABLE_SCHEMA_VERSIONS.includes(manifest.schemaVersion)) continue;
2862
+ const summary = await readJson(join(directory, "summary.json"));
2863
+ if (!summary && !ownerIsGone(manifest.process)) {
2864
+ report.running.push(entry);
2865
+ continue;
2866
+ }
2867
+ const trials = await trialIds(directory);
2868
+ report.upgraded += 1;
2869
+ report.runs += Math.max(trials.length, 1);
2870
+ if (trials.length > 1) report.batches += 1;
2871
+ if (options.dryRun) {
2872
+ report.transcripts += Math.max(trials.length, 1);
2873
+ continue;
2874
+ }
2875
+ await upgradeRun(root, entry, manifest, summary, trials);
2876
+ }
2877
+ if (!options.dryRun) await removeLeftovers(root);
2878
+ for (const entry of await readdir(root)) {
2879
+ if (entry.startsWith(".")) continue;
2880
+ const directory = join(root, entry);
2881
+ const manifest = await readJson(join(directory, "manifest.json"));
2882
+ if (manifest?.schemaVersion !== 6) continue;
2883
+ if (!await readJson(join(directory, "summary.json")) && !ownerIsGone(manifest.process)) {
2884
+ if (!report.running.includes(entry)) report.running.push(entry);
2885
+ continue;
2886
+ }
2887
+ if (options.dryRun) {
2888
+ report.transcripts += 1;
2889
+ continue;
2890
+ }
2891
+ try {
2892
+ await transcriptRun(directory, entry);
2893
+ report.transcripts += 1;
2894
+ } catch (error) {
2895
+ report.failed.push({
2896
+ runId: entry,
2897
+ reason: error instanceof Error ? error.message : String(error)
2898
+ });
2899
+ }
2900
+ }
2901
+ return report;
2902
+ }
2903
+ /** Where a version 6 run's own files wait until its version 7 files are in place. */
2904
+ const V6_BACKUP = ".v6";
2905
+ const V6_FILES = [
2906
+ "manifest.json",
2907
+ "trajectory.jsonl",
2908
+ "scoring.json",
2909
+ "summary.json"
2910
+ ];
2911
+ /**
2912
+ * Converts one version 6 run to version 7 in place (RFD0009): its
2913
+ * trajectory becomes an ATXF transcript, sealed, plus `run-events.jsonl`;
2914
+ * its scoring and summary point into the transcript. The old files are
2915
+ * copied to `.v6/` first and read from there, so a conversion a crash
2916
+ * interrupted starts again from them; the manifest's version changes last.
2917
+ */
2918
+ async function transcriptRun(directory, runId) {
2919
+ const backup = join(directory, V6_BACKUP);
2920
+ if (!await exists(backup)) {
2921
+ await mkdir(`${backup}.tmp`, { recursive: true });
2922
+ for (const file of V6_FILES) if (await exists(join(directory, file))) await cp(join(directory, file), join(`${backup}.tmp`, file));
2923
+ await rename(`${backup}.tmp`, backup);
2924
+ }
2925
+ const manifest = await readJson(join(backup, "manifest.json"));
2926
+ const events = (await readText(join(backup, "trajectory.jsonl")) ?? "").split("\n").filter((line) => line.trim()).map((line, index) => {
2927
+ try {
2928
+ return JSON.parse(line);
2929
+ } catch {
2930
+ throw new Error(`trajectory.jsonl line ${index + 1} is not JSON`);
2931
+ }
2932
+ });
2933
+ const startedAt = typeof manifest.startedAt === "string" ? manifest.startedAt : (/* @__PURE__ */ new Date(0)).toISOString();
2934
+ const aut = manifest.aut;
2935
+ const converted = convertTrajectory(events, {
2936
+ runId,
2937
+ ...typeof manifest.evalId === "string" ? { evalId: manifest.evalId } : {},
2938
+ startedAt,
2939
+ ...aut ? { agent: aut } : {}
2940
+ });
2941
+ const lastTime = converted.transcript.records.at(-1)?.time ?? converted.transcript.header.time;
2942
+ const transcript = addIntegrity(converted.transcript, { seal: {
2943
+ id: "seal",
2944
+ time: lastTime
2945
+ } });
2946
+ const text = serializeTranscript(transcript);
2947
+ const invalid = validateTranscript(text).diagnostics.filter((d) => d.severity === "error");
2948
+ if (invalid.length) throw new Error(`its converted transcript is not valid ATXF: ${invalid.slice(0, 3).map((d) => `line ${d.line}: ${d.message}`).join("; ")}`);
2949
+ const decode = (schema, value, file) => {
2950
+ try {
2951
+ return Schema.decodeUnknownSync(schema)(value);
2952
+ } catch (error) {
2953
+ const issue = ParseResult.isParseError(error) ? ParseResult.ArrayFormatter.formatErrorSync(error)[0] : void 0;
2954
+ throw new Error(issue ? `its converted ${file} is not valid at ${issue.path.join(".") || "(root)"}: ${issue.message}` : `its converted ${file} is not valid: ${error instanceof Error ? error.message : String(error)}`);
2955
+ }
2956
+ };
2957
+ const scoringIn = await readJson(join(backup, "scoring.json"));
2958
+ const scoring = scoringIn ? decode(RunScoringSchema, upgradeScoring(scoringIn, {
2959
+ runId,
2960
+ startedAt
2961
+ }, converted.recordOfEvent, converted.transcript), "scoring.json") : void 0;
2962
+ const summaryIn = await readJson(join(backup, "summary.json"));
2963
+ const summary = summaryIn ? decode(RunSummarySchema, {
2964
+ ...summaryIn,
2965
+ ...isObject(summaryIn.scoring) ? { scoring: upgradeScoring(summaryIn.scoring, {
2966
+ runId,
2967
+ startedAt
2968
+ }, converted.recordOfEvent, converted.transcript) } : {}
2969
+ }, "summary.json") : void 0;
2970
+ await writeFile(join(directory, TRANSCRIPT_FILE), text);
2971
+ await writeFile(join(directory, RUN_EVENTS_FILE), converted.runEvents.map((event) => `${JSON.stringify(event)}\n`).join(""));
2972
+ if (scoring) await writeJsonFile(join(directory, "scoring.json"), scoring);
2973
+ if (summary) await writeJsonFile(join(directory, "summary.json"), summary);
2974
+ await writeJsonFile(join(directory, "manifest.json"), {
2975
+ ...manifest,
2976
+ schemaVersion: 7
2977
+ });
2978
+ await rm(join(directory, "trajectory.jsonl"), { force: true });
2979
+ await rm(backup, {
2980
+ recursive: true,
2981
+ force: true
2982
+ });
2983
+ }
2984
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2985
+ /**
2986
+ * Finishes same-ID replacements a crash interrupted: the old run was set
2987
+ * aside, and its replacement may or may not have moved in.
2988
+ */
2989
+ async function finishReplacements(root, entries) {
2990
+ for (const entry of entries) {
2991
+ if (!entry.startsWith(RETIRED)) continue;
2992
+ const id = entry.slice(10);
2993
+ const retired = join(root, entry);
2994
+ const directory = join(root, id);
2995
+ const replacement = join(root, `${TEMPORARY}${id}`);
2996
+ if (!await exists(directory)) await rename(await exists(replacement) ? replacement : retired, directory);
2997
+ await rm(retired, {
2998
+ recursive: true,
2999
+ force: true
3000
+ });
3001
+ }
3002
+ }
3003
+ /**
3004
+ * Temporary folders nothing will finish. One holding artifacts is kept: it
3005
+ * could be the only copy, and an old run would have moved them back.
3006
+ */
3007
+ async function removeLeftovers(root) {
3008
+ for (const entry of await readdir(root)) if (entry.startsWith(TEMPORARY) && !await exists(join(root, entry, "artifacts"))) await rm(join(root, entry), {
3009
+ recursive: true,
3010
+ force: true
3011
+ });
3012
+ }
3013
+ /** Swaps a fully written replacement in for the old run of the same ID. */
3014
+ async function replaceInPlace(root, id, replacement) {
3015
+ const retired = join(root, `${RETIRED}${id}`);
3016
+ await rename(join(root, id), retired);
3017
+ await rename(replacement, join(root, id));
3018
+ await rm(retired, {
3019
+ recursive: true,
3020
+ force: true
3021
+ });
3022
+ }
3023
+ async function upgradeRun(root, oldRunId, runManifest, runSummary, trials) {
3024
+ const directory = join(root, oldRunId);
3025
+ const catalog = await readJson(join(directory, "catalog.json"));
3026
+ const extras = await presentFiles(directory, PER_RUN_FILES);
3027
+ if (!trials.length) {
3028
+ const target = join(root, `${TEMPORARY}${oldRunId}`);
3029
+ await mkdir(target, { recursive: true });
3030
+ const status = runSummary ? "cancelled" : "running";
3031
+ await writeJsonFile(join(target, "manifest.json"), upgradeManifest({
3032
+ run: runManifest,
3033
+ runId: oldRunId,
3034
+ status
3035
+ }));
3036
+ if (runSummary) await writeJsonFile(join(target, "summary.json"), {
3037
+ status,
3038
+ endedAt: runSummary.endedAt,
3039
+ ...runSummary.durationMs === void 0 ? {} : { durationMs: runSummary.durationMs },
3040
+ ...runSummary.error ? { error: runSummary.error } : {}
3041
+ });
3042
+ await copyShared(directory, target, catalog, extras);
3043
+ await replaceInPlace(root, oldRunId, target);
3044
+ return;
3045
+ }
3046
+ const inPlace = trials.length === 1;
3047
+ for (const trialId of trials) {
3048
+ const runId = inPlace ? oldRunId : trialId;
3049
+ const destination = join(root, runId);
3050
+ if (!inPlace && await exists(join(destination, "manifest.json"))) continue;
3051
+ const source = join(directory, "trials", trialId);
3052
+ const trialManifest = await readJson(join(source, "manifest.json")) ?? {};
3053
+ const trialSummary = await readJson(join(source, "summary.json"));
3054
+ const passed = (trialSummary?.scoring)?.passed;
3055
+ const status = upgradeStatus(trialSummary?.status ?? trialManifest.status, passed);
3056
+ const index = typeof trialManifest.trialIndex === "number" ? trialManifest.trialIndex : trials.indexOf(trialId);
3057
+ const target = join(root, `${TEMPORARY}${runId}`);
3058
+ if (!await exists(join(source, "artifacts")) && await exists(join(target, "artifacts"))) await rename(join(target, "artifacts"), join(source, "artifacts"));
3059
+ await rm(target, {
3060
+ recursive: true,
3061
+ force: true
3062
+ });
3063
+ await mkdir(target, { recursive: true });
3064
+ await writeJsonFile(join(target, "manifest.json"), upgradeManifest({
3065
+ run: runManifest,
3066
+ trial: trialManifest,
3067
+ runId,
3068
+ ...!inPlace ? { batch: {
3069
+ id: oldRunId,
3070
+ index,
3071
+ size: trials.length
3072
+ } } : {},
3073
+ status
3074
+ }));
3075
+ if (trialSummary) await writeJsonFile(join(target, "summary.json"), {
3076
+ ...trialSummary,
3077
+ status
3078
+ });
3079
+ const scoring = await readText(join(source, "scoring.json"));
3080
+ if (scoring !== void 0) await writeFile(join(target, "scoring.json"), scoring);
3081
+ const trajectory = await readText(join(source, "trajectory.jsonl"));
3082
+ if (trajectory !== void 0) await writeFile(join(target, "trajectory.jsonl"), upgradeTrajectory(trajectory));
3083
+ await copyShared(directory, target, catalog, extras);
3084
+ if (await exists(join(source, "artifacts"))) await rename(join(source, "artifacts"), join(target, "artifacts"));
3085
+ if (inPlace) await replaceInPlace(root, oldRunId, target);
3086
+ else await rename(target, destination);
3087
+ }
3088
+ if (!inPlace) await rm(directory, {
3089
+ recursive: true,
3090
+ force: true
3091
+ });
3092
+ }
3093
+ async function copyShared(from, to, catalog, extras) {
3094
+ if (catalog) await writeJsonFile(join(to, "catalog.json"), upgradeCatalog(catalog));
3095
+ for (const file of extras) await cp(join(from, file), join(to, file));
3096
+ }
3097
+ async function trialIds(directory) {
3098
+ try {
3099
+ return (await readdir(join(directory, "trials"), { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
3100
+ } catch {
3101
+ return [];
3102
+ }
3103
+ }
3104
+ async function presentFiles(directory, names) {
3105
+ const present = [];
3106
+ for (const name of names) if (await exists(join(directory, name))) present.push(name);
3107
+ return present;
3108
+ }
3109
+ async function exists(path) {
3110
+ try {
3111
+ await stat(path);
3112
+ return true;
3113
+ } catch {
3114
+ return false;
3115
+ }
3116
+ }
3117
+ async function readText(path) {
3118
+ try {
3119
+ return await readFile(path, "utf8");
3120
+ } catch {
3121
+ return;
3122
+ }
3123
+ }
3124
+ async function readJson(path) {
3125
+ const text = await readText(path);
3126
+ if (text === void 0) return void 0;
3127
+ let value;
3128
+ try {
3129
+ value = JSON.parse(text);
3130
+ } catch (error) {
3131
+ throw new Error(`${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
3132
+ }
3133
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
3134
+ }
3135
+ async function writeJsonFile(path, value) {
3136
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
3137
+ }
3138
+ //#endregion
3139
+ //#region ../runner/src/index.ts
3140
+ /**
3141
+ * Runs a scenario: once, or `repeat` times as one batch. Resolves to one
3142
+ * result per run started; runs never started because of cancellation are
3143
+ * left out.
3144
+ */
3145
+ function runEval(definition, options) {
3146
+ return Effect.gen(function* () {
3147
+ yield* Effect.logInfo("eval run started").pipe(Effect.annotateLogs({
3148
+ evalId: authoringId(definition),
3149
+ ...options.runId ? { runId: options.runId } : {},
3150
+ ...options.suiteId ? { suiteId: options.suiteId } : {}
3151
+ }));
3152
+ const runs = yield* Effect.tryPromise({
3153
+ try: () => executeBatch(definition, options),
3154
+ catch: (error) => error
3155
+ });
3156
+ yield* Effect.logInfo("eval run finished").pipe(Effect.annotateLogs({
3157
+ evalId: authoringId(definition),
3158
+ runs: runs.length,
3159
+ passed: runs.filter((run) => run.status === "passed").length
3160
+ }));
3161
+ return runs;
3162
+ });
3163
+ }
3164
+ function createId() {
3165
+ return crypto.randomUUID();
3166
+ }
3167
+ function canonicalId(kind, id) {
3168
+ return resourceUri(kind, id);
3169
+ }
3170
+ function assertUuid(value, label) {
3171
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) throw new Error(`${label} must be a UUID`);
3172
+ }
3173
+ function runMetadata(definition, options, runId, startedAt, extra) {
3174
+ return {
3175
+ schemaVersion: 7,
3176
+ runId,
3177
+ runUri: canonicalId("run", runId),
3178
+ evalId: definition.id,
3179
+ ...options.suiteId ? { suiteId: options.suiteId } : {},
3180
+ ...options.source ? { source: options.source } : {},
3181
+ process: {
3182
+ pid: process.pid,
3183
+ hostname: os.hostname()
3184
+ },
3185
+ ...definition.agent.identity ? { aut: definition.agent.identity } : {},
3186
+ parameters: options.parameters,
3187
+ matrix: options.matrix,
3188
+ startedAt,
3189
+ ...options.batch ? { batch: options.batch } : {},
3190
+ ...extra
3191
+ };
3192
+ }
3193
+ /** Recording a secondary error must never mask the original failure or block cleanup. */
3194
+ async function tryEmitError(error, emitRunner, now) {
3195
+ try {
3196
+ await emitRunner({
3197
+ kind: "error",
3198
+ error: recordError(error),
3199
+ timestamp: now().toISOString()
3200
+ });
3201
+ } catch {}
3202
+ }
3203
+ /** Starts `repeat` runs of a scenario, as one batch when there are several. */
3204
+ async function executeBatch(definition, options) {
3205
+ let hasUser = false;
3206
+ for (const step of definition.transcript) if (step.kind === "user") hasUser = true;
3207
+ else if (step.kind !== "agent" && !hasUser) throw new Error("Checkpoint requires a preceding user step");
3208
+ assertNoRemovedOptions(definition);
3209
+ assertJudgesIndependent(definition, options.judge);
3210
+ for (const rule of [...definition.transcript, ...definition.scoring]) if (rule.kind === "judge" && !rule.agent && !options.judge) throw new Error(`Judge ${rule.name} in ${definition.id} has no agent: pass judge(name, { agent }) or set judge in evalkit.config.ts`);
3211
+ if (options.runId) assertUuid(options.runId, "runId");
3212
+ const repeat = options.repeat ?? definition.repeat ?? 1;
3213
+ if (!Number.isInteger(repeat) || repeat < 1) throw new Error(`repeat must be a positive integer; received ${repeat}`);
3214
+ if (repeat === 1) return [await runOnce(definition, options)];
3215
+ if (options.runId) throw new Error("runId names one run; it cannot be used with repeat");
3216
+ const batchId = createId();
3217
+ const signal = options.signal;
3218
+ const runs = Array.from({ length: repeat }, (_, index) => {
3219
+ const run = Effect.tryPromise({
3220
+ try: async () => signal?.aborted ? void 0 : runOnce(definition, {
3221
+ ...options,
3222
+ batch: {
3223
+ id: batchId,
3224
+ index,
3225
+ size: repeat
3226
+ }
3227
+ }),
3228
+ catch: (error) => error
3229
+ });
3230
+ return options.semaphore ? options.semaphore.withPermits(1)(run) : run;
3231
+ });
3232
+ return (await Effect.runPromise(Effect.all(runs, { concurrency: options.concurrency ?? 32 }))).filter((run) => run !== void 0);
3233
+ }
3234
+ /** One execution: a session in its own sandbox, scored and reported. */
3235
+ async function runOnce(definition, options) {
3236
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
3237
+ const runId = options.runId ?? createId();
3238
+ const startedAt = now().toISOString();
3239
+ const selectedRuntime = options.runtime;
3240
+ const runtime = selectedRuntime ? agentRuntimes(definition.agent)[selectedRuntime] : void 0;
3241
+ if (selectedRuntime && !runtime) throw new Error(`AUT does not declare the requested ${selectedRuntime} runtime`);
3242
+ const randomSeed = crypto.getRandomValues(/* @__PURE__ */ new Uint32Array(1))[0];
3243
+ const batch = options.batch;
3244
+ const signal = options.signal ?? new AbortController().signal;
3245
+ const root = options.root ?? process.cwd();
3246
+ const baseContext = {
3247
+ runId,
3248
+ evalId: authoringId(definition),
3249
+ ...batch ? { batch } : {},
3250
+ metadata: {
3251
+ ...toJsonObject(definition.metadata ?? {}, `${definition.id}.metadata`),
3252
+ randomSeed
3253
+ },
3254
+ ...options.parameters ? { parameters: options.parameters } : {},
3255
+ ...selectedRuntime ? { runtime: selectedRuntime } : {}
3256
+ };
3257
+ let datasetsError;
3258
+ let prepared = [];
3259
+ try {
3260
+ prepared = await prepareDatasets(definition.datasets ?? [], root, {
3261
+ evalId: baseContext.evalId,
3262
+ runId,
3263
+ ...batch ? { batch } : {},
3264
+ seed: randomSeed,
3265
+ parameters: options.parameters ?? {},
3266
+ signal
3267
+ });
3268
+ } catch (error) {
3269
+ datasetsError = error;
3270
+ }
3271
+ const candidateData = datasetValues(prepared, "candidate");
3272
+ const scoringData = datasetValues(prepared, "all");
3273
+ const workspaceIgnore = [...options.artifactExclude ?? DEFAULT_ARTIFACT_EXCLUDE, ...definition.workspace?.ignore ?? []];
3274
+ const limits = resolveLimits(options.limits?.base ?? {}, parseLimits(definition.limits, `Eval ${definition.id}`), limitsFromParameters(options.parameters), options.limits?.override ?? {});
3275
+ const runWriter = await options.report.startRun(runMetadata(definition, options, runId, startedAt, {
3276
+ workspaceIgnore,
3277
+ ...prepared.length ? { datasets: runDatasets(prepared) } : {},
3278
+ ...Object.keys(limits).length ? { limits } : {},
3279
+ ...options.isolation?.kind === "container" ? { isolation: {
3280
+ kind: "container",
3281
+ image: options.isolation.image,
3282
+ network: options.isolation.network
3283
+ } } : options.isolation?.kind === "sandbox-exec" ? { isolation: { kind: "sandbox-exec" } } : {}
3284
+ }), { catalog: options.catalog });
3285
+ const attachments = attachmentStore(runWriter, options.attachmentLimits);
3286
+ let writeChain = Promise.resolve();
3287
+ let reportingFailed = false;
3288
+ const runRef = {
3289
+ runId,
3290
+ ...batch ? { batch } : {},
3291
+ evalId: definition.id,
3292
+ ...options.suiteId ? { suiteId: options.suiteId } : {},
3293
+ ...options.parameters ? { parameters: options.parameters } : {}
3294
+ };
3295
+ const observe = (update) => {
3296
+ try {
3297
+ options.onUpdate?.(runRef, update);
3298
+ } catch {}
3299
+ };
3300
+ const limitController = new AbortController();
3301
+ const runSignal = AbortSignal.any([signal, limitController.signal]);
3302
+ let reached;
3303
+ let steps = 0;
3304
+ let tokens = 0;
3305
+ const reach = (limit) => {
3306
+ if (reached || signal.aborted) return;
3307
+ reached = limit;
3308
+ emitRunner({
3309
+ kind: "limit-reached",
3310
+ ...limit,
3311
+ timestamp: now().toISOString()
3312
+ }).catch(() => {});
3313
+ limitController.abort(new LimitReachedError({
3314
+ message: `Stopped at its ${limit.limit} limit (${formatLimit(limit.limit, limit.value)})`,
3315
+ limit
3316
+ }));
3317
+ };
3318
+ const countRecord = (record) => {
3319
+ if (record.type === "message" && record.role === "assistant") {
3320
+ steps += 1;
3321
+ if (record.usage) tokens += uncachedTokens(record.usage);
3322
+ } else if (record.type === "usage" && !record.cumulative || record.type === "compaction" || record.type === "branch_summary") tokens += record.usage ? uncachedTokens(record.usage) : 0;
3323
+ if (limits.steps !== void 0 && steps > limits.steps) reach({
3324
+ limit: "steps",
3325
+ value: limits.steps,
3326
+ used: steps
3327
+ });
3328
+ else if (limits.tokens !== void 0 && tokens > limits.tokens) reach({
3329
+ limit: "tokens",
3330
+ value: limits.tokens,
3331
+ used: tokens
3332
+ });
3333
+ };
3334
+ let timeLimit;
3335
+ let sessionStarted;
3336
+ const transcript = new RunTranscript({
3337
+ id: runId,
3338
+ agent: {
3339
+ id: definition.agent.identity?.id ?? "agent",
3340
+ ...definition.agent.identity?.version ? { version: definition.agent.identity.version } : {}
3341
+ },
3342
+ runId,
3343
+ scenarioId: definition.id,
3344
+ now,
3345
+ persist: (line) => runWriter.appendTranscript(line),
3346
+ persistSubagent: (id, text) => runWriter.writeSubagent(id, text),
3347
+ onRecord: (record) => {
3348
+ countRecord(record);
3349
+ observe({
3350
+ kind: "record",
3351
+ record
3352
+ });
3353
+ },
3354
+ onSubagentRecord: countRecord
3355
+ });
3356
+ const runEvents = [];
3357
+ const emitRunner = async (input) => {
3358
+ const event = {
3359
+ ...input,
3360
+ at: transcript.leafId
3361
+ };
3362
+ runEvents.push(event);
3363
+ writeChain = writeChain.then(() => runWriter.appendEvent(event));
3364
+ try {
3365
+ await writeChain;
3366
+ observe({
3367
+ kind: "event",
3368
+ event
3369
+ });
3370
+ } catch (cause) {
3371
+ reportingFailed = true;
3372
+ throw new ReportError({
3373
+ cause,
3374
+ message: cause instanceof Error ? cause.message : String(cause)
3375
+ });
3376
+ }
3377
+ };
3378
+ let workspace;
3379
+ let runShell;
3380
+ let context;
3381
+ let primaryError;
3382
+ let closeError;
3383
+ let scoring;
3384
+ let artifacts;
3385
+ let status = "running";
3386
+ const progress = {
3387
+ checkpoints: [],
3388
+ stoppedEarly: false
3389
+ };
3390
+ let cleanupError;
3391
+ const workspaceScope = await Effect.runPromise(Scope.make());
3392
+ try {
3393
+ await emitRunner({
3394
+ kind: "run-started",
3395
+ timestamp: now().toISOString()
3396
+ });
3397
+ if (signal.aborted) throw cancelled();
3398
+ if (datasetsError) throw new DatasetError({
3399
+ cause: datasetsError,
3400
+ message: datasetsError instanceof Error ? datasetsError.message : String(datasetsError)
3401
+ });
3402
+ workspace = await runBoundary(Effect.acquireRelease(Effect.tryPromise({
3403
+ try: () => createRunWorkspace(runId, prepared, options.workspaceRoot),
3404
+ catch: (cause) => new DatasetError({
3405
+ cause,
3406
+ message: cause instanceof Error ? cause.message : String(cause)
3407
+ })
3408
+ }), (created) => Effect.tryPromise({
3409
+ try: () => created.cleanup(keepWorkspace(options.keepWorkspaces, scoring?.passed === true)),
3410
+ catch: (cause) => new DatasetError({
3411
+ cause,
3412
+ message: cause instanceof Error ? cause.message : String(cause)
3413
+ })
3414
+ }).pipe(Effect.catchAll((error) => Effect.sync(() => {
3415
+ cleanupError = error;
3416
+ }).pipe(Effect.tap(() => Effect.logError("run workspace cleanup failed")))))).pipe(Effect.provideService(Scope.Scope, workspaceScope)));
3417
+ const runWorkspace = workspace;
3418
+ runShell = await runBoundary(Effect.acquireRelease(Effect.tryPromise({
3419
+ try: () => openRunShell({
3420
+ isolation: options.isolation ?? none(),
3421
+ runId,
3422
+ root: runWorkspace.artifacts.candidate.root,
3423
+ projectRoot: options.root ?? process.cwd(),
3424
+ signal: runSignal,
3425
+ ...options.fixtures ? { fixtures: options.fixtures } : {}
3426
+ }),
3427
+ catch: (cause) => new AutExecutionError({
3428
+ cause,
3429
+ message: `Could not set up the run's ${isolationLabel(options.isolation)}: ${cause instanceof Error ? cause.message : String(cause)}`
3430
+ })
3431
+ }), (opened) => Effect.promise(() => opened.close({ keep: keepWorkspace(options.keepWorkspaces, scoring?.passed === true) }).catch((error) => {
3432
+ cleanupError ??= error;
3433
+ }))).pipe(Effect.provideService(Scope.Scope, workspaceScope)));
3434
+ const runContext = {
3435
+ ...baseContext,
3436
+ signal: runSignal,
3437
+ limits,
3438
+ shell: runShell.shell,
3439
+ fixtures: runShell.fixtures,
3440
+ data: candidateData,
3441
+ workspace: runWorkspace.artifacts.candidate,
3442
+ evaluatorWorkspace: runWorkspace.artifacts.evaluator
3443
+ };
3444
+ context = runContext;
3445
+ sessionStarted = Date.now();
3446
+ if (limits.time !== void 0) {
3447
+ const time = limits.time;
3448
+ timeLimit = setTimeout(() => reach({
3449
+ limit: "time",
3450
+ value: time,
3451
+ used: Date.now() - (sessionStarted ?? Date.now())
3452
+ }), time);
3453
+ timeLimit.unref?.();
3454
+ }
3455
+ await runBoundary(Effect.acquireUseRelease(Effect.tryPromise({
3456
+ try: () => definition.agent.start({
3457
+ context: runContext,
3458
+ ...runtime ? { runtime } : {},
3459
+ transcript: transcript.writer(),
3460
+ attach: attachments.attach
3461
+ }),
3462
+ catch: (cause) => new AutExecutionError({
3463
+ cause,
3464
+ message: cause instanceof Error ? cause.message : String(cause)
3465
+ })
3466
+ }), (session) => Effect.tryPromise({
3467
+ try: () => executeTranscript(definition, session, {
3468
+ context: runContext,
3469
+ artifacts: runWorkspace.artifacts,
3470
+ transcript,
3471
+ runEvents,
3472
+ now,
3473
+ randomSeed,
3474
+ emitRunner,
3475
+ data: scoringData,
3476
+ signal: runSignal,
3477
+ progress,
3478
+ attach: attachments.attach,
3479
+ defaultJudge: options.judge
3480
+ }),
3481
+ catch: (cause) => cause
3482
+ }), (opened) => Effect.tryPromise({
3483
+ try: async () => opened.close?.(),
3484
+ catch: (cause) => new AutExecutionError({
3485
+ cause,
3486
+ message: cause instanceof Error ? cause.message : String(cause)
3487
+ })
3488
+ }).pipe(Effect.catchAll((error) => Effect.sync(() => {
3489
+ closeError = error;
3490
+ }).pipe(Effect.tap(() => Effect.logError("AUT session close failed")))))).pipe(Effect.annotateLogs({
3491
+ runId,
3492
+ evalId: definition.id
3493
+ })));
3494
+ } catch (error) {
3495
+ primaryError = error;
3496
+ if (!(error instanceof LimitReachedError)) await tryEmitError(error, emitRunner, now);
3497
+ }
3498
+ if (timeLimit) clearTimeout(timeLimit);
3499
+ if (closeError) {
3500
+ if (!primaryError) primaryError = closeError;
3501
+ await tryEmitError(closeError, emitRunner, now);
3502
+ }
3503
+ transcript.seal();
3504
+ const problems = transcript.problems();
3505
+ if (problems.length) {
3506
+ const invalid = new AutExecutionError({
3507
+ cause: problems,
3508
+ message: `The agent's transcript is not valid ATXF: ${problems.slice(0, 3).join("; ")}${problems.length > 3 ? ` (${problems.length - 3} more)` : ""}`
3509
+ });
3510
+ if (!primaryError) primaryError = invalid;
3511
+ await tryEmitError(invalid, emitRunner, now);
3512
+ }
3513
+ if (reached && primaryError === void 0) primaryError = limitController.signal.reason;
3514
+ const limited = primaryError instanceof LimitReachedError;
3515
+ if (limited) primaryError = void 0;
3516
+ const wasCancelled = primaryError instanceof CancelledError;
3517
+ const { checkpoints, stoppedEarly, lastTurn } = progress;
3518
+ const scoreResults = [];
3519
+ const skippedScorers = [];
3520
+ const metrics = [];
3521
+ try {
3522
+ await evaluateFinalScorers(definition, {
3523
+ context,
3524
+ workspace,
3525
+ agentTranscript: transcript,
3526
+ runEvents,
3527
+ lastTurn,
3528
+ primaryError,
3529
+ limited,
3530
+ stoppedEarly,
3531
+ cancelled: wasCancelled,
3532
+ reportingFailed,
3533
+ results: scoreResults,
3534
+ skippedScorers,
3535
+ data: scoringData,
3536
+ checkpoints,
3537
+ attach: attachments.attach,
3538
+ defaultJudge: options.judge,
3539
+ now,
3540
+ emitRunner
3541
+ });
3542
+ metrics.push(...builtInMetrics(transcript.transcript, runEvents, [...scoreResults, ...checkpoints], Object.values(transcript.subagents)));
3543
+ if (context && workspace && !wasCancelled) metrics.push(...await evaluateMetrics(definition, scoringContext({
3544
+ context,
3545
+ workspace,
3546
+ agentTranscript: transcript,
3547
+ runEvents,
3548
+ lastTurn,
3549
+ data: scoringData,
3550
+ checkpoints,
3551
+ transcript: definition.transcript,
3552
+ attach: attachments.attach
3553
+ })));
3554
+ scoring = scoreSummary(scoreResults, checkpoints, skippedScorers, metrics);
3555
+ await runWriter.writeScores(scoring);
3556
+ if (workspace) try {
3557
+ artifacts = await snapshotCandidateWorkspace(workspace.artifacts.candidate.root, runWriter, workspaceIgnore);
3558
+ } catch (captureError) {
3559
+ if (!primaryError) primaryError = captureError;
3560
+ await tryEmitError(captureError, emitRunner, now);
3561
+ }
3562
+ } catch (error) {
3563
+ if (!primaryError) primaryError = error;
3564
+ await tryEmitError(error, emitRunner, now);
3565
+ } finally {
3566
+ await Effect.runPromise(Scope.close(workspaceScope, Exit.succeed(void 0)));
3567
+ if (cleanupError) {
3568
+ if (!primaryError) primaryError = cleanupError;
3569
+ await tryEmitError(cleanupError, emitRunner, now);
3570
+ }
3571
+ }
3572
+ scoring ??= scoreSummary(scoreResults, checkpoints, skippedScorers, metrics);
3573
+ if (!primaryError) try {
3574
+ await emitRunner({
3575
+ kind: "run-completed",
3576
+ timestamp: now().toISOString()
3577
+ });
3578
+ } catch (error) {
3579
+ primaryError = error;
3580
+ }
3581
+ try {
3582
+ await transcript.settled();
3583
+ } catch (cause) {
3584
+ primaryError ??= new ReportError({
3585
+ cause,
3586
+ message: cause instanceof Error ? cause.message : String(cause)
3587
+ });
3588
+ }
3589
+ status = wasCancelled ? "cancelled" : primaryError ? "errored" : limited ? "limited" : scoring.passed ? "passed" : "failed";
3590
+ if (options.fixtures?.started) for (const log of options.fixtures.logs()) await attachments.attach(`logs/fixtures/${log.name}.log`, log.content).catch(() => void 0);
3591
+ const error = primaryError ? recordError(primaryError) : void 0;
3592
+ const attached = await attachments.close();
3593
+ const endedAt = now().toISOString();
3594
+ const durationMs = new Date(endedAt).getTime() - new Date(startedAt).getTime();
3595
+ await runWriter.finalize({
3596
+ status,
3597
+ endedAt,
3598
+ durationMs,
3599
+ scoring,
3600
+ ...artifacts ? { artifacts } : {},
3601
+ ...attached.length ? { attachments: attached } : {},
3602
+ ...error ? { error } : {},
3603
+ ...limited && reached ? { limit: reached } : {}
3604
+ });
3605
+ return {
3606
+ runId,
3607
+ runUri: canonicalId("run", runId),
3608
+ evalId: definition.id,
3609
+ status,
3610
+ ...batch ? { batch } : {},
3611
+ reportLocation: runWriter.location,
3612
+ durationMs,
3613
+ scoring,
3614
+ ...error ? { error } : {},
3615
+ ...limited && reached ? { limit: reached } : {}
3616
+ };
3617
+ }
3618
+ function datasetValues(prepared, audience) {
3619
+ const values = {};
3620
+ for (const { dataset, value } of prepared) {
3621
+ if (value === void 0) continue;
3622
+ if (audience === "candidate" && dataset.visibility !== "candidate") continue;
3623
+ values[dataset.id] = value;
3624
+ }
3625
+ return values;
3626
+ }
3627
+ /** Persistent workspaces of runs that did not pass are kept for debugging by default. */
3628
+ function isolationLabel(isolation) {
3629
+ if (isolation?.kind === "container") return "container";
3630
+ if (isolation?.kind === "sandbox-exec") return "sandbox";
3631
+ return "shell";
3632
+ }
3633
+ function keepWorkspace(policy = "failed", passed) {
3634
+ return policy === "all" || policy === "failed" && !passed;
3635
+ }
3636
+ //#endregion
3637
+ export { ScoringError as C, ReportError as S, removeRunContainer as _, readRunEvents as a, CheckpointExecutionError as b, readRunSubagents as c, localReportStore as d, runWorkspacePath as f, fixtureSet as g, globMatcher as h, ownerIsGone as i, readRunSummary as l, staticDatasetFiles as m, upgradeReports as n, readRunManifest as o, staticDatasetFile as p, runMatrix as r, readRunScoring as s, runEval as t, readRunTranscript as u, AutExecutionError as v, DatasetError as x, CancelledError as y };