reeval 0.0.1
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/LICENSE +202 -0
- package/THIRD_PARTY_NOTICES.md +2160 -0
- package/assets/package.json +9 -0
- package/assets/pnpm-lock.yaml +1336 -0
- package/dist/agent-DVHkx8aT.js +532 -0
- package/dist/agent-DVHkx8aT.js.map +1 -0
- package/dist/analysis-CxSCt1YY.js +16 -0
- package/dist/analysis-CxSCt1YY.js.map +1 -0
- package/dist/assemble-B-nmz8oH.js +695 -0
- package/dist/assemble-B-nmz8oH.js.map +1 -0
- package/dist/assemble-CmRVDZ2Q.js +2 -0
- package/dist/attempt-BdRQ9jKX.js +176 -0
- package/dist/attempt-BdRQ9jKX.js.map +1 -0
- package/dist/bridge/index.mjs +1911 -0
- package/dist/bridge/package.json +13 -0
- package/dist/bridge/pnpm-lock.yaml +1041 -0
- package/dist/bridge/pnpm-workspace.yaml +2 -0
- package/dist/budget-DBUrH4jX.js +8 -0
- package/dist/budget-DBUrH4jX.js.map +1 -0
- package/dist/cli--CQ3Iy9q.js +1865 -0
- package/dist/cli--CQ3Iy9q.js.map +1 -0
- package/dist/cli-spec-DKAu09Gi.js +478 -0
- package/dist/cli-spec-DKAu09Gi.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +8 -0
- package/dist/cli.js.map +1 -0
- package/dist/colors-QV9dFVhN.js +34 -0
- package/dist/colors-QV9dFVhN.js.map +1 -0
- package/dist/command-BSJzHMtP.js +280 -0
- package/dist/command-BSJzHMtP.js.map +1 -0
- package/dist/command-CEm8PZ1N.js +1043 -0
- package/dist/command-CEm8PZ1N.js.map +1 -0
- package/dist/command-D-jnQWVG.js +673 -0
- package/dist/command-D-jnQWVG.js.map +1 -0
- package/dist/command-DQAvbG7d.js +432 -0
- package/dist/command-DQAvbG7d.js.map +1 -0
- package/dist/commands-A4udfyKZ.js +2 -0
- package/dist/commands-CF8mOfiu.js +207 -0
- package/dist/commands-CF8mOfiu.js.map +1 -0
- package/dist/config-Cuj0kVYj.js +415 -0
- package/dist/config-Cuj0kVYj.js.map +1 -0
- package/dist/context-loi9OCZv.js +106 -0
- package/dist/context-loi9OCZv.js.map +1 -0
- package/dist/core-tLN-z1Be.js +96 -0
- package/dist/core-tLN-z1Be.js.map +1 -0
- package/dist/db-Cjj2ZsfQ.js +2 -0
- package/dist/db-DxDzMhtB.js +827 -0
- package/dist/db-DxDzMhtB.js.map +1 -0
- package/dist/debug-D24NOQH1.js +89 -0
- package/dist/debug-D24NOQH1.js.map +1 -0
- package/dist/debug-SEmW4w8f.js +2 -0
- package/dist/dist-BZXYHN2X.js +1673 -0
- package/dist/dist-BZXYHN2X.js.map +1 -0
- package/dist/docker-DQDZWXF9.js +20 -0
- package/dist/docker-DQDZWXF9.js.map +1 -0
- package/dist/docker-DnWAVdEm.js +2 -0
- package/dist/docker-oSbCExHL.js +153 -0
- package/dist/docker-oSbCExHL.js.map +1 -0
- package/dist/doctor-CxlH1pDM.js +525 -0
- package/dist/doctor-CxlH1pDM.js.map +1 -0
- package/dist/egress-73SQfKdx.js +619 -0
- package/dist/egress-73SQfKdx.js.map +1 -0
- package/dist/env-BQqj93gu.js +21 -0
- package/dist/env-BQqj93gu.js.map +1 -0
- package/dist/environment-Mz7wvrtD.js +552 -0
- package/dist/environment-Mz7wvrtD.js.map +1 -0
- package/dist/errors-C9w_ud7g.js +160 -0
- package/dist/errors-C9w_ud7g.js.map +1 -0
- package/dist/errors-CSxfJ4qu.js +11 -0
- package/dist/errors-CSxfJ4qu.js.map +1 -0
- package/dist/files--6CEUXm7.js +2 -0
- package/dist/files-BLlrhU4-.js +62 -0
- package/dist/files-BLlrhU4-.js.map +1 -0
- package/dist/files-Bd25OAQH.js +2 -0
- package/dist/files-BvHCFWgD.js +2 -0
- package/dist/files-D2-qn_Dl.js +275 -0
- package/dist/files-D2-qn_Dl.js.map +1 -0
- package/dist/files-bF2fPuT9.js +99 -0
- package/dist/files-bF2fPuT9.js.map +1 -0
- package/dist/git-D6hMeieS.js +44 -0
- package/dist/git-D6hMeieS.js.map +1 -0
- package/dist/harness-ls5aX1UO.js +131 -0
- package/dist/harness-ls5aX1UO.js.map +1 -0
- package/dist/harness.d.ts +74 -0
- package/dist/harness.js +2 -0
- package/dist/header-DzFDoMCI.js +78 -0
- package/dist/header-DzFDoMCI.js.map +1 -0
- package/dist/ids-Bp6ut7ZD.js +49 -0
- package/dist/ids-Bp6ut7ZD.js.map +1 -0
- package/dist/images-Cc1-coFc.js +286 -0
- package/dist/images-Cc1-coFc.js.map +1 -0
- package/dist/index.d.ts +97 -0
- package/dist/index.js +1959 -0
- package/dist/index.js.map +1 -0
- package/dist/init-CFljS8BJ.js +344 -0
- package/dist/init-CFljS8BJ.js.map +1 -0
- package/dist/init-z2ZPn9Tj.js +2 -0
- package/dist/invocations-rXktgSAn.js +157 -0
- package/dist/invocations-rXktgSAn.js.map +1 -0
- package/dist/jiti-BOLXpKyG.js +46 -0
- package/dist/jiti-BOLXpKyG.js.map +1 -0
- package/dist/judge-D8Uf4dA9.js +146 -0
- package/dist/judge-D8Uf4dA9.js.map +1 -0
- package/dist/judge.d.ts +21 -0
- package/dist/judge.js +19 -0
- package/dist/judge.js.map +1 -0
- package/dist/live-Bs3LReMV.js +98 -0
- package/dist/live-Bs3LReMV.js.map +1 -0
- package/dist/matchers-DyLvm7EG.js +374 -0
- package/dist/matchers-DyLvm7EG.js.map +1 -0
- package/dist/model-BdO79gWz.js +44 -0
- package/dist/model-BdO79gWz.js.map +1 -0
- package/dist/model-CDL98xue.js +725 -0
- package/dist/model-CDL98xue.js.map +1 -0
- package/dist/model-v-0_Ik7S.js +2 -0
- package/dist/node-kG64XxdE.js +506 -0
- package/dist/node-kG64XxdE.js.map +1 -0
- package/dist/opencode-providers-DZuD_oYt.js +3149 -0
- package/dist/opencode-providers-DZuD_oYt.js.map +1 -0
- package/dist/options-CAQhu--C.js +452 -0
- package/dist/options-CAQhu--C.js.map +1 -0
- package/dist/patch-BkKje4cp.js +69 -0
- package/dist/patch-BkKje4cp.js.map +1 -0
- package/dist/paths-CBvm6pap.js +72 -0
- package/dist/paths-CBvm6pap.js.map +1 -0
- package/dist/plan-gwk6dJ8c.js +237 -0
- package/dist/plan-gwk6dJ8c.js.map +1 -0
- package/dist/preflight-BtubXKQ8.js +2 -0
- package/dist/preflight-BypZMNBG.js +185 -0
- package/dist/preflight-BypZMNBG.js.map +1 -0
- package/dist/prepare-Cwv0bHs_.js +3 -0
- package/dist/prepare-D6Uzcubb.js +213 -0
- package/dist/prepare-D6Uzcubb.js.map +1 -0
- package/dist/provenance-Dfb8YY5Y.js +129 -0
- package/dist/provenance-Dfb8YY5Y.js.map +1 -0
- package/dist/provider-BDbNOQhQ.js +2 -0
- package/dist/provider-CmAYTQQs.js +14 -0
- package/dist/provider-CmAYTQQs.js.map +1 -0
- package/dist/providers-2p3ylMHI.js +107 -0
- package/dist/providers-2p3ylMHI.js.map +1 -0
- package/dist/providers-BUOxitcT.js +2 -0
- package/dist/query-CFATHZmV.js +91 -0
- package/dist/query-CFATHZmV.js.map +1 -0
- package/dist/query-DR4m6CXf.js +2 -0
- package/dist/record-DQ8wzyco.js +99 -0
- package/dist/record-DQ8wzyco.js.map +1 -0
- package/dist/redact-C4fL_bZP.js +167 -0
- package/dist/redact-C4fL_bZP.js.map +1 -0
- package/dist/results-CeXb8Wde.js +37 -0
- package/dist/results-CeXb8Wde.js.map +1 -0
- package/dist/root-CFDjpWuc.js +110 -0
- package/dist/root-CFDjpWuc.js.map +1 -0
- package/dist/sandbox-By2gkdkC.js +133 -0
- package/dist/sandbox-By2gkdkC.js.map +1 -0
- package/dist/sandbox.d.ts +41 -0
- package/dist/sandbox.js +2 -0
- package/dist/server-Bi61uiY0.js +350 -0
- package/dist/server-Bi61uiY0.js.map +1 -0
- package/dist/server-CsSBL8NC.js +2 -0
- package/dist/source-BXzrmof5.js +2 -0
- package/dist/source-Bsfa3LZH.js +40 -0
- package/dist/source-Bsfa3LZH.js.map +1 -0
- package/dist/src-55j1zNHH.js +3 -0
- package/dist/src-AEwxd1UP.js +602 -0
- package/dist/src-AEwxd1UP.js.map +1 -0
- package/dist/src-BDwSuyIo.js +3 -0
- package/dist/src-BQyMiWri.js +490 -0
- package/dist/src-BQyMiWri.js.map +1 -0
- package/dist/src-C-LHyTSV.js +3 -0
- package/dist/src-Cmp-7YrD.js +1136 -0
- package/dist/src-Cmp-7YrD.js.map +1 -0
- package/dist/src-D0RdqpNb.js +252 -0
- package/dist/src-D0RdqpNb.js.map +1 -0
- package/dist/src-D8KYzxBX.js +793 -0
- package/dist/src-D8KYzxBX.js.map +1 -0
- package/dist/src-DEgff9Z7.js +4 -0
- package/dist/src-DXx_29pq.js +567 -0
- package/dist/src-DXx_29pq.js.map +1 -0
- package/dist/src-DwwiVLqv.js +2937 -0
- package/dist/src-DwwiVLqv.js.map +1 -0
- package/dist/src-N7E_mfcZ.js +2 -0
- package/dist/src-W9g-y5X5.js +3 -0
- package/dist/src-daS0wKH2.js +3 -0
- package/dist/state-Cyw3QTsN.js +2 -0
- package/dist/state-DZ7eqb1F.js +167 -0
- package/dist/state-DZ7eqb1F.js.map +1 -0
- package/dist/stats-BNAHlNMD.js +114 -0
- package/dist/stats-BNAHlNMD.js.map +1 -0
- package/dist/studio-app/assets/index-D6XyfFSH.js +58 -0
- package/dist/studio-app/assets/index-xwzWdcE_.css +2 -0
- package/dist/studio-app/favicon.svg +1 -0
- package/dist/studio-app/index.html +15 -0
- package/dist/summary-D7Ttky0z.js +166 -0
- package/dist/summary-D7Ttky0z.js.map +1 -0
- package/dist/text-gEcKjAyp.js +29 -0
- package/dist/text-gEcKjAyp.js.map +1 -0
- package/dist/types-C5vI3g1C.js +9 -0
- package/dist/types-C5vI3g1C.js.map +1 -0
- package/dist/types-DLec-6Cx.d.ts +1535 -0
- package/dist/usage-DsYUqIUM.js +24 -0
- package/dist/usage-DsYUqIUM.js.map +1 -0
- package/dist/view/index.d.ts +645 -0
- package/dist/view/index.js +8 -0
- package/dist/views-BrHOv6wg.js +220 -0
- package/dist/views-BrHOv6wg.js.map +1 -0
- package/dist/views-CwCWLWqo.js +3 -0
- package/dist/worker-entry-DnYkANPG.js +506 -0
- package/dist/worker-entry-DnYkANPG.js.map +1 -0
- package/dist/workspace-C2S0Z6gl.js +327 -0
- package/dist/workspace-C2S0Z6gl.js.map +1 -0
- package/dist/workspace-CyoXG6CY.js +2 -0
- package/docker/Dockerfile +19 -0
- package/package.json +79 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1959 @@
|
|
|
1
|
+
import { n as isTimeout, t as UsageError } from "./errors-CSxfJ4qu.js";
|
|
2
|
+
import { _ as stepCommand, b as workspaceOf, d as isSecret, f as isSecretTemplate, h as parseGitRepo, l as isHostFile, m as mcpHosts, o as defineEnvironment, t as DEFAULT_SKILL_EXCLUDE, x as parseDuration, y as workspaceLabel } from "./environment-Mz7wvrtD.js";
|
|
3
|
+
import { t as inCI } from "./env-BQqj93gu.js";
|
|
4
|
+
import { a as registerSecret, i as redactSecrets } from "./redact-C4fL_bZP.js";
|
|
5
|
+
import { t as InfraError } from "./types-C5vI3g1C.js";
|
|
6
|
+
import { _ as layoutOf, a as unenforceable, h as expectedBlockedFor, n as allowedHosts, o as createFakeSandbox, p as brokeringFor, r as egressLabel, t as DEFAULT_EGRESS_ALLOW, v as refusedMcp } from "./egress-73SQfKdx.js";
|
|
7
|
+
import { c as relativeFile, i as evalSlug, l as sha256, n as attemptKey, r as evalId, s as recordFileName, t as attemptId } from "./ids-Bp6ut7ZD.js";
|
|
8
|
+
import { l as runJudge, s as judgeModelName } from "./judge-D8Uf4dA9.js";
|
|
9
|
+
import { S as placeholder, et as run, tt as shellQuote } from "./src-DwwiVLqv.js";
|
|
10
|
+
import { a as evalPlan, d as secretVarsIn, f as unbrokered, i as checkOptions, l as matchesTags, n as cascade, o as expand, p as unbrokeredError, s as harnessId, t as brokeredIn, u as sandboxId } from "./options-CAQhu--C.js";
|
|
11
|
+
import { n as badCharacterAt, o as defineConfig } from "./config-Cuj0kVYj.js";
|
|
12
|
+
import { a as withLease, c as registerGroup, l as registerTest, o as collectingFile, p as expect, s as getRunContext, t as HarnessCapabilityError } from "./matchers-DyLvm7EG.js";
|
|
13
|
+
import { a as withDebug, i as debugTrace, n as attemptDebug, r as debugLog, t as AttemptDebug } from "./attempt-BdRQ9jKX.js";
|
|
14
|
+
import { t as attemptDebugDir } from "./files-BLlrhU4-.js";
|
|
15
|
+
import { n as clip, r as formatDuration, t as capMiddle } from "./text-gEcKjAyp.js";
|
|
16
|
+
import { t as contextTaker } from "./context-loi9OCZv.js";
|
|
17
|
+
import { t as PrepareError } from "./provider-CmAYTQQs.js";
|
|
18
|
+
import { i as preparedWorkspace } from "./workspace-C2S0Z6gl.js";
|
|
19
|
+
import { r as patchForJudge, t as changedFilesForJudge } from "./patch-BkKje4cp.js";
|
|
20
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
21
|
+
import { basename, isAbsolute, join, relative, sep } from "node:path";
|
|
22
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
23
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
24
|
+
import { parse } from "yaml";
|
|
25
|
+
//#region src/attempt/checks.ts
|
|
26
|
+
/** Runs a check or setup step with the checks' brokered secrets swapped in (see brokerPhases). */
|
|
27
|
+
const asCheck = (a, fn) => a.phases ? a.phases.checks(fn) : fn();
|
|
28
|
+
function requireSandbox(a) {
|
|
29
|
+
if (!a.sandbox) throw new Error("No sandbox yet: call t.send() or await t.sandbox() before t.exec() or t.readFile()");
|
|
30
|
+
return a.sandbox;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Phase 4, checks: `t.exec` runs with the checks' variables and secrets, each run recorded and
|
|
34
|
+
* timed; `t.readFile` reads the sandbox as is.
|
|
35
|
+
*/
|
|
36
|
+
function createChecks(a) {
|
|
37
|
+
const checkEnv = () => a.env?.checkVars ?? {};
|
|
38
|
+
const exec = async (command) => {
|
|
39
|
+
const box = requireSandbox(a);
|
|
40
|
+
const start = performance.now();
|
|
41
|
+
a.report({
|
|
42
|
+
kind: "check-start",
|
|
43
|
+
command
|
|
44
|
+
});
|
|
45
|
+
const r = await a.timed("checksMs", () => asCheck(a, () => box.exec(command, {
|
|
46
|
+
signal: a.signal,
|
|
47
|
+
env: checkEnv()
|
|
48
|
+
})));
|
|
49
|
+
const durationMs = Math.round(performance.now() - start);
|
|
50
|
+
a.record.checks.push({
|
|
51
|
+
command,
|
|
52
|
+
...r,
|
|
53
|
+
durationMs
|
|
54
|
+
});
|
|
55
|
+
debugLog("check", `$ ${clip(command, 160)} → exit ${r.exitCode} · ${formatDuration(durationMs)}`);
|
|
56
|
+
debugTrace("check", "output", {
|
|
57
|
+
command,
|
|
58
|
+
...r,
|
|
59
|
+
durationMs
|
|
60
|
+
}, `check output: ${clip(command, 80)}`);
|
|
61
|
+
a.report({
|
|
62
|
+
kind: "check-end",
|
|
63
|
+
command,
|
|
64
|
+
exitCode: r.exitCode,
|
|
65
|
+
ms: durationMs
|
|
66
|
+
});
|
|
67
|
+
return r;
|
|
68
|
+
};
|
|
69
|
+
const readFile = (path) => requireSandbox(a).readFile(path);
|
|
70
|
+
return {
|
|
71
|
+
exec,
|
|
72
|
+
readFile
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/attempt/debug.ts
|
|
77
|
+
/** Which config and profile the attempt runs under, and where each resolved option came from. */
|
|
78
|
+
function tracePlan(debug, spec, ctx) {
|
|
79
|
+
const config = ctx.configFile ?? "none (no config file found)";
|
|
80
|
+
const from = (key, fallback = ctx.configFile ? `${config} or its default` : "default") => `(${spec.sources[key] ?? fallback})`;
|
|
81
|
+
const profile = ctx.profile ? `profile ${ctx.profile.name} (${ctx.profile.reason})` : "no profile";
|
|
82
|
+
debug.log("plan", `config ${config} · ${profile} · run ${ctx.runId} · attempt ${spec.cell.key}#${spec.n}`);
|
|
83
|
+
debug.log("plan", `harness ${harnessId(spec.harness)} ${from("harness")}`);
|
|
84
|
+
debug.log("plan", `sandbox ${sandboxId(spec.sandbox)} ${from("sandbox")}`);
|
|
85
|
+
const env = spec.environment;
|
|
86
|
+
const role = spec.cell.role === "variant" ? ` · variant ${spec.cell.variant}` : "";
|
|
87
|
+
const envFrom = spec.cell.role === "variant" ? from("compare") : from("environment", "none set");
|
|
88
|
+
debug.log("plan", `environment ${env.name}${role} ${envFrom}`);
|
|
89
|
+
debug.log("plan", `egress ${egressLabel(spec.egress) || "allow-all"} ${from("egress")}`);
|
|
90
|
+
const gate = typeof spec.gate === "string" ? spec.gate : `passRate ${spec.gate.passRate}`;
|
|
91
|
+
debug.log("plan", `run ${spec.n} · runs ${ctx.runs !== void 0 ? "(--runs)" : from("runs")} · gate ${gate} · timeout ${formatDuration(spec.timeoutMs)} ${from("timeout", "config.timeout or 15m")}`);
|
|
92
|
+
const judge = spec.judge;
|
|
93
|
+
const model = typeof judge.model === "string" ? judge.model : judge.model && "kind" in judge.model ? "fake judge" : judge.model ? "a model object" : "none";
|
|
94
|
+
debug.log("plan", `judge ${model}${judge.threshold !== void 0 ? ` · threshold ${judge.threshold}` : ""} ${from("judge")}`);
|
|
95
|
+
for (const note of spec.notes) debug.log("plan", `profile: ${note}`);
|
|
96
|
+
debug.trace("plan", "config", {
|
|
97
|
+
config: ctx.config,
|
|
98
|
+
profile: ctx.profile
|
|
99
|
+
}, "resolved config");
|
|
100
|
+
debug.trace("plan", "attempt", {
|
|
101
|
+
cell: spec.cell,
|
|
102
|
+
n: spec.n,
|
|
103
|
+
harness: spec.harness,
|
|
104
|
+
sandbox: spec.sandbox,
|
|
105
|
+
egress: spec.egress,
|
|
106
|
+
gate: spec.gate,
|
|
107
|
+
timeoutMs: spec.timeoutMs,
|
|
108
|
+
judge: spec.judge,
|
|
109
|
+
sources: spec.sources,
|
|
110
|
+
env: Object.keys(spec.env).sort()
|
|
111
|
+
}, "resolved attempt");
|
|
112
|
+
}
|
|
113
|
+
/** `POST api2.cursor.sh/auth/exchange`, `GET host/v1/*` or `POST host (path ~ /re/)`. */
|
|
114
|
+
function endpointLabel({ method, host, path }) {
|
|
115
|
+
const where = !path ? host : "exact" in path ? `${host}${path.exact}` : "startsWith" in path ? `${host}${path.startsWith}*` : `${host} (path ~ /${path.regex}/)`;
|
|
116
|
+
return `${method?.join("|") ?? "*"} ${where}`;
|
|
117
|
+
}
|
|
118
|
+
/** The resolved environment and what the gateway brokers, by name. */
|
|
119
|
+
function traceEnvironment(debug, spec, env) {
|
|
120
|
+
const r = env.record;
|
|
121
|
+
debug.log("env", `environment ${r.name} (digest ${r.digest.replace(/^sha256:/, "").slice(0, 12)})${r.parent ? ` · extends ${r.parent}` : ""}${r.overrides.length ? ` · overrides ${r.overrides.join(", ")}` : ""}`);
|
|
122
|
+
const unset = r.unset.length ? ` · unset (optional) ${r.unset.join(", ")}` : "";
|
|
123
|
+
debug.log("env", `vars ${r.vars.join(", ") || "none"} · secrets ${r.secrets.join(", ") || "none"}${unset}`);
|
|
124
|
+
for (const s of env.substitutions) debug.log("broker", `${s.name} is a placeholder in the sandbox · swapped in on HTTPS to ${s.hosts.join(", ")} in the ${s.in.join(", ")} · for ${s.phase === "checks" ? "checks and setup only" : "the agent and checks"}`);
|
|
125
|
+
const brokering = brokeringFor(spec.harness);
|
|
126
|
+
if (brokering) {
|
|
127
|
+
const endpoints = brokering.endpoints.map(endpointLabel).join(", ");
|
|
128
|
+
debug.log("broker", `${brokering.credential} stays on the host · the gateway swaps it in on ${endpoints}`);
|
|
129
|
+
}
|
|
130
|
+
debug.trace("env", "environment", {
|
|
131
|
+
record: r,
|
|
132
|
+
vars: Object.keys(env.vars).sort(),
|
|
133
|
+
checkVars: Object.keys(env.checkVars).sort(),
|
|
134
|
+
files: env.files.map((f) => f.path),
|
|
135
|
+
setup: env.setup,
|
|
136
|
+
skills: env.skills.map((s) => s.name),
|
|
137
|
+
workspaceSkills: env.workspaceSkills.map((s) => s.name),
|
|
138
|
+
mcp: Object.fromEntries(Object.entries(env.mcp).map(([name, m]) => [name, m.transport === "stdio" ? {
|
|
139
|
+
transport: m.transport,
|
|
140
|
+
command: m.command,
|
|
141
|
+
args: m.args,
|
|
142
|
+
env: Object.keys(m.env)
|
|
143
|
+
} : {
|
|
144
|
+
transport: m.transport,
|
|
145
|
+
url: m.url,
|
|
146
|
+
headers: Object.keys(m.headers)
|
|
147
|
+
}])),
|
|
148
|
+
brokered: env.substitutions.map(({ name, hosts, in: where, phase }) => ({
|
|
149
|
+
name,
|
|
150
|
+
hosts,
|
|
151
|
+
in: where,
|
|
152
|
+
phase
|
|
153
|
+
}))
|
|
154
|
+
}, "resolved environment");
|
|
155
|
+
}
|
|
156
|
+
//#endregion
|
|
157
|
+
//#region src/environment/resolve.ts
|
|
158
|
+
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
159
|
+
const DEFAULT_SETUP_TIMEOUT = "5m";
|
|
160
|
+
function broker(secrets, name, value, hosts, where = ["header"], phase = "agent") {
|
|
161
|
+
const at = badCharacterAt(value);
|
|
162
|
+
if (at && where.includes("header")) throw new InfraError(`${name} has whitespace or a line break in it (character ${at}), so it can't go in a request header. A key copied from a wrapped terminal line picks these up; set it again as one line.`);
|
|
163
|
+
const sub = {
|
|
164
|
+
name,
|
|
165
|
+
placeholder: placeholder(),
|
|
166
|
+
value,
|
|
167
|
+
hosts: [...hosts],
|
|
168
|
+
in: [...where],
|
|
169
|
+
phase
|
|
170
|
+
};
|
|
171
|
+
secrets.brokered.push(sub);
|
|
172
|
+
return sub.placeholder;
|
|
173
|
+
}
|
|
174
|
+
/** The host value, or its placeholder when it's brokered to `secret.hosts` (else `hosts`). */
|
|
175
|
+
function hostValue(name, secret, where, secrets, hosts = secret.hosts) {
|
|
176
|
+
const from = secret.from ?? name;
|
|
177
|
+
const value = process.env[from];
|
|
178
|
+
if (!value) {
|
|
179
|
+
if (secret.optional) {
|
|
180
|
+
secrets.unset.push(name);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const source = from === name ? name : `${name} (from ${from})`;
|
|
184
|
+
throw new InfraError(`Secret ${source} for ${where} isn't set. Set it in the environment or .env.local.`);
|
|
185
|
+
}
|
|
186
|
+
registerSecret(value);
|
|
187
|
+
if (secret.to === "checks") secrets.checks.push(name);
|
|
188
|
+
else secrets.names.push(name);
|
|
189
|
+
if (!hosts) return value;
|
|
190
|
+
return broker(secrets, name, value, hosts, secret.in, secret.to === "checks" ? "checks" : "agent");
|
|
191
|
+
}
|
|
192
|
+
/** Each host variable in the template, or with `hosts` one placeholder per variable. */
|
|
193
|
+
function template(value, where, secrets, hosts) {
|
|
194
|
+
const placeholders = /* @__PURE__ */ new Map();
|
|
195
|
+
return value.template.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => {
|
|
196
|
+
const v = process.env[name];
|
|
197
|
+
if (!v) throw new InfraError(`secret.template needs ${name} for ${where}, and it isn't set`);
|
|
198
|
+
registerSecret(v);
|
|
199
|
+
if (!hosts) return v;
|
|
200
|
+
const p = placeholders.get(name) ?? broker(secrets, name, v, hosts);
|
|
201
|
+
placeholders.set(name, p);
|
|
202
|
+
return p;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/** A `SKILL.md` split into its YAML frontmatter's `name` and `description` and its body. */
|
|
206
|
+
function parseSkillMd(text, where) {
|
|
207
|
+
const match = /^---\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n|$)([\s\S]*)$/.exec(text);
|
|
208
|
+
if (!match) return { body: text };
|
|
209
|
+
let front;
|
|
210
|
+
try {
|
|
211
|
+
front = parse(match[1] ?? "");
|
|
212
|
+
} catch (error) {
|
|
213
|
+
const reason = error.message.split("\n")[0];
|
|
214
|
+
throw new InfraError(`${where}: the SKILL.md frontmatter isn't valid YAML (${reason}). Quote values that contain ": ".`);
|
|
215
|
+
}
|
|
216
|
+
if (front != null && (typeof front !== "object" || Array.isArray(front))) throw new InfraError(`${where}: the SKILL.md frontmatter must be a YAML mapping`);
|
|
217
|
+
const field = (key) => {
|
|
218
|
+
const value = front?.[key];
|
|
219
|
+
if (value == null) return void 0;
|
|
220
|
+
if (typeof value !== "string") throw new InfraError(`${where}: the SKILL.md ${key} must be a string (got ${typeof value})`);
|
|
221
|
+
return value.trim() || void 0;
|
|
222
|
+
};
|
|
223
|
+
return {
|
|
224
|
+
name: field("name"),
|
|
225
|
+
description: field("description"),
|
|
226
|
+
body: match[2] ?? ""
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** A gitignore-style test for `DEFAULT_SKILL_EXCLUDE`-shaped patterns against a skill-relative path. */
|
|
230
|
+
function excludeMatcher(patterns) {
|
|
231
|
+
const rules = patterns.map((pattern) => {
|
|
232
|
+
const dirOnly = pattern.endsWith("/");
|
|
233
|
+
const body = pattern.replace(/\/+$/, "");
|
|
234
|
+
const anchored = body.includes("/");
|
|
235
|
+
const glob = body.replace(/^\/+/, "").split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*");
|
|
236
|
+
return {
|
|
237
|
+
dirOnly,
|
|
238
|
+
re: new RegExp(`${anchored ? "^" : "(?:^|/)"}${glob}$`)
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
return (path, isDir) => rules.some((r) => (isDir || !r.dirOnly) && r.re.test(path));
|
|
242
|
+
}
|
|
243
|
+
function parseSkill(dir, source, exclude) {
|
|
244
|
+
const front = parseSkillMd(readFileSync(join(dir, "SKILL.md"), "utf8"), `Skill ${basename(dir)} in ${source}`);
|
|
245
|
+
const name = front.name || basename(dir);
|
|
246
|
+
checkSkill(name, front.description, source);
|
|
247
|
+
const skip = excludeMatcher(exclude);
|
|
248
|
+
const paths = [];
|
|
249
|
+
const excluded = [];
|
|
250
|
+
const walk = (rel) => {
|
|
251
|
+
for (const entry of readdirSync(join(dir, rel), { withFileTypes: true })) {
|
|
252
|
+
const path = rel ? `${rel}/${entry.name}` : entry.name;
|
|
253
|
+
const isDir = entry.isDirectory();
|
|
254
|
+
if (entry.name === ".git") continue;
|
|
255
|
+
if (skip(path, isDir)) excluded.push(isDir ? `${path}/` : path);
|
|
256
|
+
else if (isDir) walk(path);
|
|
257
|
+
else if (entry.isFile() && path !== "SKILL.md") paths.push(path);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
walk("");
|
|
261
|
+
const files = paths.sort().map((path) => ({
|
|
262
|
+
path,
|
|
263
|
+
content: readFileSync(join(dir, path), "utf8")
|
|
264
|
+
}));
|
|
265
|
+
return {
|
|
266
|
+
skill: {
|
|
267
|
+
name,
|
|
268
|
+
description: front.description ?? "",
|
|
269
|
+
content: front.body,
|
|
270
|
+
files
|
|
271
|
+
},
|
|
272
|
+
excluded: excluded.sort()
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function checkSkill(name, description, source) {
|
|
276
|
+
if (!SKILL_NAME.test(name)) throw new InfraError(`Skill "${name}" in ${source}: the name must be kebab-case`);
|
|
277
|
+
if (!description) throw new InfraError(`Skill "${name}" in ${source} has no description in its SKILL.md frontmatter`);
|
|
278
|
+
}
|
|
279
|
+
function loadSkills(dir, source, only, exclude = DEFAULT_SKILL_EXCLUDE) {
|
|
280
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new InfraError(`Skill source ${source} isn't a directory`);
|
|
281
|
+
const dirs = existsSync(join(dir, "SKILL.md")) ? [dir] : readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && existsSync(join(dir, e.name, "SKILL.md"))).map((e) => join(dir, e.name)).sort();
|
|
282
|
+
if (dirs.length === 0) throw new InfraError(`Skill source ${source} has no SKILL.md (and no subdirectories with one)`);
|
|
283
|
+
const skills = dirs.map((d) => parseSkill(d, source, exclude));
|
|
284
|
+
if (!only) return skills;
|
|
285
|
+
const missing = only.filter((n) => !skills.some((s) => s.skill.name === n));
|
|
286
|
+
if (missing.length) throw new InfraError(`only: ${JSON.stringify(missing)} not found in ${source} (found: ${skills.map((s) => s.skill.name).join(", ")})`);
|
|
287
|
+
return skills.filter((s) => only.includes(s.skill.name));
|
|
288
|
+
}
|
|
289
|
+
async function git(args, cwd, what) {
|
|
290
|
+
const r = await run("git", args, { cwd });
|
|
291
|
+
if (r.exitCode !== 0) throw new InfraError(`${what} failed: ${r.stderr.trim()}`);
|
|
292
|
+
return r.stdout.trim();
|
|
293
|
+
}
|
|
294
|
+
/** One clone per source and ref per worker process; the commit is recorded. */
|
|
295
|
+
const clones = /* @__PURE__ */ new Map();
|
|
296
|
+
function cloneSkills(root, url, ref) {
|
|
297
|
+
const key = `${url}#${ref ?? ""}`;
|
|
298
|
+
let clone = clones.get(key);
|
|
299
|
+
if (!clone) {
|
|
300
|
+
clone = (async () => {
|
|
301
|
+
const dir = join(root, ".reeval", "cache", "skills", `${sha256(key).slice(0, 16)}-${process.pid}`);
|
|
302
|
+
await rm(dir, {
|
|
303
|
+
recursive: true,
|
|
304
|
+
force: true
|
|
305
|
+
});
|
|
306
|
+
await mkdir(dir, { recursive: true });
|
|
307
|
+
const what = `Cloning ${url}${ref ? ` at ${ref}` : ""}`;
|
|
308
|
+
await git(["init", "-q"], dir, what);
|
|
309
|
+
await git([
|
|
310
|
+
"fetch",
|
|
311
|
+
"-q",
|
|
312
|
+
"--depth",
|
|
313
|
+
"1",
|
|
314
|
+
url,
|
|
315
|
+
ref ?? "HEAD"
|
|
316
|
+
], dir, what);
|
|
317
|
+
await git([
|
|
318
|
+
"-c",
|
|
319
|
+
"advice.detachedHead=false",
|
|
320
|
+
"checkout",
|
|
321
|
+
"-q",
|
|
322
|
+
"FETCH_HEAD"
|
|
323
|
+
], dir, what);
|
|
324
|
+
return {
|
|
325
|
+
dir,
|
|
326
|
+
commit: await git(["rev-parse", "HEAD"], dir, what)
|
|
327
|
+
};
|
|
328
|
+
})();
|
|
329
|
+
clones.set(key, clone);
|
|
330
|
+
clone.catch(() => clones.delete(key));
|
|
331
|
+
}
|
|
332
|
+
return clone;
|
|
333
|
+
}
|
|
334
|
+
const packs = /* @__PURE__ */ new Map();
|
|
335
|
+
/** `npm pack` into the cache, unpacked; returns the package directory. */
|
|
336
|
+
function unpackNpm(root, spec) {
|
|
337
|
+
let pack = packs.get(spec);
|
|
338
|
+
if (!pack) {
|
|
339
|
+
pack = (async () => {
|
|
340
|
+
const dir = join(root, ".reeval", "cache", "skills", `npm-${sha256(spec).slice(0, 16)}-${process.pid}`);
|
|
341
|
+
await rm(dir, {
|
|
342
|
+
recursive: true,
|
|
343
|
+
force: true
|
|
344
|
+
});
|
|
345
|
+
await mkdir(dir, { recursive: true });
|
|
346
|
+
const packed = await run("npm", [
|
|
347
|
+
"pack",
|
|
348
|
+
spec,
|
|
349
|
+
"--silent",
|
|
350
|
+
"--pack-destination",
|
|
351
|
+
dir
|
|
352
|
+
], { cwd: dir });
|
|
353
|
+
const tarball = packed.stdout.trim().split("\n").at(-1);
|
|
354
|
+
if (packed.exitCode !== 0 || !tarball) throw new InfraError(`npm pack ${spec} failed: ${packed.stderr.trim()}`);
|
|
355
|
+
const untar = await run("tar", ["-xzf", tarball], { cwd: dir });
|
|
356
|
+
if (untar.exitCode !== 0) throw new InfraError(`Unpacking ${spec} failed: ${untar.stderr.trim()}`);
|
|
357
|
+
return join(dir, "package");
|
|
358
|
+
})();
|
|
359
|
+
packs.set(spec, pack);
|
|
360
|
+
pack.catch(() => packs.delete(spec));
|
|
361
|
+
}
|
|
362
|
+
return pack;
|
|
363
|
+
}
|
|
364
|
+
async function resolveSkills(root, s) {
|
|
365
|
+
switch (s.source) {
|
|
366
|
+
case "local": {
|
|
367
|
+
const label = `local:${relative(root, s.path).split(sep).join("/") || "."}`;
|
|
368
|
+
return {
|
|
369
|
+
skills: loadSkills(s.path, label, s.only, s.exclude),
|
|
370
|
+
source: label,
|
|
371
|
+
commit: null
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
case "git": {
|
|
375
|
+
const { url } = parseGitRepo(s.repo);
|
|
376
|
+
const { dir, commit } = await cloneSkills(root, url, s.ref);
|
|
377
|
+
const label = `${s.repo}${s.ref ? `#${s.ref}` : ""}${s.path ? `:${s.path}` : ""}`;
|
|
378
|
+
return {
|
|
379
|
+
skills: loadSkills(s.path ? join(dir, s.path) : dir, label, s.only, s.exclude),
|
|
380
|
+
source: label,
|
|
381
|
+
commit
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
case "registry": {
|
|
385
|
+
const [repo = "", name = ""] = s.id.split("@");
|
|
386
|
+
const { dir, commit } = await cloneSkills(root, `https://github.com/${repo}.git`, s.version);
|
|
387
|
+
const label = `registry:${s.id}${s.version ? `#${s.version}` : ""}`;
|
|
388
|
+
return {
|
|
389
|
+
skills: loadSkills(existsSync(join(dir, "skills")) ? join(dir, "skills") : dir, label, [name]),
|
|
390
|
+
source: label,
|
|
391
|
+
commit
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
case "npm": {
|
|
395
|
+
const dir = await unpackNpm(root, s.spec);
|
|
396
|
+
const label = `npm:${isAbsolute(s.spec) ? relative(root, s.spec).split(sep).join("/") : s.spec}:${s.path}`;
|
|
397
|
+
return {
|
|
398
|
+
skills: loadSkills(join(dir, s.path), label, s.only, s.exclude),
|
|
399
|
+
source: label,
|
|
400
|
+
commit: null
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
case "inline": {
|
|
404
|
+
checkSkill(s.name, s.description, "skill.inline");
|
|
405
|
+
const files = Object.entries(s.files).sort(([a], [b]) => a < b ? -1 : 1).map(([path, content]) => ({
|
|
406
|
+
path,
|
|
407
|
+
content
|
|
408
|
+
}));
|
|
409
|
+
return {
|
|
410
|
+
skills: [{
|
|
411
|
+
skill: {
|
|
412
|
+
name: s.name,
|
|
413
|
+
description: s.description,
|
|
414
|
+
content: s.content,
|
|
415
|
+
files
|
|
416
|
+
},
|
|
417
|
+
excluded: []
|
|
418
|
+
}],
|
|
419
|
+
source: "inline",
|
|
420
|
+
commit: null
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const skillHash = (s) => sha256(JSON.stringify(s));
|
|
426
|
+
const readHostFile = (f, where) => {
|
|
427
|
+
if (!existsSync(f.path)) throw new InfraError(`${f.path} for ${where} doesn't exist`);
|
|
428
|
+
return readFileSync(f.path);
|
|
429
|
+
};
|
|
430
|
+
/**
|
|
431
|
+
* Layers the environment (root first; a later layer wins on shared keys, and each such key is
|
|
432
|
+
* an override), over the options `env` merged down the cascade. `where` names the attempt, and
|
|
433
|
+
* `rulesFile` is the workspace file the harness's agent reads `rules` from.
|
|
434
|
+
*/
|
|
435
|
+
async function resolveEnvironment(environment, optionsEnv, root, where, rulesFile, egress = "allow-all", prepared = () => void 0) {
|
|
436
|
+
const envWhere = `environment ${environment.name} (${where})`;
|
|
437
|
+
const overrides = /* @__PURE__ */ new Set();
|
|
438
|
+
const env = { ...optionsEnv };
|
|
439
|
+
const files = /* @__PURE__ */ new Map();
|
|
440
|
+
const mcp = /* @__PURE__ */ new Map();
|
|
441
|
+
const skills = /* @__PURE__ */ new Map();
|
|
442
|
+
let rules;
|
|
443
|
+
const setup = [];
|
|
444
|
+
const seen = {
|
|
445
|
+
env: /* @__PURE__ */ new Set(),
|
|
446
|
+
files: /* @__PURE__ */ new Set(),
|
|
447
|
+
mcp: /* @__PURE__ */ new Set()
|
|
448
|
+
};
|
|
449
|
+
let egressSet = false;
|
|
450
|
+
let workspace;
|
|
451
|
+
for (const { spec } of environment.layers) {
|
|
452
|
+
if (spec.workspace !== void 0) {
|
|
453
|
+
if (workspace) overrides.add("workspace");
|
|
454
|
+
workspace = spec.workspace;
|
|
455
|
+
}
|
|
456
|
+
if (spec.egress !== void 0) {
|
|
457
|
+
if (egressSet) overrides.add("egress");
|
|
458
|
+
egressSet = true;
|
|
459
|
+
}
|
|
460
|
+
for (const [k, v] of Object.entries(spec.env ?? {})) {
|
|
461
|
+
if (seen.env.has(k)) overrides.add(`env.${k}`);
|
|
462
|
+
seen.env.add(k);
|
|
463
|
+
env[k] = v;
|
|
464
|
+
}
|
|
465
|
+
for (const [path, v] of Object.entries(spec.files ?? {})) {
|
|
466
|
+
if (seen.files.has(path)) overrides.add(`files.${path}`);
|
|
467
|
+
seen.files.add(path);
|
|
468
|
+
files.set(path, v);
|
|
469
|
+
}
|
|
470
|
+
if (spec.rules !== void 0) {
|
|
471
|
+
if (rules !== void 0) overrides.add("rules");
|
|
472
|
+
rules = spec.rules;
|
|
473
|
+
}
|
|
474
|
+
for (const [name, server] of Object.entries(spec.mcp ?? {})) {
|
|
475
|
+
if (seen.mcp.has(name)) overrides.add(`mcp.${name}`);
|
|
476
|
+
seen.mcp.add(name);
|
|
477
|
+
mcp.set(name, server);
|
|
478
|
+
}
|
|
479
|
+
for (const step of spec.setup ?? []) {
|
|
480
|
+
const s = typeof step === "string" ? { run: step } : step;
|
|
481
|
+
setup.push({
|
|
482
|
+
run: s.run,
|
|
483
|
+
timeoutMs: parseDuration(s.timeout ?? DEFAULT_SETUP_TIMEOUT),
|
|
484
|
+
...s.cwd ? { cwd: s.cwd } : {}
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
const layerSkills = /* @__PURE__ */ new Set();
|
|
488
|
+
for (const source of spec.skills ?? []) {
|
|
489
|
+
const found = await resolveSkills(root, source);
|
|
490
|
+
for (const loaded of found.skills) {
|
|
491
|
+
const name = loaded.skill.name;
|
|
492
|
+
if (skills.has(name) && !layerSkills.has(name)) overrides.add(`skills.${name}`);
|
|
493
|
+
layerSkills.add(name);
|
|
494
|
+
skills.delete(name);
|
|
495
|
+
skills.set(name, {
|
|
496
|
+
...loaded,
|
|
497
|
+
placement: source.placement,
|
|
498
|
+
source: found.source,
|
|
499
|
+
commit: found.commit
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const secrets = {
|
|
505
|
+
names: [],
|
|
506
|
+
checks: [],
|
|
507
|
+
unset: [],
|
|
508
|
+
brokered: []
|
|
509
|
+
};
|
|
510
|
+
const vars = {};
|
|
511
|
+
const checkVars = {};
|
|
512
|
+
for (const [name, value] of Object.entries(env)) {
|
|
513
|
+
if (!isSecret(value)) {
|
|
514
|
+
vars[name] = value;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const resolved = hostValue(name, value, envWhere, secrets);
|
|
518
|
+
if (resolved === void 0) continue;
|
|
519
|
+
if (value.to === "checks") checkVars[name] = resolved;
|
|
520
|
+
else vars[name] = resolved;
|
|
521
|
+
}
|
|
522
|
+
const secretOf = (value, key, hosts) => {
|
|
523
|
+
if (typeof value === "string") return value;
|
|
524
|
+
if (isSecretTemplate(value)) return template(value, envWhere, secrets, hosts);
|
|
525
|
+
return hostValue(key, value, envWhere, secrets, value.hosts ?? hosts);
|
|
526
|
+
};
|
|
527
|
+
const resolvedMcp = {};
|
|
528
|
+
for (const [name, s] of mcp) if (s.transport === "stdio") {
|
|
529
|
+
const e = {};
|
|
530
|
+
for (const [k, v] of Object.entries(s.env)) {
|
|
531
|
+
const r = secretOf(v, k);
|
|
532
|
+
if (r !== void 0) e[k] = r;
|
|
533
|
+
}
|
|
534
|
+
resolvedMcp[name] = {
|
|
535
|
+
transport: "stdio",
|
|
536
|
+
command: s.command,
|
|
537
|
+
args: s.args,
|
|
538
|
+
env: e
|
|
539
|
+
};
|
|
540
|
+
} else {
|
|
541
|
+
const h = {};
|
|
542
|
+
const hosts = mcpHosts(s.url);
|
|
543
|
+
for (const [k, v] of Object.entries(s.headers)) {
|
|
544
|
+
const r = secretOf(v, k, hosts);
|
|
545
|
+
if (r !== void 0) h[k] = r;
|
|
546
|
+
}
|
|
547
|
+
resolvedMcp[name] = {
|
|
548
|
+
transport: s.transport,
|
|
549
|
+
url: s.url,
|
|
550
|
+
headers: h
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
const fileList = [...files].map(([path, v]) => ({
|
|
554
|
+
path,
|
|
555
|
+
content: isHostFile(v) ? readHostFile(v, envWhere) : v
|
|
556
|
+
}));
|
|
557
|
+
const rulesContent = rules === void 0 ? void 0 : isHostFile(rules) ? Buffer.from(readHostFile(rules, envWhere)).toString("utf8") : rules;
|
|
558
|
+
const skillList = [...skills.values()];
|
|
559
|
+
const skillRecords = skillList.map(({ skill, placement, source, commit, excluded }) => ({
|
|
560
|
+
name: skill.name,
|
|
561
|
+
source,
|
|
562
|
+
commit,
|
|
563
|
+
sha256: skillHash(skill),
|
|
564
|
+
placement,
|
|
565
|
+
excluded
|
|
566
|
+
}));
|
|
567
|
+
const fileRecords = fileList.map((f) => ({
|
|
568
|
+
path: f.path,
|
|
569
|
+
sha256: sha256(f.content)
|
|
570
|
+
}));
|
|
571
|
+
const rulesRecord = rulesContent === void 0 ? void 0 : {
|
|
572
|
+
path: rulesFile,
|
|
573
|
+
sha256: sha256(rulesContent)
|
|
574
|
+
};
|
|
575
|
+
if (rulesContent !== void 0) fileList.push({
|
|
576
|
+
path: rulesFile,
|
|
577
|
+
content: rulesContent
|
|
578
|
+
});
|
|
579
|
+
const secretNames = [.../* @__PURE__ */ new Set([...secrets.names, ...secrets.checks])].sort();
|
|
580
|
+
const brokered = secrets.brokered.map((b) => JSON.stringify([
|
|
581
|
+
b.name,
|
|
582
|
+
b.hosts,
|
|
583
|
+
b.in,
|
|
584
|
+
b.phase
|
|
585
|
+
])).sort();
|
|
586
|
+
const brokeredNames = [...new Set(secrets.brokered.map((b) => b.name))].sort();
|
|
587
|
+
const varNames = Object.keys(vars).filter((n) => !secrets.names.includes(n)).sort();
|
|
588
|
+
const literalVars = Object.fromEntries(varNames.map((n) => [n, vars[n]]));
|
|
589
|
+
const mcpShape = Object.fromEntries(Object.entries(resolvedMcp).map(([name, s]) => s.transport === "stdio" ? [name, {
|
|
590
|
+
command: s.command,
|
|
591
|
+
args: s.args,
|
|
592
|
+
env: Object.keys(s.env).sort()
|
|
593
|
+
}] : [name, {
|
|
594
|
+
transport: s.transport,
|
|
595
|
+
url: s.url,
|
|
596
|
+
headers: Object.keys(s.headers).sort()
|
|
597
|
+
}]));
|
|
598
|
+
const tree = workspace && prepared(workspace);
|
|
599
|
+
if (workspace && !tree) throw new InfraError(`The workspace git(${JSON.stringify(workspaceLabel(workspace))}) for ${envWhere} wasn't prepared before the run started`);
|
|
600
|
+
const workspaceRecord = workspace && tree ? {
|
|
601
|
+
source: isAbsolute(workspace.url) ? relative(root, workspace.url).split(sep).join("/") || "." : workspace.repo,
|
|
602
|
+
ref: workspace.ref ?? null,
|
|
603
|
+
path: workspace.path ?? null,
|
|
604
|
+
commit: tree.commit,
|
|
605
|
+
tree: tree.tree,
|
|
606
|
+
...workspace.init ? { init: true } : {}
|
|
607
|
+
} : void 0;
|
|
608
|
+
const digest = `sha256:${sha256(JSON.stringify({
|
|
609
|
+
setup,
|
|
610
|
+
skills: skillRecords.map((s) => [
|
|
611
|
+
s.name,
|
|
612
|
+
s.sha256,
|
|
613
|
+
s.placement
|
|
614
|
+
]),
|
|
615
|
+
mcp: mcpShape,
|
|
616
|
+
files: fileRecords,
|
|
617
|
+
rules: rulesRecord?.sha256,
|
|
618
|
+
vars: literalVars,
|
|
619
|
+
secrets: secretNames,
|
|
620
|
+
checks: [...secrets.checks].sort(),
|
|
621
|
+
unset: [...secrets.unset].sort(),
|
|
622
|
+
brokered,
|
|
623
|
+
...egress === "allow-all" ? {} : { egress: typeof egress === "string" ? egress : [...egress.allow].sort() },
|
|
624
|
+
...workspaceRecord ? { workspace: workspaceRecord.init ? [workspaceRecord.tree, "init"] : workspaceRecord.tree } : {}
|
|
625
|
+
}))}`;
|
|
626
|
+
return {
|
|
627
|
+
record: {
|
|
628
|
+
name: environment.name,
|
|
629
|
+
parent: environment.parent,
|
|
630
|
+
overrides: [...overrides],
|
|
631
|
+
digest,
|
|
632
|
+
setup: setup.map(stepCommand),
|
|
633
|
+
skills: skillRecords,
|
|
634
|
+
mcp: Object.keys(resolvedMcp).sort(),
|
|
635
|
+
files: rulesRecord ? [...fileRecords, rulesRecord] : fileRecords,
|
|
636
|
+
vars: varNames,
|
|
637
|
+
secrets: secretNames,
|
|
638
|
+
brokered: brokeredNames,
|
|
639
|
+
unset: [...secrets.unset].sort(),
|
|
640
|
+
egress,
|
|
641
|
+
...workspaceRecord ? { workspace: workspaceRecord } : {}
|
|
642
|
+
},
|
|
643
|
+
setup,
|
|
644
|
+
skills: skillList.filter((s) => s.placement === "harness").map((s) => s.skill),
|
|
645
|
+
workspaceSkills: skillList.filter((s) => s.placement === "workspace").map((s) => s.skill),
|
|
646
|
+
mcp: resolvedMcp,
|
|
647
|
+
files: fileList,
|
|
648
|
+
vars,
|
|
649
|
+
checkVars,
|
|
650
|
+
substitutions: secrets.brokered,
|
|
651
|
+
...workspace && tree ? { workspace: {
|
|
652
|
+
tar: tree.tar,
|
|
653
|
+
label: workspaceLabel(workspace),
|
|
654
|
+
commit: tree.commit,
|
|
655
|
+
...workspace.init ? { init: true } : {}
|
|
656
|
+
} } : {}
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
/** A skill as a `SKILL.md` file plus its other files, for writing into a directory. */
|
|
660
|
+
function skillFiles(skill) {
|
|
661
|
+
return [{
|
|
662
|
+
path: "SKILL.md",
|
|
663
|
+
content: `${`---\nname: ${skill.name}\ndescription: ${JSON.stringify(skill.description)}\n---\n`}${skill.content}`
|
|
664
|
+
}, ...skill.files];
|
|
665
|
+
}
|
|
666
|
+
//#endregion
|
|
667
|
+
//#region src/harness/fake.ts
|
|
668
|
+
const lineCount = (text) => text ? text.split("\n").length - (text.endsWith("\n") ? 1 : 0) : 0;
|
|
669
|
+
/**
|
|
670
|
+
* Writes canned files and returns a canned message on every turn, after running its `script`'s
|
|
671
|
+
* real sandbox actions. Harness-placed skills are written under `~/` where its layout's adapter
|
|
672
|
+
* puts them (`~/.agents/skills` by default, as Cursor's does).
|
|
673
|
+
*/
|
|
674
|
+
const createFakeHarness = (descriptor) => {
|
|
675
|
+
let starts = 0;
|
|
676
|
+
return { async start(sandbox, _signal, { skills, workspaceSkills, mcp }) {
|
|
677
|
+
if (descriptor.failWith) throw new InfraError(descriptor.failWith);
|
|
678
|
+
const hangs = descriptor.startHangs;
|
|
679
|
+
if (hangs === true || starts++ < (hangs ?? 0)) return new Promise(() => {});
|
|
680
|
+
const dir = `~/${layoutOf(descriptor).harnessSkillDir}`;
|
|
681
|
+
for (const skill of skills) {
|
|
682
|
+
for (const f of skillFiles(skill)) await sandbox.writeFile(`${dir}/${skill.name}/${f.path}`, f.content);
|
|
683
|
+
debugLog("env", `skill ${skill.name} placed at ${dir}/${skill.name}/ by the fake agent`);
|
|
684
|
+
}
|
|
685
|
+
const names = [...skills.map((s) => s.name), ...workspaceSkills];
|
|
686
|
+
const loaded = [names.length ? `skills: ${names.join(", ")}` : "", Object.keys(mcp).length ? `mcp: ${Object.keys(mcp).join(", ")}` : ""].filter(Boolean);
|
|
687
|
+
let calls = 0;
|
|
688
|
+
let turns = 0;
|
|
689
|
+
const skilled = names.length > 0 && descriptor.withSkill;
|
|
690
|
+
const files = (skilled ? descriptor.withSkill?.files : void 0) ?? descriptor.files ?? {};
|
|
691
|
+
const reply = (skilled ? descriptor.withSkill?.text : void 0) ?? descriptor.text ?? "Done.";
|
|
692
|
+
const flaked = Math.random() < (descriptor.flakeRate ?? 0);
|
|
693
|
+
return {
|
|
694
|
+
async send(prompt, signal) {
|
|
695
|
+
const startedAt = Date.now();
|
|
696
|
+
const script = descriptor.script ?? [];
|
|
697
|
+
const steps = script[Math.min(turns++, script.length - 1)] ?? [];
|
|
698
|
+
const at = () => {
|
|
699
|
+
const ms = Date.now() - startedAt;
|
|
700
|
+
return {
|
|
701
|
+
ts: new Date(startedAt + ms).toISOString(),
|
|
702
|
+
atMs: ms
|
|
703
|
+
};
|
|
704
|
+
};
|
|
705
|
+
const transcript = [{
|
|
706
|
+
...at(),
|
|
707
|
+
type: "text",
|
|
708
|
+
text: `Working on: ${prompt}`,
|
|
709
|
+
durationMs: 0
|
|
710
|
+
}, ...loaded.length ? [{
|
|
711
|
+
...at(),
|
|
712
|
+
type: "text",
|
|
713
|
+
text: `Loaded ${loaded.join("; ")}`,
|
|
714
|
+
durationMs: 0
|
|
715
|
+
}] : []];
|
|
716
|
+
const done = (text, status) => ({
|
|
717
|
+
text,
|
|
718
|
+
status,
|
|
719
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
720
|
+
durationMs: Date.now() - startedAt,
|
|
721
|
+
transcript,
|
|
722
|
+
usage: null,
|
|
723
|
+
model: descriptor.servedModel ?? null,
|
|
724
|
+
unreported: []
|
|
725
|
+
});
|
|
726
|
+
const write = async (path, content) => {
|
|
727
|
+
const time = at();
|
|
728
|
+
const before = await sandbox.readFile(path);
|
|
729
|
+
await sandbox.writeFile(path, content);
|
|
730
|
+
debugLog("harness", `tool write ${path} (${before === null ? "create" : "modify"})`);
|
|
731
|
+
transcript.push({
|
|
732
|
+
...time,
|
|
733
|
+
type: "file-edit",
|
|
734
|
+
toolCallId: `call_${++calls}`,
|
|
735
|
+
path,
|
|
736
|
+
kind: before === null ? "create" : "modify",
|
|
737
|
+
added: lineCount(content),
|
|
738
|
+
removed: before === null ? 0 : lineCount(before),
|
|
739
|
+
durationMs: Date.now() - startedAt - time.atMs
|
|
740
|
+
});
|
|
741
|
+
};
|
|
742
|
+
if (descriptor.reasoning) transcript.push({
|
|
743
|
+
...at(),
|
|
744
|
+
type: "reasoning",
|
|
745
|
+
text: descriptor.reasoning,
|
|
746
|
+
durationMs: 0
|
|
747
|
+
});
|
|
748
|
+
for (const tool of descriptor.tools ?? []) transcript.push({
|
|
749
|
+
...at(),
|
|
750
|
+
type: "tool",
|
|
751
|
+
toolCallId: `call_${++calls}`,
|
|
752
|
+
tool: tool.tool,
|
|
753
|
+
input: tool.input,
|
|
754
|
+
output: tool.output,
|
|
755
|
+
ok: tool.ok ?? true,
|
|
756
|
+
durationMs: 0
|
|
757
|
+
});
|
|
758
|
+
if (flaked) {
|
|
759
|
+
const text = "I ran out of ideas and left the workspace unchanged.";
|
|
760
|
+
transcript.push({
|
|
761
|
+
...at(),
|
|
762
|
+
type: "text",
|
|
763
|
+
text,
|
|
764
|
+
durationMs: 0
|
|
765
|
+
});
|
|
766
|
+
return done(text, "completed");
|
|
767
|
+
}
|
|
768
|
+
let scripted;
|
|
769
|
+
for (const step of steps) if ("exec" in step) {
|
|
770
|
+
const time = at();
|
|
771
|
+
debugLog("harness", `tool shell called: $ ${step.exec}`);
|
|
772
|
+
const r = await sandbox.exec(step.exec, { signal });
|
|
773
|
+
debugLog("harness", `tool shell returned exit ${r.exitCode} in ${Date.now() - startedAt - time.atMs}ms`);
|
|
774
|
+
transcript.push({
|
|
775
|
+
...time,
|
|
776
|
+
type: "tool",
|
|
777
|
+
toolCallId: `call_${++calls}`,
|
|
778
|
+
tool: "shell",
|
|
779
|
+
input: { command: step.exec },
|
|
780
|
+
output: r.stdout,
|
|
781
|
+
ok: r.exitCode === 0,
|
|
782
|
+
durationMs: Date.now() - startedAt - time.atMs
|
|
783
|
+
});
|
|
784
|
+
} else if ("write" in step) for (const [path, content] of Object.entries(step.write)) await write(path, content);
|
|
785
|
+
else if ("mcp" in step) {
|
|
786
|
+
const time = at();
|
|
787
|
+
const [server = "", tool = ""] = step.mcp.split("/");
|
|
788
|
+
debugLog("harness", `tool mcp__${server}__${tool} called`);
|
|
789
|
+
const r = await callMcpTool(sandbox, mcp, server, tool, signal);
|
|
790
|
+
debugLog("harness", `tool mcp__${server}__${tool} ${r.ok ? "returned" : "failed"}`);
|
|
791
|
+
transcript.push({
|
|
792
|
+
...time,
|
|
793
|
+
type: "tool",
|
|
794
|
+
toolCallId: `call_${++calls}`,
|
|
795
|
+
tool: `mcp__${server}__${tool}`,
|
|
796
|
+
input: {},
|
|
797
|
+
output: r.output,
|
|
798
|
+
ok: r.ok,
|
|
799
|
+
durationMs: Date.now() - startedAt - time.atMs
|
|
800
|
+
});
|
|
801
|
+
} else if ("error" in step) {
|
|
802
|
+
debugLog("harness", `turn failed: ${step.error}`);
|
|
803
|
+
transcript.push({
|
|
804
|
+
...at(),
|
|
805
|
+
type: "error",
|
|
806
|
+
message: step.error
|
|
807
|
+
});
|
|
808
|
+
return done("", "error");
|
|
809
|
+
} else scripted = step.reply;
|
|
810
|
+
if (descriptor.delayMs) try {
|
|
811
|
+
await setTimeout$1(descriptor.delayMs, void 0, descriptor.ignoreAbort ? {} : { signal });
|
|
812
|
+
} catch {
|
|
813
|
+
return done("", "aborted");
|
|
814
|
+
}
|
|
815
|
+
for (const [path, content] of Object.entries(files)) await write(path, content);
|
|
816
|
+
const text = scripted ?? reply;
|
|
817
|
+
transcript.push({
|
|
818
|
+
...at(),
|
|
819
|
+
type: "text",
|
|
820
|
+
text,
|
|
821
|
+
durationMs: 0
|
|
822
|
+
});
|
|
823
|
+
return done(text, "completed");
|
|
824
|
+
},
|
|
825
|
+
async close() {}
|
|
826
|
+
};
|
|
827
|
+
} };
|
|
828
|
+
};
|
|
829
|
+
/**
|
|
830
|
+
* Starts a stdio MCP server in the sandbox, as an agent would, and calls one tool with no
|
|
831
|
+
* arguments: initialize, then the call.
|
|
832
|
+
*/
|
|
833
|
+
async function callMcpTool(sandbox, servers, name, tool, signal) {
|
|
834
|
+
const server = servers[name];
|
|
835
|
+
if (!server) {
|
|
836
|
+
const known = Object.keys(servers);
|
|
837
|
+
throw new InfraError(`The fake agent's script calls MCP server "${name}", which the environment doesn't have (it has ${known.length ? known.join(", ") : "none"})`);
|
|
838
|
+
}
|
|
839
|
+
if (server.transport !== "stdio") throw new InfraError(`The fake agent calls stdio MCP servers only; "${name}" is ${server.transport}`);
|
|
840
|
+
const messages = [
|
|
841
|
+
{
|
|
842
|
+
jsonrpc: "2.0",
|
|
843
|
+
id: 1,
|
|
844
|
+
method: "initialize",
|
|
845
|
+
params: {
|
|
846
|
+
protocolVersion: "2024-11-05",
|
|
847
|
+
capabilities: {},
|
|
848
|
+
clientInfo: {
|
|
849
|
+
name: "reeval-fake-agent",
|
|
850
|
+
version: "1"
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
jsonrpc: "2.0",
|
|
856
|
+
method: "notifications/initialized"
|
|
857
|
+
},
|
|
858
|
+
{
|
|
859
|
+
jsonrpc: "2.0",
|
|
860
|
+
id: 2,
|
|
861
|
+
method: "tools/call",
|
|
862
|
+
params: {
|
|
863
|
+
name: tool,
|
|
864
|
+
arguments: {}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
].map((m) => shellQuote(JSON.stringify(m)));
|
|
868
|
+
const command = [server.command, ...server.args].map(shellQuote).join(" ");
|
|
869
|
+
const script = [
|
|
870
|
+
"d=$(mktemp -d) && : > \"$d/out\" && : > \"$d/err\" || exit 1",
|
|
871
|
+
"{",
|
|
872
|
+
" trap '' PIPE",
|
|
873
|
+
` printf '%s\\n' ${messages.join(" ")} 2>/dev/null`,
|
|
874
|
+
" i=0",
|
|
875
|
+
` while [ $i -lt 300 ] && [ ! -f "$d/code" ] && ! grep -Eq '"id": ?2[,}]' "$d/out"; do sleep 0.1; i=$((i + 1)); done`,
|
|
876
|
+
`} | { ${command} > "$d/out" 2> "$d/err"; echo $? > "$d/code"; }`,
|
|
877
|
+
"code=$(cat \"$d/code\")",
|
|
878
|
+
"cat \"$d/out\"; cat \"$d/err\" >&2; rm -rf \"$d\"; exit $code"
|
|
879
|
+
].join("\n");
|
|
880
|
+
const r = await sandbox.exec(script, {
|
|
881
|
+
signal,
|
|
882
|
+
env: server.env
|
|
883
|
+
});
|
|
884
|
+
const reply = r.stdout.split("\n").flatMap((line) => {
|
|
885
|
+
try {
|
|
886
|
+
return [JSON.parse(line)];
|
|
887
|
+
} catch {
|
|
888
|
+
return [];
|
|
889
|
+
}
|
|
890
|
+
}).find((m) => m.id === 2);
|
|
891
|
+
if (!reply) return {
|
|
892
|
+
output: r.stderr.trim() || `no reply (exit ${r.exitCode})`,
|
|
893
|
+
ok: false
|
|
894
|
+
};
|
|
895
|
+
if (reply.error) return {
|
|
896
|
+
output: reply.error.message,
|
|
897
|
+
ok: false
|
|
898
|
+
};
|
|
899
|
+
return {
|
|
900
|
+
output: (reply.result?.content ?? []).flatMap((c) => c.type === "text" ? [c.text] : []).join("\n"),
|
|
901
|
+
ok: !reply.result?.isError
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
//#endregion
|
|
905
|
+
//#region src/harness/index.ts
|
|
906
|
+
async function createHarness(descriptor) {
|
|
907
|
+
if (descriptor.kind === "fake") return createFakeHarness(descriptor);
|
|
908
|
+
const { createAgentHarness } = await import("./agent-DVHkx8aT.js");
|
|
909
|
+
return createAgentHarness(descriptor);
|
|
910
|
+
}
|
|
911
|
+
/** Where each harness's agent looks for repo skills, relative to the workspace. */
|
|
912
|
+
const workspaceSkillDir = (descriptor) => layoutOf(descriptor).workspaceSkillDir;
|
|
913
|
+
//#endregion
|
|
914
|
+
//#region src/attempt/environment.ts
|
|
915
|
+
/**
|
|
916
|
+
* Phase 1: resolves the attempt's environment (files, skills, secrets and their placeholders)
|
|
917
|
+
* and finds the image a Docker sandbox starts from, which preflight prepared. Runs on the test's
|
|
918
|
+
* signal, before the eval timer starts.
|
|
919
|
+
*/
|
|
920
|
+
async function prepareEnvironment(a, root, runId, where, signal) {
|
|
921
|
+
const { spec, record } = a;
|
|
922
|
+
const env = await resolveEnvironment(spec.environment, spec.env, root, where, layoutOf(spec.harness).rulesFile, spec.egress.policy, (w) => preparedWorkspace(root, runId, w));
|
|
923
|
+
a.env = env;
|
|
924
|
+
record.environment = env.record;
|
|
925
|
+
const { secrets, brokered } = env.record;
|
|
926
|
+
record.env = {
|
|
927
|
+
vars: Object.keys(env.vars).filter((n) => !secrets.includes(n)).sort(),
|
|
928
|
+
secrets,
|
|
929
|
+
brokered
|
|
930
|
+
};
|
|
931
|
+
const debug = attemptDebug();
|
|
932
|
+
if (debug) traceEnvironment(debug, spec, env);
|
|
933
|
+
record.image = null;
|
|
934
|
+
if (spec.sandbox.kind === "docker") {
|
|
935
|
+
const { preparedFor } = await import("./preflight-BtubXKQ8.js");
|
|
936
|
+
const { dockerProvider } = await import("./providers-BUOxitcT.js");
|
|
937
|
+
signal.throwIfAborted();
|
|
938
|
+
const prepared = preparedFor(root, runId, dockerProvider(), spec.sandbox, spec.harness);
|
|
939
|
+
if (!prepared) throw new InfraError(`The sandbox image for ${where} wasn't prepared before the run started`);
|
|
940
|
+
record.image = prepared;
|
|
941
|
+
a.image = record.image.ref;
|
|
942
|
+
const { ref, id, base, install, agentCli } = record.image;
|
|
943
|
+
debugLog("image", `ready ${ref} (${id.replace(/^sha256:/, "").slice(0, 12)}) · prepared before the run${ref !== base ? ` · on ${base}` : ""}${agentCli ? ` · agent CLI ${agentCli}` : ""}${install.length ? ` · install: ${install.join(" && ")}` : ""}`);
|
|
944
|
+
} else debugLog("image", `none: the ${spec.sandbox.kind} sandbox runs on the host`);
|
|
945
|
+
}
|
|
946
|
+
//#endregion
|
|
947
|
+
//#region src/attempt/judge.ts
|
|
948
|
+
const noJudgeModel = (configFile) => `t.judge() has no model. Set judge: { model: 'typesafe-ai/jev-1.13.0' } in ${configFile}, on the group or eval, or pass { model } to this call.`;
|
|
949
|
+
const isTurn = (value) => typeof value === "object" && value !== null && "transcript" in value && "diff" in value;
|
|
950
|
+
function judgeState(turn, output = turn.text) {
|
|
951
|
+
return {
|
|
952
|
+
task: turn.prompt,
|
|
953
|
+
output,
|
|
954
|
+
files: changedFilesForJudge(turn.diff.files),
|
|
955
|
+
diff: patchForJudge(turn.diff.patch)
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Phase 4, the judge: grades a turn (the last by default) or a value against a question, under
|
|
960
|
+
* the judge provider's concurrency lease. Every call's input is recorded, including calls that
|
|
961
|
+
* fail, and a failure marks the attempt's assertion errors as the judge's.
|
|
962
|
+
*/
|
|
963
|
+
function judgeCaller(a) {
|
|
964
|
+
return async (question, { on, ...call } = {}) => {
|
|
965
|
+
if (typeof question !== "string" || !question.trim()) throw new Error(`t.judge() needs a question: a non-empty string (got ${JSON.stringify(question)})`);
|
|
966
|
+
const { threshold } = call;
|
|
967
|
+
if (threshold !== void 0 && !(typeof threshold === "number" && threshold >= 0 && threshold <= 1)) throw new Error(`t.judge() threshold must be between 0 and 1 (got ${String(threshold)})`);
|
|
968
|
+
const subject = isTurn(on) ? on : a.turns.at(-1);
|
|
969
|
+
if (!subject) throw new Error("t.judge() has no turn to grade: call t.send() first");
|
|
970
|
+
const options = {
|
|
971
|
+
...a.spec.judge,
|
|
972
|
+
...call
|
|
973
|
+
};
|
|
974
|
+
const { model } = options;
|
|
975
|
+
if (model === void 0) {
|
|
976
|
+
a.judgeErrored = true;
|
|
977
|
+
throw new Error(noJudgeModel(getRunContext().config.file));
|
|
978
|
+
}
|
|
979
|
+
const state = judgeState(subject, on === void 0 || isTurn(on) ? void 0 : on);
|
|
980
|
+
const input = {
|
|
981
|
+
question,
|
|
982
|
+
model: judgeModelName(model),
|
|
983
|
+
state
|
|
984
|
+
};
|
|
985
|
+
a.record.judgeInputs?.push(input);
|
|
986
|
+
const start = performance.now();
|
|
987
|
+
const ms = () => Math.round(performance.now() - start);
|
|
988
|
+
a.report({
|
|
989
|
+
kind: "judge-start",
|
|
990
|
+
question
|
|
991
|
+
});
|
|
992
|
+
debugLog("judge", `asking ${input.model}: ${JSON.stringify(clip(question, 160))}`);
|
|
993
|
+
debugTrace("judge", "prompt", input, "judge prompt");
|
|
994
|
+
const { maxConcurrent, ...judgeOptions } = options;
|
|
995
|
+
const provider = judgeModelName(model).split("/")[0];
|
|
996
|
+
try {
|
|
997
|
+
const { verdict, retries } = await a.timed("judgeMs", () => withLease(maxConcurrent ? { [`judge:${provider}`]: maxConcurrent } : {}, () => runJudge(question, state, {
|
|
998
|
+
...judgeOptions,
|
|
999
|
+
model,
|
|
1000
|
+
threshold: options.threshold ?? .8
|
|
1001
|
+
}, a.signal), a.signal));
|
|
1002
|
+
a.record.judgements.push(retries.length ? {
|
|
1003
|
+
...verdict,
|
|
1004
|
+
retries
|
|
1005
|
+
} : verdict);
|
|
1006
|
+
const { score, threshold } = verdict;
|
|
1007
|
+
debugLog("judge", `${verdict.resolvedModel ?? verdict.model} · ${formatDuration(ms())} · score ${score.toFixed(2)} ${score >= threshold ? "≥" : "<"} ${threshold} · ${verdict.verdict}${retries.length ? ` · ${retries.length} retries` : ""} · reason: P(yes) ${score.toFixed(2)} is ${score >= threshold ? "at or above" : "below"} the ${threshold} threshold`);
|
|
1008
|
+
debugTrace("judge", "response", {
|
|
1009
|
+
verdict,
|
|
1010
|
+
retries
|
|
1011
|
+
}, "judge response");
|
|
1012
|
+
a.report({
|
|
1013
|
+
kind: "judge-end",
|
|
1014
|
+
question,
|
|
1015
|
+
verdict: verdict.verdict,
|
|
1016
|
+
score,
|
|
1017
|
+
threshold,
|
|
1018
|
+
ms: ms()
|
|
1019
|
+
});
|
|
1020
|
+
return verdict;
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
a.judgeErrored = true;
|
|
1023
|
+
const { retries } = error;
|
|
1024
|
+
input.error = error instanceof Error ? error.message : String(error);
|
|
1025
|
+
if (retries?.length) input.retries = retries;
|
|
1026
|
+
debugLog("judge", `${input.model} · ${formatDuration(ms())} · error: ${input.error}`);
|
|
1027
|
+
debugTrace("judge", "error", {
|
|
1028
|
+
error: input.error,
|
|
1029
|
+
retries
|
|
1030
|
+
}, "judge error");
|
|
1031
|
+
a.report({
|
|
1032
|
+
kind: "judge-end",
|
|
1033
|
+
question,
|
|
1034
|
+
verdict: "error",
|
|
1035
|
+
score: null,
|
|
1036
|
+
threshold: null,
|
|
1037
|
+
ms: ms()
|
|
1038
|
+
});
|
|
1039
|
+
throw error;
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
//#endregion
|
|
1044
|
+
//#region src/sandbox/index.ts
|
|
1045
|
+
async function createSandbox(descriptor, options) {
|
|
1046
|
+
try {
|
|
1047
|
+
switch (descriptor.kind) {
|
|
1048
|
+
case "fake": return await createFakeSandbox(descriptor, options.env);
|
|
1049
|
+
case "docker": {
|
|
1050
|
+
const { createDockerSandbox } = await import("./docker-DnWAVdEm.js");
|
|
1051
|
+
return await createDockerSandbox(descriptor, options);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
if (error instanceof InfraError || options.signal.aborted) throw error;
|
|
1056
|
+
throw new InfraError(`Failed to create ${descriptor.kind} sandbox: ${error.message}`);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
//#endregion
|
|
1060
|
+
//#region src/attempt/record.ts
|
|
1061
|
+
function classify(error, signal) {
|
|
1062
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1063
|
+
if (signal.aborted) {
|
|
1064
|
+
const reason = signal.reason instanceof Error ? signal.reason.message : String(signal.reason);
|
|
1065
|
+
return isTimeout(reason) ? {
|
|
1066
|
+
kind: "timeout",
|
|
1067
|
+
message: "The eval timed out"
|
|
1068
|
+
} : {
|
|
1069
|
+
kind: "aborted",
|
|
1070
|
+
message: "The run was cancelled"
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
if (error instanceof InfraError) return {
|
|
1074
|
+
kind: "infra",
|
|
1075
|
+
message
|
|
1076
|
+
};
|
|
1077
|
+
return {
|
|
1078
|
+
kind: "assertion",
|
|
1079
|
+
message
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Why the attempt failed, into the record: the eval's own timeout, a cancel, an infra error, or an
|
|
1084
|
+
* assertion, which is the judge's when a judge call failed or gave a failing verdict.
|
|
1085
|
+
*/
|
|
1086
|
+
function recordFailure(a, error, timedOut) {
|
|
1087
|
+
const { record } = a;
|
|
1088
|
+
record.error = timedOut ? {
|
|
1089
|
+
kind: "timeout",
|
|
1090
|
+
message: "The eval timed out"
|
|
1091
|
+
} : classify(error, a.signal);
|
|
1092
|
+
record.error.message = capMiddle(record.error.message);
|
|
1093
|
+
if (error instanceof PrepareError && !timedOut) {
|
|
1094
|
+
const { class: kind, stage, ref, cause, log, setBy } = error.failure;
|
|
1095
|
+
record.error.prepare = {
|
|
1096
|
+
class: kind,
|
|
1097
|
+
stage,
|
|
1098
|
+
ref,
|
|
1099
|
+
cause,
|
|
1100
|
+
log,
|
|
1101
|
+
setBy: setBy ?? []
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
const judgeFailed = a.judgeErrored || record.judgements.some((j) => j.verdict !== "pass");
|
|
1105
|
+
if (record.error.kind === "assertion" && judgeFailed) record.error.kind = "judge";
|
|
1106
|
+
record.outcome = record.error.kind === "infra" ? "infra_error" : "fail";
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* The attempt's egress policy and, when its sandbox records it, what it contacted and was refused.
|
|
1110
|
+
* Refused hosts the harness always tries are `expectedBlocked`, summarised in one `-v` line.
|
|
1111
|
+
*/
|
|
1112
|
+
function recordEgress(a, sandbox) {
|
|
1113
|
+
const traffic = sandbox?.traffic?.();
|
|
1114
|
+
const expected = expectedBlockedFor(a.spec.harness);
|
|
1115
|
+
const refused = traffic?.blocked ?? [];
|
|
1116
|
+
const isExpected = (h) => !!expected?.hosts.includes(h.host);
|
|
1117
|
+
const expectedBlocked = refused.filter(isExpected);
|
|
1118
|
+
a.record.egress = {
|
|
1119
|
+
policy: a.spec.egress.policy,
|
|
1120
|
+
allowed: allowedHosts(a.spec.egress),
|
|
1121
|
+
recorded: !!traffic,
|
|
1122
|
+
contacted: traffic?.contacted ?? [],
|
|
1123
|
+
blocked: refused.filter((h) => !isExpected(h)),
|
|
1124
|
+
expectedBlocked
|
|
1125
|
+
};
|
|
1126
|
+
if (expected && expectedBlocked.length) {
|
|
1127
|
+
const hosts = expectedBlocked.map((h) => `${h.host}:${h.port} ×${h.connections}`).join(", ");
|
|
1128
|
+
debugLog("gateway", `expected blocks, not logged per request: ${hosts} · ${expected.why}`);
|
|
1129
|
+
}
|
|
1130
|
+
a.record.servedModels = sandbox?.servedModels?.() ?? null;
|
|
1131
|
+
}
|
|
1132
|
+
/** Phase 6: writes the attempt's record, redacted, and returns what the runner reports. */
|
|
1133
|
+
async function writeRecord(a, stagingDir, id) {
|
|
1134
|
+
const { record, spec } = a;
|
|
1135
|
+
const recordPath = join(stagingDir, "evals", recordFileName(attemptKey(id, spec.cell.key, spec.n)));
|
|
1136
|
+
await mkdir(join(stagingDir, "evals"), { recursive: true });
|
|
1137
|
+
const tmp = `${recordPath}.${process.pid}.tmp`;
|
|
1138
|
+
await writeFile(tmp, redactSecrets(JSON.stringify(record, null, 2)));
|
|
1139
|
+
await rename(tmp, recordPath);
|
|
1140
|
+
return {
|
|
1141
|
+
outcome: record.outcome,
|
|
1142
|
+
errorKind: record.error?.kind ?? null,
|
|
1143
|
+
judgements: record.judgements.map((j) => ({
|
|
1144
|
+
verdict: j.verdict,
|
|
1145
|
+
score: j.score,
|
|
1146
|
+
threshold: j.threshold,
|
|
1147
|
+
question: clip(j.question, 200)
|
|
1148
|
+
})),
|
|
1149
|
+
turnErrors: turnErrors(record.turns.map((t) => t.transcript)),
|
|
1150
|
+
...record.error?.prepare ? { prepare: {
|
|
1151
|
+
ref: record.error.prepare.ref,
|
|
1152
|
+
stage: record.error.prepare.stage
|
|
1153
|
+
} } : {}
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
/** `turn N error: <message>` for each turn with an error event. */
|
|
1157
|
+
function turnErrors(transcripts) {
|
|
1158
|
+
return transcripts.flatMap((events, i) => {
|
|
1159
|
+
const found = events.find((e) => e.type === "error");
|
|
1160
|
+
return found?.type === "error" ? [`turn ${i + 1} error: ${redactSecrets(clip(found.message.trim(), 500))}`] : [];
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
//#endregion
|
|
1164
|
+
//#region src/diff.ts
|
|
1165
|
+
const GIT = "git -c user.name=reeval -c user.email=reeval@localhost -c core.hooksPath=/dev/null";
|
|
1166
|
+
/** Records the workspace state before the agent runs, in a git dir outside the workspace. */
|
|
1167
|
+
async function snapshot(sandbox, signal) {
|
|
1168
|
+
const r = await sandbox.exec(`export GIT_DIR="$PWD/../.reeval-git-$(basename "$PWD")" GIT_WORK_TREE="$PWD" && ${GIT} init -q && ${GIT} add -A && ${GIT} commit -q --allow-empty -m base`, { signal });
|
|
1169
|
+
if (r.exitCode !== 0) throw new InfraError(`Failed to snapshot the workspace: ${r.stderr.trim()}`);
|
|
1170
|
+
}
|
|
1171
|
+
async function diffSinceSnapshot(sandbox, signal) {
|
|
1172
|
+
const env = `export GIT_DIR="$PWD/../.reeval-git-$(basename "$PWD")" GIT_WORK_TREE="$PWD" && ${GIT} add -A && `;
|
|
1173
|
+
const patch = await sandbox.exec(`${env}${GIT} diff --cached --binary HEAD`, { signal });
|
|
1174
|
+
const names = await sandbox.exec(`${env}${GIT} diff --cached --name-status --no-renames HEAD`, { signal });
|
|
1175
|
+
if (patch.exitCode !== 0 || names.exitCode !== 0) throw new InfraError(`Failed to diff the workspace: ${patch.stderr || names.stderr}`);
|
|
1176
|
+
const status = {
|
|
1177
|
+
A: "added",
|
|
1178
|
+
M: "modified",
|
|
1179
|
+
D: "deleted"
|
|
1180
|
+
};
|
|
1181
|
+
const files = names.stdout.split("\n").filter(Boolean).map((line) => {
|
|
1182
|
+
const [code = "M", ...rest] = line.split(" ");
|
|
1183
|
+
return {
|
|
1184
|
+
path: rest.join(" "),
|
|
1185
|
+
status: status[code[0]] ?? "modified"
|
|
1186
|
+
};
|
|
1187
|
+
});
|
|
1188
|
+
return {
|
|
1189
|
+
patch: patch.stdout,
|
|
1190
|
+
files
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
//#endregion
|
|
1194
|
+
//#region src/sandbox/phases.ts
|
|
1195
|
+
const strip = ({ phase: _, ...sub }) => sub;
|
|
1196
|
+
/**
|
|
1197
|
+
* Which brokered secrets the gateway swaps in when. `to: 'checks'` secrets are active only while
|
|
1198
|
+
* at least one check or setup step is running, so a request the agent makes between them gets
|
|
1199
|
+
* the placeholder.
|
|
1200
|
+
*/
|
|
1201
|
+
function brokerPhases(box, subs) {
|
|
1202
|
+
const agent = subs.filter((s) => s.phase === "agent").map(strip);
|
|
1203
|
+
const all = subs.map(strip);
|
|
1204
|
+
const perPhase = box.setSubstitutions && all.length > agent.length;
|
|
1205
|
+
let running = 0;
|
|
1206
|
+
return {
|
|
1207
|
+
async start() {
|
|
1208
|
+
if (all.length > 0) await box.setSubstitutions?.(agent);
|
|
1209
|
+
},
|
|
1210
|
+
async checks(fn) {
|
|
1211
|
+
if (!perPhase) return fn();
|
|
1212
|
+
if (running === 0) await box.setSubstitutions?.(all);
|
|
1213
|
+
running++;
|
|
1214
|
+
try {
|
|
1215
|
+
return await fn();
|
|
1216
|
+
} finally {
|
|
1217
|
+
if (--running === 0) await box.setSubstitutions?.(agent);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
//#endregion
|
|
1223
|
+
//#region src/attempt/state.ts
|
|
1224
|
+
/** Extra time an attempt gets, outside its eval timeout, when its image may need building. */
|
|
1225
|
+
const SETUP_BUDGET_MS = 12e5;
|
|
1226
|
+
function createAttempt(spec, signal, progress) {
|
|
1227
|
+
const record = {
|
|
1228
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1229
|
+
outcome: "pass",
|
|
1230
|
+
error: null,
|
|
1231
|
+
turns: [],
|
|
1232
|
+
diff: null,
|
|
1233
|
+
checks: [],
|
|
1234
|
+
judgements: [],
|
|
1235
|
+
timings: {
|
|
1236
|
+
setupMs: 0,
|
|
1237
|
+
agentMs: 0,
|
|
1238
|
+
diffMs: 0,
|
|
1239
|
+
checksMs: 0,
|
|
1240
|
+
judgeMs: 0
|
|
1241
|
+
},
|
|
1242
|
+
judgeInputs: []
|
|
1243
|
+
};
|
|
1244
|
+
return {
|
|
1245
|
+
spec,
|
|
1246
|
+
record,
|
|
1247
|
+
turns: [],
|
|
1248
|
+
report: (update) => progress({
|
|
1249
|
+
at: Date.now(),
|
|
1250
|
+
...update
|
|
1251
|
+
}),
|
|
1252
|
+
async timed(phase, fn) {
|
|
1253
|
+
const start = performance.now();
|
|
1254
|
+
try {
|
|
1255
|
+
return await fn();
|
|
1256
|
+
} finally {
|
|
1257
|
+
record.timings[phase] += Math.round(performance.now() - start);
|
|
1258
|
+
}
|
|
1259
|
+
},
|
|
1260
|
+
signal,
|
|
1261
|
+
judgeErrored: false
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
const startTimeoutMs = (harness) => parseDuration(harness.startTimeout ?? "90s");
|
|
1265
|
+
/**
|
|
1266
|
+
* Setup steps and the harness's start (once retried) run outside the eval timeout, so the
|
|
1267
|
+
* attempt's own limit makes room for them.
|
|
1268
|
+
*/
|
|
1269
|
+
function setupAllowance(planned) {
|
|
1270
|
+
const setupMs = planned.environment.layers.flatMap((l) => l.spec.setup ?? []).reduce((ms, step) => ms + parseDuration((typeof step === "string" ? void 0 : step.timeout) ?? "5m"), 0);
|
|
1271
|
+
const { sandbox } = planned;
|
|
1272
|
+
return setupMs + (sandbox.kind === "docker" && !!(sandbox.from || sandbox.dockerfile || sandbox.install?.length) ? SETUP_BUDGET_MS : 0) + 2 * startTimeoutMs(planned.harness);
|
|
1273
|
+
}
|
|
1274
|
+
/** The eval's timer, paused while the sandbox and its setup steps start. */
|
|
1275
|
+
function evalTimer(ms) {
|
|
1276
|
+
const controller = new AbortController();
|
|
1277
|
+
let left = ms;
|
|
1278
|
+
let since = Date.now();
|
|
1279
|
+
let timer;
|
|
1280
|
+
const fire = () => controller.abort(new DOMException(`Eval timed out after ${ms / 1e3}s`, "TimeoutError"));
|
|
1281
|
+
const start = () => {
|
|
1282
|
+
since = Date.now();
|
|
1283
|
+
timer = setTimeout(fire, Math.max(0, left));
|
|
1284
|
+
};
|
|
1285
|
+
start();
|
|
1286
|
+
return {
|
|
1287
|
+
signal: controller.signal,
|
|
1288
|
+
pause() {
|
|
1289
|
+
clearTimeout(timer);
|
|
1290
|
+
left -= Date.now() - since;
|
|
1291
|
+
},
|
|
1292
|
+
resume: start,
|
|
1293
|
+
clear: () => clearTimeout(timer)
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
//#endregion
|
|
1297
|
+
//#region src/attempt/session.ts
|
|
1298
|
+
const tailOf = (text) => text.trim().split("\n").slice(-10).join("\n");
|
|
1299
|
+
/** Setup steps in order, each with its own timeout; a failure is an infra error. */
|
|
1300
|
+
async function runSetup(a, box, env) {
|
|
1301
|
+
const { record, spec } = a;
|
|
1302
|
+
const steps = env.setup;
|
|
1303
|
+
record.setup = [];
|
|
1304
|
+
for (const [i, step] of steps.entries()) {
|
|
1305
|
+
const what = `setup step ${i + 1}/${steps.length} "${clip(step.run, 80)}" in environment ${spec.environment.name}`;
|
|
1306
|
+
const start = performance.now();
|
|
1307
|
+
const stepSignal = AbortSignal.any([a.signal, AbortSignal.timeout(step.timeoutMs)]);
|
|
1308
|
+
const r = await asCheck(a, () => box.exec(step.run, {
|
|
1309
|
+
signal: stepSignal,
|
|
1310
|
+
env: env.checkVars,
|
|
1311
|
+
...step.cwd ? { cwd: step.cwd } : {}
|
|
1312
|
+
})).catch((error) => {
|
|
1313
|
+
if (a.signal.aborted) throw error;
|
|
1314
|
+
if (!stepSignal.aborted) throw new InfraError(`${what} couldn't run: ${error instanceof Error ? error.message : error}`);
|
|
1315
|
+
return null;
|
|
1316
|
+
});
|
|
1317
|
+
const durationMs = Math.round(performance.now() - start);
|
|
1318
|
+
const output = r ? [r.stdout, r.stderr].filter(Boolean).join("\n") : "";
|
|
1319
|
+
record.setup.push({
|
|
1320
|
+
run: step.run,
|
|
1321
|
+
exitCode: r?.exitCode ?? null,
|
|
1322
|
+
durationMs,
|
|
1323
|
+
output
|
|
1324
|
+
});
|
|
1325
|
+
debugLog("env", `setup ${i + 1}/${steps.length} $ ${clip(step.run, 120)} → ${r ? `exit ${r.exitCode}` : `timed out after ${formatDuration(step.timeoutMs)}`} · ${formatDuration(durationMs)}${step.cwd ? ` · in ${step.cwd}` : ""}`);
|
|
1326
|
+
debugTrace("env", "setup", {
|
|
1327
|
+
run: step.run,
|
|
1328
|
+
cwd: step.cwd,
|
|
1329
|
+
exitCode: r?.exitCode ?? null,
|
|
1330
|
+
durationMs,
|
|
1331
|
+
stdout: r?.stdout,
|
|
1332
|
+
stderr: r?.stderr
|
|
1333
|
+
}, `setup ${i + 1} output`);
|
|
1334
|
+
if (r?.exitCode === 0) continue;
|
|
1335
|
+
throw new InfraError(r ? `${what} exited ${r.exitCode} after ${formatDuration(durationMs)} (see setup.log):\n${tailOf(r.stderr || r.stdout)}` : `${what} timed out after ${formatDuration(step.timeoutMs)} (see setup.log)`);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
const WORKSPACE_TAR = ".reeval-workspace.tar";
|
|
1339
|
+
/** Unpacks the git workspace's tarball into the (empty) workspace. */
|
|
1340
|
+
async function copyWorkspace(box, workspace, signal) {
|
|
1341
|
+
const start = performance.now();
|
|
1342
|
+
const tar = await readFile(workspace.tar);
|
|
1343
|
+
await box.writeFile(WORKSPACE_TAR, tar);
|
|
1344
|
+
const r = await box.exec(`tar -xf ${WORKSPACE_TAR} && rm -f ${WORKSPACE_TAR}`, { signal });
|
|
1345
|
+
if (r.exitCode !== 0) throw new InfraError(`Couldn't unpack the workspace ${workspace.label} into the sandbox (the image needs tar): ${tailOf(r.stderr || r.stdout)}`);
|
|
1346
|
+
if (workspace.init) {
|
|
1347
|
+
const at = "2000-01-01T00:00:00Z";
|
|
1348
|
+
const init = await box.exec(`git init -q -b main && git add -A && git -c commit.gpgsign=false commit -q --no-verify -m ${shellQuote(`${workspace.label} at ${workspace.commit}`)}`, {
|
|
1349
|
+
signal,
|
|
1350
|
+
env: {
|
|
1351
|
+
GIT_AUTHOR_NAME: "reeval",
|
|
1352
|
+
GIT_AUTHOR_EMAIL: "reeval@localhost",
|
|
1353
|
+
GIT_AUTHOR_DATE: at,
|
|
1354
|
+
GIT_COMMITTER_NAME: "reeval",
|
|
1355
|
+
GIT_COMMITTER_EMAIL: "reeval@localhost",
|
|
1356
|
+
GIT_COMMITTER_DATE: at
|
|
1357
|
+
}
|
|
1358
|
+
});
|
|
1359
|
+
if (init.exitCode !== 0) throw new InfraError(`Couldn't give the workspace ${workspace.label} a .git (init: true needs git in the image): ${tailOf(init.stderr || init.stdout)}`);
|
|
1360
|
+
}
|
|
1361
|
+
debugLog("env", `workspace ${workspace.label} at ${workspace.commit.slice(0, 12)} (${tar.byteLength} bytes of tar${workspace.init ? ", with a .git" : ""}) in ${formatDuration(performance.now() - start)}`);
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Starts the harness session within its `startTimeout`. A start that doesn't become ready in time
|
|
1365
|
+
* is aborted (and closed, should it finish later) and started once more: a stuck bootstrap is
|
|
1366
|
+
* usually transient, and nothing has run yet, so the retry can't change what the attempt
|
|
1367
|
+
* measures. A second timeout is an infra error; a start that fails outright isn't retried.
|
|
1368
|
+
*/
|
|
1369
|
+
async function startHarness(a, harness, box, options) {
|
|
1370
|
+
const { harness: descriptor } = a.spec;
|
|
1371
|
+
const ms = startTimeoutMs(descriptor);
|
|
1372
|
+
const late = `${descriptor.kind}: session did not become ready within ${formatDuration(ms)}`;
|
|
1373
|
+
for (let tries = 1;; tries++) {
|
|
1374
|
+
const stop = new AbortController();
|
|
1375
|
+
let timer;
|
|
1376
|
+
const timedOut = new Promise((resolve) => {
|
|
1377
|
+
timer = setTimeout(() => resolve(null), ms);
|
|
1378
|
+
});
|
|
1379
|
+
const starting = harness.start(box, AbortSignal.any([a.signal, stop.signal]), options);
|
|
1380
|
+
const session = await Promise.race([starting, timedOut]).finally(() => clearTimeout(timer));
|
|
1381
|
+
if (session) return session;
|
|
1382
|
+
stop.abort(new InfraError(late));
|
|
1383
|
+
starting.then((s) => s.close().catch(() => {}), () => {});
|
|
1384
|
+
a.signal.throwIfAborted();
|
|
1385
|
+
if (tries === 2) throw new InfraError(`${late} (tried twice)`);
|
|
1386
|
+
a.record.startRetries = [{
|
|
1387
|
+
error: late,
|
|
1388
|
+
waitedMs: ms
|
|
1389
|
+
}];
|
|
1390
|
+
debugLog("harness", `${late}; starting it once more`);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Phase 2: starts the sandbox (with the brokered secrets its gateway swaps in), writes the
|
|
1395
|
+
* environment's files and workspace skills, runs its setup steps, snapshots the workspace for
|
|
1396
|
+
* the diff, and starts the harness session with the harness-placed skills and MCP servers.
|
|
1397
|
+
*/
|
|
1398
|
+
async function startSession(a, runId, setupStart) {
|
|
1399
|
+
const { spec } = a;
|
|
1400
|
+
const env = a.env;
|
|
1401
|
+
const box = await createSandbox(spec.sandbox, {
|
|
1402
|
+
runId,
|
|
1403
|
+
timeoutMs: spec.timeoutMs + setupAllowance(spec) + 6e4,
|
|
1404
|
+
signal: a.signal,
|
|
1405
|
+
env: env.vars,
|
|
1406
|
+
image: a.image,
|
|
1407
|
+
brokering: brokeringFor(spec.harness),
|
|
1408
|
+
expectedBlocked: expectedBlockedFor(spec.harness)?.hosts,
|
|
1409
|
+
substitutions: env.substitutions,
|
|
1410
|
+
egress: spec.egress
|
|
1411
|
+
});
|
|
1412
|
+
a.sandbox = box;
|
|
1413
|
+
if (spec.sandbox.kind === "fake") debugLog("container", `fake sandbox at ${box.workdir}`);
|
|
1414
|
+
a.phases = brokerPhases(box, env.substitutions);
|
|
1415
|
+
if (a.signal.aborted) box.destroy();
|
|
1416
|
+
a.signal.throwIfAborted();
|
|
1417
|
+
await a.phases.start();
|
|
1418
|
+
if (env.substitutions.length) {
|
|
1419
|
+
const agent = env.substitutions.filter((s) => s.phase === "agent").map((s) => s.name);
|
|
1420
|
+
debugLog("broker", `gateway swaps in ${agent.join(", ") || "nothing"} for the agent`);
|
|
1421
|
+
}
|
|
1422
|
+
if (env.workspace) await copyWorkspace(box, env.workspace, a.signal);
|
|
1423
|
+
for (const f of env.files) {
|
|
1424
|
+
await box.writeFile(f.path, f.content);
|
|
1425
|
+
const size = typeof f.content === "string" ? Buffer.byteLength(f.content) : f.content.byteLength;
|
|
1426
|
+
debugLog("env", `file ${f.path} (${size} bytes)`);
|
|
1427
|
+
}
|
|
1428
|
+
const skillDir = workspaceSkillDir(spec.harness);
|
|
1429
|
+
for (const skill of env.workspaceSkills) {
|
|
1430
|
+
const files = skillFiles(skill);
|
|
1431
|
+
for (const f of files) await box.writeFile(`${skillDir}/${skill.name}/${f.path}`, f.content);
|
|
1432
|
+
debugLog("env", `skill ${skill.name} → ${skillDir}/${skill.name}/ (${files.length} files, workspace)`);
|
|
1433
|
+
}
|
|
1434
|
+
const layout = layoutOf(spec.harness);
|
|
1435
|
+
for (const skill of env.skills) debugLog("env", `skill ${skill.name} → the harness adapter places it (~/${layout.harnessSkillDir}/${skill.name}/ for ${spec.harness.kind === "fake" ? `the fake ${spec.harness.layout ?? "cursor"} layout` : spec.harness.kind})`);
|
|
1436
|
+
for (const [name, server] of Object.entries(env.mcp)) debugLog("env", `mcp ${name} (${server.transport}${server.transport === "stdio" ? `: ${server.command} ${server.args.join(" ")}` : `: ${server.url}`}) → the harness adapter writes its MCP config`);
|
|
1437
|
+
await runSetup(a, box, env);
|
|
1438
|
+
await snapshot(box, a.signal);
|
|
1439
|
+
debugLog("env", "workspace snapshot taken: the diff starts here");
|
|
1440
|
+
const harness = await createHarness(spec.harness);
|
|
1441
|
+
const harnessStart = performance.now();
|
|
1442
|
+
debugLog("harness", `starting ${harnessId(spec.harness)}: bootstrap and session`);
|
|
1443
|
+
const session = await startHarness(a, harness, box, {
|
|
1444
|
+
skills: env.skills,
|
|
1445
|
+
workspaceSkills: env.workspaceSkills.map((s) => s.name),
|
|
1446
|
+
mcp: env.mcp
|
|
1447
|
+
});
|
|
1448
|
+
debugLog("harness", `${harnessId(spec.harness)} session ready in ${formatDuration(performance.now() - harnessStart)}`);
|
|
1449
|
+
a.session = session;
|
|
1450
|
+
a.report({
|
|
1451
|
+
kind: "sandbox-ready",
|
|
1452
|
+
ms: Math.round(performance.now() - setupStart)
|
|
1453
|
+
});
|
|
1454
|
+
return [box, session];
|
|
1455
|
+
}
|
|
1456
|
+
//#endregion
|
|
1457
|
+
//#region src/attempt/turns.ts
|
|
1458
|
+
/** How long a turn gets to stop once its attempt is aborted, before reeval stops waiting for it. */
|
|
1459
|
+
const TURN_STOP_GRACE_MS = 3e3;
|
|
1460
|
+
/**
|
|
1461
|
+
* Resolves `grace` ms after `signal` aborts, with the turn as cut off; `cancel` drops the timer
|
|
1462
|
+
* once the turn settles on its own.
|
|
1463
|
+
*/
|
|
1464
|
+
function cutOff(signal, startedAt, grace = TURN_STOP_GRACE_MS) {
|
|
1465
|
+
let timer;
|
|
1466
|
+
let onAbort = () => {};
|
|
1467
|
+
const cutoff = new Promise((resolve) => {
|
|
1468
|
+
onAbort = () => {
|
|
1469
|
+
timer = setTimeout(() => {
|
|
1470
|
+
const message = `The agent didn't stop within ${grace / 1e3}s of being cancelled, so reeval stopped waiting for its turn`;
|
|
1471
|
+
const at = Date.now() - Date.parse(startedAt);
|
|
1472
|
+
resolve({
|
|
1473
|
+
text: "",
|
|
1474
|
+
status: "aborted",
|
|
1475
|
+
transcript: [{
|
|
1476
|
+
type: "error",
|
|
1477
|
+
message,
|
|
1478
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1479
|
+
atMs: at
|
|
1480
|
+
}],
|
|
1481
|
+
usage: null,
|
|
1482
|
+
model: null,
|
|
1483
|
+
unreported: [],
|
|
1484
|
+
startedAt,
|
|
1485
|
+
durationMs: at
|
|
1486
|
+
});
|
|
1487
|
+
}, grace);
|
|
1488
|
+
};
|
|
1489
|
+
if (signal.aborted) onAbort();
|
|
1490
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
1491
|
+
});
|
|
1492
|
+
const cancel = () => {
|
|
1493
|
+
clearTimeout(timer);
|
|
1494
|
+
signal.removeEventListener("abort", onAbort);
|
|
1495
|
+
};
|
|
1496
|
+
return {
|
|
1497
|
+
cutoff,
|
|
1498
|
+
cancel
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* Phase 3: one turn. Sends the prompt to the started session, records the turn as it goes (an
|
|
1503
|
+
* errored entry first, so a turn that throws is still in the record), takes the agent's context
|
|
1504
|
+
* after the first, and diffs the workspace since the snapshot.
|
|
1505
|
+
*/
|
|
1506
|
+
function turnSender(a, start, takeContext, checks) {
|
|
1507
|
+
return async (prompt) => {
|
|
1508
|
+
const [box, agent] = await start();
|
|
1509
|
+
const entry = {
|
|
1510
|
+
prompt,
|
|
1511
|
+
text: "",
|
|
1512
|
+
status: "error",
|
|
1513
|
+
transcript: [],
|
|
1514
|
+
usage: null,
|
|
1515
|
+
model: null,
|
|
1516
|
+
unreported: [],
|
|
1517
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1518
|
+
durationMs: 0
|
|
1519
|
+
};
|
|
1520
|
+
a.record.turns.push(entry);
|
|
1521
|
+
const turnNumber = a.record.turns.length;
|
|
1522
|
+
a.report({
|
|
1523
|
+
kind: "turn-start",
|
|
1524
|
+
turn: turnNumber
|
|
1525
|
+
});
|
|
1526
|
+
debugLog("harness", `turn ${turnNumber} started: ${JSON.stringify(clip(prompt, 160))}`);
|
|
1527
|
+
debugTrace("harness", "prompt", {
|
|
1528
|
+
turn: turnNumber,
|
|
1529
|
+
prompt
|
|
1530
|
+
}, `turn ${turnNumber} prompt`);
|
|
1531
|
+
const { cutoff, cancel } = cutOff(a.signal, entry.startedAt);
|
|
1532
|
+
const { raw, ...result } = await a.timed("agentMs", () => Promise.race([agent.send(prompt, a.signal), cutoff])).finally(cancel);
|
|
1533
|
+
Object.assign(entry, result, raw ? { raw } : {});
|
|
1534
|
+
for (const event of result.transcript) debugTrace("harness", "transcript", {
|
|
1535
|
+
turn: turnNumber,
|
|
1536
|
+
...event
|
|
1537
|
+
}, `transcript ${event.type}`);
|
|
1538
|
+
await takeContext(box, a.signal);
|
|
1539
|
+
const count = (...types) => result.transcript.filter((e) => types.includes(e.type)).length;
|
|
1540
|
+
const failure = result.transcript.find((e) => e.type === "error");
|
|
1541
|
+
a.report({
|
|
1542
|
+
kind: "turn-end",
|
|
1543
|
+
turn: turnNumber,
|
|
1544
|
+
status: result.status,
|
|
1545
|
+
ms: result.durationMs,
|
|
1546
|
+
tools: count("tool", "web-search", "web-fetch"),
|
|
1547
|
+
fileEdits: count("file-edit"),
|
|
1548
|
+
...failure?.type === "error" ? { error: redactSecrets(clip(failure.message.trim(), 300)) } : {}
|
|
1549
|
+
});
|
|
1550
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
1551
|
+
debugLog("harness", `turn ${turnNumber} ${result.status} in ${formatDuration(result.durationMs)} · ${plural(count("tool", "web-search", "web-fetch"), "tool call")} · ${plural(count("file-edit"), "file edit")}${failure && "message" in failure ? ` · error: ${clip(String(failure.message), 200)}` : ""}${result.model ? ` · model ${result.model}` : ""}`);
|
|
1552
|
+
a.signal.throwIfAborted();
|
|
1553
|
+
const diff = await a.timed("diffMs", () => diffSinceSnapshot(box, a.signal));
|
|
1554
|
+
a.record.diff = diff;
|
|
1555
|
+
const data = {
|
|
1556
|
+
prompt,
|
|
1557
|
+
...result,
|
|
1558
|
+
diff
|
|
1559
|
+
};
|
|
1560
|
+
const turn = Object.freeze({
|
|
1561
|
+
...data,
|
|
1562
|
+
...checks
|
|
1563
|
+
});
|
|
1564
|
+
a.turns.push(turn);
|
|
1565
|
+
return turn;
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
//#endregion
|
|
1569
|
+
//#region src/attempt/index.ts
|
|
1570
|
+
const TRACE_BUFFER_DEFAULT = 8 * 2 ** 20;
|
|
1571
|
+
async function runAttempt(spec, fn, handle) {
|
|
1572
|
+
const ctx = getRunContext();
|
|
1573
|
+
const { config, runId } = ctx;
|
|
1574
|
+
const verbosity = ctx.verbosity ?? 0;
|
|
1575
|
+
const id = evalId(relativeFile(config.root, handle.file), [...spec.groups, spec.name]);
|
|
1576
|
+
const debug = new AttemptDebug({
|
|
1577
|
+
dir: attemptDebugDir(config.root, runId, id, spec.cell.key, spec.n),
|
|
1578
|
+
verbosity,
|
|
1579
|
+
traceLimit: config.debug?.traceBufferBytes ?? TRACE_BUFFER_DEFAULT,
|
|
1580
|
+
...verbosity > 0 ? { emit: handle.debug } : {}
|
|
1581
|
+
});
|
|
1582
|
+
return withDebug(debug, () => attemptBody(spec, fn, handle, id, debug));
|
|
1583
|
+
}
|
|
1584
|
+
async function attemptBody(spec, fn, { signal: testSignal, setMeta, progress, unawaited }, id, debug) {
|
|
1585
|
+
const setupStart = performance.now();
|
|
1586
|
+
const ctx = getRunContext();
|
|
1587
|
+
const { runId, stagingDir, config } = ctx;
|
|
1588
|
+
tracePlan(debug, spec, ctx);
|
|
1589
|
+
const a = createAttempt(spec, testSignal, progress);
|
|
1590
|
+
let timer;
|
|
1591
|
+
let clock;
|
|
1592
|
+
const destroy = () => void a.sandbox?.destroy();
|
|
1593
|
+
testSignal.addEventListener("abort", destroy, { once: true });
|
|
1594
|
+
let starting;
|
|
1595
|
+
const start = () => {
|
|
1596
|
+
starting ??= (async () => {
|
|
1597
|
+
clock?.pause();
|
|
1598
|
+
try {
|
|
1599
|
+
return await a.timed("setupMs", () => startSession(a, runId, setupStart));
|
|
1600
|
+
} finally {
|
|
1601
|
+
clock?.resume();
|
|
1602
|
+
}
|
|
1603
|
+
})();
|
|
1604
|
+
return starting;
|
|
1605
|
+
};
|
|
1606
|
+
const takeContext = contextTaker(a);
|
|
1607
|
+
const { exec, readFile } = createChecks(a);
|
|
1608
|
+
const t = {
|
|
1609
|
+
get signal() {
|
|
1610
|
+
return a.signal;
|
|
1611
|
+
},
|
|
1612
|
+
turns: a.turns,
|
|
1613
|
+
exec,
|
|
1614
|
+
readFile,
|
|
1615
|
+
attempt: {
|
|
1616
|
+
id: attemptId(spec.cell.key, spec.n),
|
|
1617
|
+
cell: spec.cell,
|
|
1618
|
+
n: spec.n
|
|
1619
|
+
},
|
|
1620
|
+
async sandbox() {
|
|
1621
|
+
await start();
|
|
1622
|
+
return {
|
|
1623
|
+
exec,
|
|
1624
|
+
readFile
|
|
1625
|
+
};
|
|
1626
|
+
},
|
|
1627
|
+
send: turnSender(a, start, takeContext, {
|
|
1628
|
+
exec,
|
|
1629
|
+
readFile
|
|
1630
|
+
}),
|
|
1631
|
+
judge: judgeCaller(a)
|
|
1632
|
+
};
|
|
1633
|
+
const timedOut = () => !!timer?.aborted && !testSignal.aborted;
|
|
1634
|
+
try {
|
|
1635
|
+
a.report({ kind: "sandbox" });
|
|
1636
|
+
const where = `${id} [${attemptId(spec.cell.key, spec.n)}]`;
|
|
1637
|
+
await a.timed("setupMs", () => prepareEnvironment(a, config.root, runId, where, testSignal));
|
|
1638
|
+
clock = evalTimer(spec.timeoutMs);
|
|
1639
|
+
timer = clock.signal;
|
|
1640
|
+
a.signal = AbortSignal.any([testSignal, timer]);
|
|
1641
|
+
timer.addEventListener("abort", destroy, { once: true });
|
|
1642
|
+
await fn(t);
|
|
1643
|
+
a.signal.throwIfAborted();
|
|
1644
|
+
const missed = await unawaited();
|
|
1645
|
+
if (missed) throw missed;
|
|
1646
|
+
} catch (error) {
|
|
1647
|
+
recordFailure(a, error, timedOut());
|
|
1648
|
+
if (timedOut()) throw new Error(`Eval timed out after ${spec.timeoutMs / 1e3}s`);
|
|
1649
|
+
throw error;
|
|
1650
|
+
} finally {
|
|
1651
|
+
if (!a.signal.aborted) await unawaited().catch(() => {});
|
|
1652
|
+
clock?.clear();
|
|
1653
|
+
const { sandbox } = a;
|
|
1654
|
+
if (sandbox && !a.signal.aborted) await takeContext(sandbox, AbortSignal.timeout(3e4));
|
|
1655
|
+
if (sandbox) a.report({ kind: "cleanup" });
|
|
1656
|
+
testSignal.removeEventListener("abort", destroy);
|
|
1657
|
+
const teardown = performance.now();
|
|
1658
|
+
await a.session?.close().catch(() => {});
|
|
1659
|
+
if (a.session) debug.log("teardown", "harness session closed");
|
|
1660
|
+
recordEgress(a, sandbox);
|
|
1661
|
+
await sandbox?.destroy();
|
|
1662
|
+
if (sandbox) debug.log("teardown", `sandbox destroyed (${formatDuration(performance.now() - teardown)} for teardown)`);
|
|
1663
|
+
const { record } = a;
|
|
1664
|
+
const error = record.error ? ` · ${record.error.kind}: ${record.error.message}` : "";
|
|
1665
|
+
debug.log("teardown", `outcome ${record.outcome}${error}`);
|
|
1666
|
+
const rel = (path) => relativeFile(config.root, path) || ".";
|
|
1667
|
+
const scratch = `.reeval/runs/${runId}`;
|
|
1668
|
+
debug.log("teardown", `record ${scratch}.json · artifacts ${scratch}/${evalSlug(id)}/${evalSlug(spec.cell.key)}/${spec.n} · debug ${rel(debug.dir)}`);
|
|
1669
|
+
setMeta(await writeRecord(a, stagingDir, id));
|
|
1670
|
+
await debug.finish(debug.verbosity === 2 || record.outcome !== "pass");
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
//#endregion
|
|
1674
|
+
//#region src/plan-eval.ts
|
|
1675
|
+
/** Lets the attempt's own eval timer fire before the engine's backstop, so the message and cleanup are its own. */
|
|
1676
|
+
const GRACE_MS = 5e3;
|
|
1677
|
+
function configLevel(config) {
|
|
1678
|
+
return {
|
|
1679
|
+
harness: config.harness,
|
|
1680
|
+
sandbox: config.sandbox,
|
|
1681
|
+
environment: config.environment,
|
|
1682
|
+
compare: void 0,
|
|
1683
|
+
runs: config.runs,
|
|
1684
|
+
gate: config.gate,
|
|
1685
|
+
env: config.env,
|
|
1686
|
+
egress: config.egress !== void 0 ? { inherited: config.egress } : {},
|
|
1687
|
+
judge: config.judge,
|
|
1688
|
+
timeout: void 0,
|
|
1689
|
+
tags: [],
|
|
1690
|
+
pin: [],
|
|
1691
|
+
sources: {}
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* The tests an eval registers, or `null` when the run's tags leave it out (it isn't counted in
|
|
1696
|
+
* `order`). Throws a UsageError for options the plan refuses.
|
|
1697
|
+
*/
|
|
1698
|
+
function planEval(declaration, ctx) {
|
|
1699
|
+
const { name, mode, groups: enclosing, options: own } = declaration;
|
|
1700
|
+
const groups = enclosing.map((g) => g.name);
|
|
1701
|
+
const where = `evaluate("${[...groups, name].join(" › ")}")`;
|
|
1702
|
+
checkOptions(own, where);
|
|
1703
|
+
const levels = [...enclosing.map((g) => ({
|
|
1704
|
+
options: g.options,
|
|
1705
|
+
where: `group("${g.name}")`
|
|
1706
|
+
})), {
|
|
1707
|
+
options: own,
|
|
1708
|
+
where
|
|
1709
|
+
}];
|
|
1710
|
+
const resolvedEval = cascade(configLevel(ctx.config), levels, where, {
|
|
1711
|
+
profile: ctx.profile,
|
|
1712
|
+
runs: ctx.runs
|
|
1713
|
+
});
|
|
1714
|
+
const { resolved } = resolvedEval;
|
|
1715
|
+
if (!matchesTags(resolved.tags, ctx.tags)) return null;
|
|
1716
|
+
const timeoutMs = resolved.timeout === void 0 ? ctx.config.timeoutMs : parseDuration(resolved.timeout);
|
|
1717
|
+
const plannedAttempts = resolvedEval.skip ? [] : expand(resolved);
|
|
1718
|
+
if (ctx.config.secrets.unbrokered === "error") {
|
|
1719
|
+
const [first] = unbrokered(resolved.env, plannedAttempts);
|
|
1720
|
+
if (first) throw new UsageError(unbrokeredError(first, where, ctx.config.file));
|
|
1721
|
+
}
|
|
1722
|
+
for (const planned of plannedAttempts) {
|
|
1723
|
+
const why = unenforceable(planned.egress, planned.sandbox, where);
|
|
1724
|
+
if (why) throw new UsageError(why);
|
|
1725
|
+
const mcp = refusedMcp(planned.harness, planned.environment.layers);
|
|
1726
|
+
if (mcp) throw new UsageError(`${where} on ${harnessId(planned.harness)}: ${mcp}`);
|
|
1727
|
+
}
|
|
1728
|
+
const path = ctx.file ? relativeFile(ctx.config.root, ctx.file) : null;
|
|
1729
|
+
const setBy = {
|
|
1730
|
+
sandbox: resolved.sources?.sandbox ?? ctx.config.file,
|
|
1731
|
+
harness: resolved.sources?.harness ?? ctx.config.file
|
|
1732
|
+
};
|
|
1733
|
+
const plan = evalPlan(resolvedEval, plannedAttempts, {
|
|
1734
|
+
configFile: ctx.config.file,
|
|
1735
|
+
file: path
|
|
1736
|
+
});
|
|
1737
|
+
if (resolvedEval.skip) {
|
|
1738
|
+
const [first] = expand(resolved);
|
|
1739
|
+
if (!first) return [];
|
|
1740
|
+
const attempt = {
|
|
1741
|
+
name,
|
|
1742
|
+
groups,
|
|
1743
|
+
tags: resolved.tags,
|
|
1744
|
+
cell: first.cell,
|
|
1745
|
+
n: 1,
|
|
1746
|
+
label: "",
|
|
1747
|
+
harness: first.harness,
|
|
1748
|
+
sandbox: first.sandbox,
|
|
1749
|
+
setBy,
|
|
1750
|
+
gate: "none",
|
|
1751
|
+
brokered: [],
|
|
1752
|
+
secretVars: [],
|
|
1753
|
+
egress: first.egress,
|
|
1754
|
+
plan
|
|
1755
|
+
};
|
|
1756
|
+
return [{
|
|
1757
|
+
name: `${name} [${attemptId(first.cell.key, 1)}]`,
|
|
1758
|
+
mode: "skip",
|
|
1759
|
+
attempt
|
|
1760
|
+
}];
|
|
1761
|
+
}
|
|
1762
|
+
const cells = [];
|
|
1763
|
+
return plannedAttempts.map((planned) => {
|
|
1764
|
+
if (!cells.includes(planned.cell.key)) cells.push(planned.cell.key);
|
|
1765
|
+
const workspace = workspaceOf(planned.environment.layers);
|
|
1766
|
+
const attempt = {
|
|
1767
|
+
name,
|
|
1768
|
+
groups,
|
|
1769
|
+
tags: resolved.tags,
|
|
1770
|
+
cell: planned.cell,
|
|
1771
|
+
n: planned.n,
|
|
1772
|
+
label: planned.label,
|
|
1773
|
+
harness: planned.harness,
|
|
1774
|
+
sandbox: planned.sandbox,
|
|
1775
|
+
setBy,
|
|
1776
|
+
gate: planned.gate,
|
|
1777
|
+
...planned.minLift !== void 0 ? { minLift: planned.minLift } : {},
|
|
1778
|
+
brokered: brokeredIn(resolved.env, planned),
|
|
1779
|
+
secretVars: secretVarsIn(resolved.env, planned),
|
|
1780
|
+
egress: planned.egress,
|
|
1781
|
+
...workspace ? { workspace } : {},
|
|
1782
|
+
plan
|
|
1783
|
+
};
|
|
1784
|
+
const rankFile = relativeFile(ctx.config.root, ctx.file ?? "");
|
|
1785
|
+
return {
|
|
1786
|
+
name: `${name} [${attemptId(planned.cell.key, planned.n)}]`,
|
|
1787
|
+
mode,
|
|
1788
|
+
attempt,
|
|
1789
|
+
timeout: timeoutMs + setupAllowance(planned) + GRACE_MS,
|
|
1790
|
+
gate: {
|
|
1791
|
+
limits: attemptLimits(planned, ctx.concurrency),
|
|
1792
|
+
rank: {
|
|
1793
|
+
eval: evalId(rankFile, [...groups, name]),
|
|
1794
|
+
file: rankFile,
|
|
1795
|
+
order: ctx.order,
|
|
1796
|
+
n: planned.n,
|
|
1797
|
+
cell: cells.indexOf(planned.cell.key)
|
|
1798
|
+
}
|
|
1799
|
+
},
|
|
1800
|
+
spec: {
|
|
1801
|
+
...planned,
|
|
1802
|
+
name,
|
|
1803
|
+
groups,
|
|
1804
|
+
timeoutMs,
|
|
1805
|
+
env: resolved.env,
|
|
1806
|
+
judge: resolved.judge,
|
|
1807
|
+
sources: resolved.sources ?? {},
|
|
1808
|
+
notes: resolvedEval.notes
|
|
1809
|
+
}
|
|
1810
|
+
};
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
/** The run-wide cap, plus the sandbox provider's and harness's own caps when they set one. */
|
|
1814
|
+
function attemptLimits(planned, concurrency) {
|
|
1815
|
+
const limits = { attempts: concurrency };
|
|
1816
|
+
const { sandbox, harness } = planned;
|
|
1817
|
+
if (sandbox.maxConcurrent) limits[`sandbox:${sandbox.kind}`] = sandbox.maxConcurrent;
|
|
1818
|
+
if (harness.maxConcurrent) limits[`harness:${harness.kind}`] = harness.maxConcurrent;
|
|
1819
|
+
return limits;
|
|
1820
|
+
}
|
|
1821
|
+
//#endregion
|
|
1822
|
+
//#region src/evaluate.ts
|
|
1823
|
+
let groupStack = [];
|
|
1824
|
+
/** Each eval file's declarations as `evaluate` and `group` were called, and each eval's body by id. */
|
|
1825
|
+
const trees = /* @__PURE__ */ new Map();
|
|
1826
|
+
const bodies = /* @__PURE__ */ new Map();
|
|
1827
|
+
/** Options as the tree holds them; from TypeScript they may still hold function forms. */
|
|
1828
|
+
const data = (options) => options;
|
|
1829
|
+
function declare(node) {
|
|
1830
|
+
const parent = groupStack.at(-1)?.node;
|
|
1831
|
+
if (parent) return void parent.children.push(node);
|
|
1832
|
+
const file = collectingFile() ?? "";
|
|
1833
|
+
const tree = trees.get(file) ?? { children: [] };
|
|
1834
|
+
trees.set(file, tree);
|
|
1835
|
+
tree.children.push(node);
|
|
1836
|
+
}
|
|
1837
|
+
/** `(name, fn)`, `(name, options, fn)` as in Vitest, or `(name, fn, options)`. */
|
|
1838
|
+
function splitArgs(what, name, a, b) {
|
|
1839
|
+
if (typeof a === "function" && typeof b === "function") throw new UsageError(`${what}("${name}") got two functions. Options must be a plain object; helpers such as secret are only available in defineConfig and defineEnvironment callbacks. Put the secret in an environment (environment: …) or in the config's env.`);
|
|
1840
|
+
if (typeof a === "function") return [b ?? {}, a];
|
|
1841
|
+
if (typeof b === "function") return [a ?? {}, b];
|
|
1842
|
+
throw new UsageError(what === "group" ? `group("${name}") has no function. Pass the group's body, e.g. group("${name}", () => { … }).` : `${what}("${name}") has no function. Pass the eval's body, e.g. ${what}("${name}", async (t) => { … }).`);
|
|
1843
|
+
}
|
|
1844
|
+
/** `evaluate.only` and `group.only` are refused in CI, where they'd silently skip the rest. */
|
|
1845
|
+
function checkOnly(mode, what) {
|
|
1846
|
+
if (mode !== "only" || !inCI()) return;
|
|
1847
|
+
const error = /* @__PURE__ */ new Error(`${what}.only isn't allowed in CI`);
|
|
1848
|
+
error.name = "OnlyInCiError";
|
|
1849
|
+
throw error;
|
|
1850
|
+
}
|
|
1851
|
+
const defineGroup = (mode) => (name, a, b) => {
|
|
1852
|
+
checkOnly(mode, "group");
|
|
1853
|
+
const [options, fn] = splitArgs("group", name, a, b);
|
|
1854
|
+
checkOptions(options, `group("${name}")`);
|
|
1855
|
+
const node = {
|
|
1856
|
+
type: "group",
|
|
1857
|
+
name,
|
|
1858
|
+
mode,
|
|
1859
|
+
options: data(options),
|
|
1860
|
+
children: []
|
|
1861
|
+
};
|
|
1862
|
+
declare(node);
|
|
1863
|
+
const stack = [...groupStack, {
|
|
1864
|
+
name,
|
|
1865
|
+
options,
|
|
1866
|
+
node
|
|
1867
|
+
}];
|
|
1868
|
+
registerGroup(name, mode, () => {
|
|
1869
|
+
const outer = groupStack;
|
|
1870
|
+
groupStack = stack;
|
|
1871
|
+
try {
|
|
1872
|
+
fn();
|
|
1873
|
+
} finally {
|
|
1874
|
+
groupStack = outer;
|
|
1875
|
+
}
|
|
1876
|
+
});
|
|
1877
|
+
};
|
|
1878
|
+
const group = Object.assign(defineGroup("run"), {
|
|
1879
|
+
skip: defineGroup("skip"),
|
|
1880
|
+
only: defineGroup("only"),
|
|
1881
|
+
todo: (name) => registerTodo(name)
|
|
1882
|
+
});
|
|
1883
|
+
/**
|
|
1884
|
+
* A placeholder, `evaluate.todo` or `group.todo`: one eval entry that never runs, counted as
|
|
1885
|
+
* skipped and shown as todo. The enclosing groups' tags filter it as they do an eval.
|
|
1886
|
+
*/
|
|
1887
|
+
function registerTodo(name) {
|
|
1888
|
+
declare({
|
|
1889
|
+
type: "todo",
|
|
1890
|
+
name
|
|
1891
|
+
});
|
|
1892
|
+
const tags = [...new Set(groupStack.flatMap((g) => g.options.tags ?? []))];
|
|
1893
|
+
if (matchesTags(tags, getRunContext().tags)) registerTest(name, { mode: "todo" }, async () => {});
|
|
1894
|
+
}
|
|
1895
|
+
/** The evals declared so far, by file and name path, so a second one with the same id is refused. */
|
|
1896
|
+
const declared = /* @__PURE__ */ new Set();
|
|
1897
|
+
function checkUnique(where, groups, name, hint) {
|
|
1898
|
+
const key = `${collectingFile() ?? ""}\0${[...groups, name].join("\0")}`;
|
|
1899
|
+
if (declared.has(key)) throw new UsageError(`${where} is declared twice in this file. Each eval needs its own name, or its attempts are merged into one eval.${hint}`);
|
|
1900
|
+
declared.add(key);
|
|
1901
|
+
}
|
|
1902
|
+
function register(mode, name, own, fn, hint = "") {
|
|
1903
|
+
const groups = groupStack.map((g) => g.name);
|
|
1904
|
+
checkUnique(`evaluate("${[...groups, name].join(" › ")}")`, groups, name, hint);
|
|
1905
|
+
const body = `b${bodies.size + 1}`;
|
|
1906
|
+
bodies.set(body, fn);
|
|
1907
|
+
declare({
|
|
1908
|
+
type: "eval",
|
|
1909
|
+
name,
|
|
1910
|
+
mode,
|
|
1911
|
+
options: data(own),
|
|
1912
|
+
body
|
|
1913
|
+
});
|
|
1914
|
+
const tests = planEval({
|
|
1915
|
+
name,
|
|
1916
|
+
mode,
|
|
1917
|
+
groups: groupStack.map(({ name, options }) => ({
|
|
1918
|
+
name,
|
|
1919
|
+
options
|
|
1920
|
+
})),
|
|
1921
|
+
options: own
|
|
1922
|
+
}, {
|
|
1923
|
+
...getRunContext(),
|
|
1924
|
+
file: collectingFile(),
|
|
1925
|
+
order: registered
|
|
1926
|
+
});
|
|
1927
|
+
if (!tests) return;
|
|
1928
|
+
registered++;
|
|
1929
|
+
for (const { name: testName, spec, ...options } of tests) registerTest(testName, options, spec ? (handle) => runAttempt(spec, fn, handle) : async () => {});
|
|
1930
|
+
}
|
|
1931
|
+
/** Evals registered so far in this worker, for the scheduler's tie-break. */
|
|
1932
|
+
let registered = 0;
|
|
1933
|
+
const defineEval = (mode) => (name, a, b) => {
|
|
1934
|
+
checkOnly(mode, "evaluate");
|
|
1935
|
+
const [own, fn] = splitArgs("evaluate", name, a, b);
|
|
1936
|
+
register(mode, name, own, fn);
|
|
1937
|
+
};
|
|
1938
|
+
/** `$key` is a property of the case, `%s` the case itself, `%#` its index. */
|
|
1939
|
+
function formatName(template, item, index) {
|
|
1940
|
+
return template.replace(/%#/g, String(index)).replace(/%s/g, typeof item === "string" ? item : JSON.stringify(item)).replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (whole, key) => typeof item === "object" && item !== null && key in item ? String(item[key]) : whole);
|
|
1941
|
+
}
|
|
1942
|
+
/** One eval per case: `evaluate.for(cases)('adds $a and $b', async (t, c) => …)`. */
|
|
1943
|
+
const evalFor = (cases) => (name, a, b) => {
|
|
1944
|
+
const [own, fn] = splitArgs("evaluate.for", name, a, b);
|
|
1945
|
+
const hint = ` For evaluate.for, put $key, %s or %# in the name ("${name}") so each case's name differs.`;
|
|
1946
|
+
cases.forEach((item, i) => {
|
|
1947
|
+
register("run", formatName(name, item, i), own, (t) => fn(t, item), hint);
|
|
1948
|
+
});
|
|
1949
|
+
};
|
|
1950
|
+
const evaluate = Object.assign(defineEval("run"), {
|
|
1951
|
+
skip: defineEval("skip"),
|
|
1952
|
+
only: defineEval("only"),
|
|
1953
|
+
todo: registerTodo,
|
|
1954
|
+
for: evalFor
|
|
1955
|
+
});
|
|
1956
|
+
//#endregion
|
|
1957
|
+
export { DEFAULT_EGRESS_ALLOW, DEFAULT_SKILL_EXCLUDE, HarnessCapabilityError, defineConfig, defineEnvironment, evaluate, expect, group };
|
|
1958
|
+
|
|
1959
|
+
//# sourceMappingURL=index.js.map
|