@leostera/evalkit 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -1
- package/README.md +3 -2
- package/dist/cli.mjs +311 -92
- package/dist/dashboard/assets/index-CFJzcw3o.css +2 -0
- package/dist/dashboard/assets/{index-By1SJ1on.js → index-DgoX2x_M.js} +34 -31
- package/dist/dashboard/index.html +2 -2
- package/dist/{index-db-BbVblaHz.mjs → index-db-DI7xKMSX.mjs} +237 -47
- package/dist/index.d.mts +1141 -220
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +241 -52
- package/dist/runner.d.mts +782 -225
- package/dist/runner.d.mts.map +1 -1
- package/dist/runner.mjs +2 -2
- package/dist/share-worker.d.mts +1 -1
- package/dist/share-worker.d.mts.map +1 -1
- package/dist/share-worker.mjs +1 -1
- package/dist/share.d.mts +9 -6
- package/dist/share.d.mts.map +1 -1
- package/dist/share.mjs +2 -2
- package/dist/skills/evalkit/SKILL.md +3 -3
- package/dist/skills/evalkit-agents/SKILL.md +3 -3
- package/dist/skills/evalkit-datasets/SKILL.md +1 -1
- package/dist/skills/evalkit-evals/SKILL.md +1 -1
- package/dist/skills/evalkit-judges/SKILL.md +4 -4
- package/dist/skills/evalkit-results/SKILL.md +2 -2
- package/dist/{src-Bn6fnZnF.mjs → src-CXzbHAKN.mjs} +1 -1
- package/dist/{src-BrfIXfO2.mjs → src-D6Ax2t9b.mjs} +636 -220
- package/dist/src-Dc3GXacB.mjs +3637 -0
- package/dist/upgrade-rMsGzUEJ.mjs +230 -0
- package/dist/upgrade-transcript-C8DUykLd.mjs +402 -0
- package/package.json +2 -1
- package/dist/dashboard/assets/index-geXMXCx9.css +0 -2
- package/dist/src-CcdLZqFk.mjs +0 -1714
- package/dist/upgrade-60SIRius.mjs +0 -110
|
@@ -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 };
|