vigiles 36.0.0 → 38.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-commands.d.ts +1 -1
- package/dist/cli-commands.js +1 -0
- package/dist/cli-main.d.ts +2 -0
- package/dist/cli-main.js +84 -15
- package/dist/cli.js +51 -0
- package/dist/core/compile.d.ts +36 -6
- package/dist/core/compile.js +181 -49
- package/dist/core/config-schema.d.ts +9 -0
- package/dist/core/config-schema.js +15 -0
- package/dist/core/eval-driver.d.ts +9 -1
- package/dist/core/eval-spec-parse.d.ts +14 -0
- package/dist/core/eval-spec-parse.js +168 -0
- package/dist/core/markdown.d.ts +17 -0
- package/dist/core/markdown.js +57 -0
- package/dist/core/presets.d.ts +1 -1
- package/dist/core/presets.js +1 -0
- package/dist/core/rule-meta.js +12 -0
- package/dist/core/run-env.d.ts +46 -1
- package/dist/core/run-env.js +59 -0
- package/dist/core/spec.d.ts +45 -2
- package/dist/core/spec.js +16 -2
- package/dist/core/stub-rules.d.ts +199 -0
- package/dist/core/stub-rules.js +397 -0
- package/dist/core/types.d.ts +26 -0
- package/dist/eval-cache.d.ts +19 -1
- package/dist/eval-cache.js +1 -1
- package/dist/eval-entry.d.ts +28 -0
- package/dist/eval-entry.js +63 -10
- package/dist/eval-surface.d.ts +2 -0
- package/dist/eval.d.ts +80 -32
- package/dist/eval.js +293 -91
- package/dist/harness-test.d.ts +1 -1
- package/dist/services.d.ts +1 -1
- package/dist/services.js +1 -1
- package/dist/stub.d.ts +57 -0
- package/dist/stub.js +103 -0
- package/dist/test.d.ts +4 -1
- package/dist/test.js +8 -4
- package/dist/tool-stub.d.ts +24 -29
- package/dist/tool-stub.js +63 -74
- package/dist/vigilesrc.schema.json +94 -0
- package/package.json +1 -1
- package/skills/test-harness/references/observing-a-run.md +13 -12
- package/skills/test-harness/references/writing-tests.md +1 -0
package/dist/eval.js
CHANGED
|
@@ -16,6 +16,7 @@ exports.measureArmsWith = measureArmsWith;
|
|
|
16
16
|
exports.measureArms = measureArms;
|
|
17
17
|
exports.compareCheck = compareCheck;
|
|
18
18
|
exports.formatCheckReport = formatCheckReport;
|
|
19
|
+
exports.formatArmsCheckReport = formatArmsCheckReport;
|
|
19
20
|
exports.assertRates = assertRates;
|
|
20
21
|
exports.checkReportToJUnit = checkReportToJUnit;
|
|
21
22
|
exports.parseUsage = parseUsage;
|
|
@@ -32,6 +33,8 @@ exports.ephemeralRunEnv = ephemeralRunEnv;
|
|
|
32
33
|
exports.seedEphemeralHome = seedEphemeralHome;
|
|
33
34
|
exports.isRateLimited = isRateLimited;
|
|
34
35
|
exports.runPool = runPool;
|
|
36
|
+
exports.unansweredInReport = unansweredInReport;
|
|
37
|
+
exports.runEvalReporting = runEvalReporting;
|
|
35
38
|
exports.runEvalWith = runEvalWith;
|
|
36
39
|
exports.formatEvalReport = formatEvalReport;
|
|
37
40
|
exports.packageSkillsDir = packageSkillsDir;
|
|
@@ -54,6 +57,7 @@ exports.formatTriggerRateReport = formatTriggerRateReport;
|
|
|
54
57
|
* benchmark harness under `bench/` — the empirical half of testing your harness.
|
|
55
58
|
*
|
|
56
59
|
* const report = await runEval({
|
|
60
|
+
* env: { kind: "ephemeral" },
|
|
57
61
|
* fixture: { "src/billing.ts": "export function chargeCard(){}" },
|
|
58
62
|
* arms: {
|
|
59
63
|
* vanilla: {},
|
|
@@ -89,6 +93,8 @@ const check_count_js_1 = require("./check-count.js");
|
|
|
89
93
|
const coverage_probe_js_1 = require("./coverage-probe.js");
|
|
90
94
|
const tool_intercept_js_1 = require("./tool-intercept.js");
|
|
91
95
|
const tool_stub_js_1 = require("./tool-stub.js");
|
|
96
|
+
const stub_rules_js_1 = require("./core/stub-rules.js");
|
|
97
|
+
const eval_spec_parse_js_1 = require("./core/eval-spec-parse.js");
|
|
92
98
|
const tmp_root_js_1 = require("./core/tmp-root.js");
|
|
93
99
|
const edit_distance_js_1 = require("./core/edit-distance.js");
|
|
94
100
|
/**
|
|
@@ -108,7 +114,7 @@ function writeFiles(cwd, files) {
|
|
|
108
114
|
}
|
|
109
115
|
/**
|
|
110
116
|
* Resolve the environment a trial's subprocess actually runs with — the
|
|
111
|
-
* SECURITY-CRITICAL decision behind `
|
|
117
|
+
* SECURITY-CRITICAL decision behind `env: { kind: "ephemeral" }`. When `replaceEnv` is set, the
|
|
112
118
|
* scrubbed `env` is the COMPLETE environment, so the real `$HOME` and inherited
|
|
113
119
|
* secrets are DROPPED; otherwise `env` is an overlay on `base`. In BOTH, the
|
|
114
120
|
* harness's declared session identity (`runEnv.sessionIdentity`) is removed: a
|
|
@@ -345,13 +351,14 @@ function warnUnregisteredSkillArms(arms) {
|
|
|
345
351
|
}
|
|
346
352
|
}
|
|
347
353
|
async function runEval(spec) {
|
|
348
|
-
|
|
349
|
-
const report = await runEvalWith(spec, exports.spawnAgent);
|
|
350
|
-
// Surface what the run spent — tokens + API-equivalent $, and a LOUD warning if
|
|
351
|
-
// it was billed to a metered API key instead of the subscription. See eval-cost.ts.
|
|
352
|
-
(0, eval_cost_js_1.emitCostSummary)((0, eval_cost_js_1.costFromEvalReport)(report));
|
|
353
|
-
return report;
|
|
354
|
+
return runEvalReporting(spec, exports.spawnAgent);
|
|
354
355
|
}
|
|
356
|
+
/** The env `measure` / `measureArms` pass on: they have no `env` field yet. */
|
|
357
|
+
const MEASURE_INHERIT_REASON = "measure and measureArms have no `env` field; they run with your HOME and environment (docs/safety.md)";
|
|
358
|
+
const INHERITED_BY_MEASURE = {
|
|
359
|
+
kind: "inherit",
|
|
360
|
+
reason: MEASURE_INHERIT_REASON,
|
|
361
|
+
};
|
|
355
362
|
/**
|
|
356
363
|
* Score a check vocabulary across trials — the scored counterpart to
|
|
357
364
|
* `assertChecks` (strict). Each check yields a `rate ± se` and `pass^k` over `n`
|
|
@@ -396,6 +403,9 @@ async function measureWith(spec, runner) {
|
|
|
396
403
|
allowedTools: spec.allowedTools,
|
|
397
404
|
timeoutMs: spec.timeoutMs,
|
|
398
405
|
spacingSec: spec.spacingSec,
|
|
406
|
+
// measure / measureArms have no `env` field (yet): they run with your
|
|
407
|
+
// HOME and environment, as before — said here, not defaulted silently.
|
|
408
|
+
env: INHERITED_BY_MEASURE,
|
|
399
409
|
measure: (ctx) => Object.fromEntries(keyed.map(([k, c]) => [k, c.eval(ctx).pass])),
|
|
400
410
|
}, runner);
|
|
401
411
|
return checkReportOf(report.arms.run, keyed, installNamespace(arm));
|
|
@@ -450,6 +460,9 @@ async function measureArmsWith(spec, runner) {
|
|
|
450
460
|
allowedTools: spec.allowedTools,
|
|
451
461
|
timeoutMs: spec.timeoutMs,
|
|
452
462
|
spacingSec: spec.spacingSec,
|
|
463
|
+
// measure / measureArms have no `env` field (yet): they run with your
|
|
464
|
+
// HOME and environment, as before — said here, not defaulted silently.
|
|
465
|
+
env: INHERITED_BY_MEASURE,
|
|
453
466
|
measure: (ctx) => Object.fromEntries(keyed.map(([k, c]) => [k, c.eval(ctx).pass])),
|
|
454
467
|
}, runner);
|
|
455
468
|
const arms = {};
|
|
@@ -491,8 +504,25 @@ function checkLabel(json) {
|
|
|
491
504
|
}
|
|
492
505
|
return json.kind;
|
|
493
506
|
}
|
|
494
|
-
/**
|
|
507
|
+
/** The report line that says a run used your real HOME and environment, and why. */
|
|
508
|
+
const inheritLine = (reason) => ` env: inherited your HOME and environment — ${reason}`;
|
|
509
|
+
/**
|
|
510
|
+
* Format a {@link CheckReport}: one line per check with its rate ± se and
|
|
511
|
+
* pass^k, and the line saying `measure` ran with your HOME and environment
|
|
512
|
+
* (it has no `env` field; `INHERITED_BY_MEASURE`).
|
|
513
|
+
*/
|
|
495
514
|
function formatCheckReport(report) {
|
|
515
|
+
return [formatChecks(report), inheritLine(MEASURE_INHERIT_REASON)].join("\n");
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Format an {@link ArmsCheckReport}: each arm's checks under `[arm: name]`, and
|
|
519
|
+
* the inherit line ONCE at the end — every arm ran the same way.
|
|
520
|
+
*/
|
|
521
|
+
function formatArmsCheckReport(report) {
|
|
522
|
+
const arms = Object.entries(report.arms).map(([name, arm]) => `\n[arm: ${name}]\n${formatChecks(arm)}`);
|
|
523
|
+
return [...arms, inheritLine(MEASURE_INHERIT_REASON)].join("\n");
|
|
524
|
+
}
|
|
525
|
+
function formatChecks(report) {
|
|
496
526
|
const lines = [`measured ${String(report.n)} run(s):`];
|
|
497
527
|
for (const c of report.perCheck) {
|
|
498
528
|
lines.push(` ${(c.rate * 100).toFixed(0)}% ± ${(c.se * 100).toFixed(0)}% ${checkLabel(c.check)}` +
|
|
@@ -781,9 +811,11 @@ function aggregateUsage(usages) {
|
|
|
781
811
|
* miss, run the agent and (in `readwrite`) record output + cwd snapshot. The
|
|
782
812
|
* cache key excludes `measure`, so editing the metric still replays.
|
|
783
813
|
*/
|
|
784
|
-
async function runWithCache(runArgs, keyParts, runner, cfg) {
|
|
785
|
-
if (cfg.cache === "off")
|
|
786
|
-
|
|
814
|
+
async function runWithCache(runArgs, keyParts, runner, cfg, collectStubCalls) {
|
|
815
|
+
if (cfg.cache === "off") {
|
|
816
|
+
const out = await runner(runArgs);
|
|
817
|
+
return { out, stubCalls: collectStubCalls() };
|
|
818
|
+
}
|
|
787
819
|
const key = (0, eval_cache_js_1.cacheKey)({
|
|
788
820
|
task: runArgs.task,
|
|
789
821
|
model: runArgs.model,
|
|
@@ -791,7 +823,9 @@ async function runWithCache(runArgs, keyParts, runner, cfg) {
|
|
|
791
823
|
tools: runArgs.tools,
|
|
792
824
|
files: keyParts.files,
|
|
793
825
|
settings: keyParts.settings,
|
|
794
|
-
env:
|
|
826
|
+
env: keyParts.env,
|
|
827
|
+
stubs: keyParts.stubs.length > 0 ? keyParts.stubs : undefined,
|
|
828
|
+
runEnv: keyParts.runEnv,
|
|
795
829
|
// A native --plugin-dir install isn't in `files`, so hash its CONTENTS into
|
|
796
830
|
// the key — otherwise editing a skill in it would false-replay.
|
|
797
831
|
pluginDirHash: runArgs.pluginDir ? (0, eval_cache_js_1.hashDir)(runArgs.pluginDir) : undefined,
|
|
@@ -806,13 +840,20 @@ async function runWithCache(runArgs, keyParts, runner, cfg) {
|
|
|
806
840
|
// otherwise be scored as a trial on every later run.
|
|
807
841
|
if (hit && startFailure(hit.out) === null) {
|
|
808
842
|
(0, eval_cache_js_1.restoreDir)(runArgs.cwd, hit.files);
|
|
809
|
-
|
|
843
|
+
// The stub log lives beside the work dir, so the record carries it: a
|
|
844
|
+
// replayed trial reports the same calls, unanswered ones included.
|
|
845
|
+
return { out: hit.out, stubCalls: hit.stubCalls };
|
|
810
846
|
}
|
|
811
847
|
const out = await runner(runArgs);
|
|
848
|
+
const stubCalls = collectStubCalls();
|
|
812
849
|
if (cfg.cache === "readwrite") {
|
|
813
|
-
(0, eval_cache_js_1.writeCache)(cfg.cacheDir, key, {
|
|
850
|
+
(0, eval_cache_js_1.writeCache)(cfg.cacheDir, key, {
|
|
851
|
+
out,
|
|
852
|
+
files: (0, eval_cache_js_1.snapshotDir)(runArgs.cwd),
|
|
853
|
+
...(stubCalls === undefined ? {} : { stubCalls }),
|
|
854
|
+
});
|
|
814
855
|
}
|
|
815
|
-
return out;
|
|
856
|
+
return { out, stubCalls };
|
|
816
857
|
}
|
|
817
858
|
/**
|
|
818
859
|
* The `vigiles hook-runtime intercept-tool` command, as an absolute `node <cli> …`
|
|
@@ -822,7 +863,15 @@ async function runWithCache(runArgs, keyParts, runner, cfg) {
|
|
|
822
863
|
* entries).
|
|
823
864
|
*/
|
|
824
865
|
const INTERCEPT_TOOL_HOOK_CLI = [(0, node_path_1.join)(__dirname, "cli.js"), (0, node_path_1.join)(__dirname, "..", "dist", "cli.js")].find((p) => (0, node_fs_1.existsSync)(p)) ?? (0, node_path_1.join)(__dirname, "cli.js");
|
|
825
|
-
|
|
866
|
+
/** This node binary — what every command the eval writes for a child calls back into. */
|
|
867
|
+
const NODE_BIN = process.execPath;
|
|
868
|
+
const INTERCEPT_TOOL_HOOK_CMD = `"${NODE_BIN}" "${INTERCEPT_TOOL_HOOK_CLI}" hook-runtime intercept-tool`;
|
|
869
|
+
/**
|
|
870
|
+
* How a stub shim reaches the stub runtime: the same node and `cli.js` as the
|
|
871
|
+
* intercept hook, for the same reason (`npx vigiles` does not resolve from a
|
|
872
|
+
* throwaway dir). The shim runs `cli.js hook-runtime stub`.
|
|
873
|
+
*/
|
|
874
|
+
const STUB_LAUNCHER = { node: NODE_BIN, cli: INTERCEPT_TOOL_HOOK_CLI };
|
|
826
875
|
function isRecord(v) {
|
|
827
876
|
return v !== null && typeof v === "object";
|
|
828
877
|
}
|
|
@@ -988,9 +1037,91 @@ function seedEphemeralHome(throwawayHome, realHome, keep = EVAL_RUNTIME.runEnv?.
|
|
|
988
1037
|
(0, node_fs_1.copyFileSync)(src, dest);
|
|
989
1038
|
}
|
|
990
1039
|
}
|
|
1040
|
+
/**
|
|
1041
|
+
* The parts of a {@link RunEnv} that move behaviour — the kind and the seed —
|
|
1042
|
+
* for the cache key and the lock. Never the per-trial HOME path, and never an
|
|
1043
|
+
* `inherit` reason (prose, not an input).
|
|
1044
|
+
*/
|
|
1045
|
+
function runEnvKeyView(env) {
|
|
1046
|
+
return env.kind === "ephemeral"
|
|
1047
|
+
? { kind: env.kind, home: env.home }
|
|
1048
|
+
: { kind: env.kind };
|
|
1049
|
+
}
|
|
1050
|
+
/** The run environment as a report carries it: the kind, and an inherit's reason. */
|
|
1051
|
+
function runEnvReportView(env) {
|
|
1052
|
+
return env.kind === "ephemeral"
|
|
1053
|
+
? { kind: "ephemeral" }
|
|
1054
|
+
: { kind: "inherit", reason: env.reason };
|
|
1055
|
+
}
|
|
1056
|
+
/** Write a seed's files under a throwaway HOME, parents created. Effects only. */
|
|
1057
|
+
function materializeHome(home, files) {
|
|
1058
|
+
Object.entries(files).forEach(([path, contents]) => {
|
|
1059
|
+
const dest = (0, node_path_1.join)(home, path);
|
|
1060
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(dest), { recursive: true });
|
|
1061
|
+
(0, node_fs_1.writeFileSync)(dest, contents);
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Build a trial's environment from the spec's {@link RunEnv}. `ephemeral`: a
|
|
1066
|
+
* throwaway HOME (`home`, a directory BESIDE the trial cwd — never in it, or a
|
|
1067
|
+
* cache snapshot of the cwd would carry the credentials and the seed), seeded
|
|
1068
|
+
* (the seed first, then the harness's own auth file), and a scrubbed allowlist env — the eval's injected
|
|
1069
|
+
* keys (`VIGILES_INTERCEPT_TOOLS`) allowlisted through so interception works.
|
|
1070
|
+
* `inherit`: an overlay on the caller's env. Either way the stub bin dir, when
|
|
1071
|
+
* there is one, goes first on PATH.
|
|
1072
|
+
*/
|
|
1073
|
+
function trialEnv(runEnv, home, overlay, stubBin) {
|
|
1074
|
+
const withStubs = (path) => stubBin === undefined
|
|
1075
|
+
? (path ?? "")
|
|
1076
|
+
: path === undefined || path === ""
|
|
1077
|
+
? stubBin
|
|
1078
|
+
: `${stubBin}${node_path_1.delimiter}${path}`;
|
|
1079
|
+
switch (runEnv.kind) {
|
|
1080
|
+
case "ephemeral": {
|
|
1081
|
+
// The seed was validated at the spec boundary (`parseRunEnv`): every
|
|
1082
|
+
// path HOME-relative, inside HOME, and not the harness's auth file.
|
|
1083
|
+
materializeHome(home, runEnv.home?.files ?? {});
|
|
1084
|
+
// Carry the harness's own auth FILE (local OAuth) into the fresh HOME —
|
|
1085
|
+
// env-var/host-brokered auth is covered by ephemeralRunEnv's allowlist.
|
|
1086
|
+
seedEphemeralHome(home, process.env.HOME ?? (0, node_os_1.homedir)());
|
|
1087
|
+
const base = {
|
|
1088
|
+
...ephemeralRunEnv(process.env, {
|
|
1089
|
+
home,
|
|
1090
|
+
allow: overlay ? Object.keys(overlay) : [],
|
|
1091
|
+
}),
|
|
1092
|
+
...overlay,
|
|
1093
|
+
};
|
|
1094
|
+
return {
|
|
1095
|
+
env: stubBin === undefined
|
|
1096
|
+
? base
|
|
1097
|
+
: { ...base, PATH: withStubs(base.PATH) },
|
|
1098
|
+
replaceEnv: true,
|
|
1099
|
+
keyEnv: base,
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
case "inherit":
|
|
1103
|
+
// `spawnAgent` spreads `{ ...process.env, ...env }`, so a stub PATH is
|
|
1104
|
+
// set in the overlay as the stub dir prepended over process.env.PATH.
|
|
1105
|
+
return {
|
|
1106
|
+
env: stubBin === undefined
|
|
1107
|
+
? overlay
|
|
1108
|
+
: { ...overlay, PATH: withStubs(process.env.PATH) },
|
|
1109
|
+
replaceEnv: false,
|
|
1110
|
+
keyEnv: overlay,
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
991
1114
|
/** Execute one trial in a fresh sandbox; returns its metric row + usage. */
|
|
992
|
-
async function executeTrial(spec, arm, trialIndex,
|
|
1115
|
+
async function executeTrial(spec, arm, trialIndex, retrying, cfg) {
|
|
993
1116
|
const cwd = (0, tmp_root_js_1.makeTmpDir)("eval");
|
|
1117
|
+
// The ephemeral env's HOME: beside the work dir, so `snapshotDir(cwd)` (the
|
|
1118
|
+
// cache record) never sees the credentials file or the seed, and the agent's
|
|
1119
|
+
// `ls -a` of its project does not show it. An `inherit` run leaves it empty.
|
|
1120
|
+
const home = (0, tmp_root_js_1.makeTmpDir)("home");
|
|
1121
|
+
// Tool stubs (rung R2): a shim per stub in a temp dir BESIDE the work dir —
|
|
1122
|
+
// not in it, so the agent does not find vigiles plumbing in its project.
|
|
1123
|
+
const stubs = spec.stubs;
|
|
1124
|
+
const stubDir = stubs.length > 0 ? (0, tool_stub_js_1.writeStubDir)(stubs, STUB_LAUNCHER) : undefined;
|
|
994
1125
|
try {
|
|
995
1126
|
const resolved = (0, plugin_loader_js_1.resolveHarness)({
|
|
996
1127
|
plugin: arm.plugin,
|
|
@@ -1004,55 +1135,13 @@ async function executeTrial(spec, arm, trialIndex, runner, cfg) {
|
|
|
1004
1135
|
const overlay = intercepts.length > 0
|
|
1005
1136
|
? { [tool_intercept_js_1.INTERCEPT_TOOLS_ENV]: (0, tool_intercept_js_1.serializeIntercepts)(intercepts) }
|
|
1006
1137
|
: undefined;
|
|
1007
|
-
|
|
1008
|
-
// trial's own temp cwd + a scrubbed, auth-only allowlist. The eval's injected
|
|
1009
|
-
// keys (e.g. VIGILES_INTERCEPT_TOOLS) are allowlisted through so interception
|
|
1010
|
-
// still works. When OFF, `env`/`replaceEnv` are exactly as before.
|
|
1011
|
-
// Tool stubs on PATH (rung R2): write the fake binaries into a bin dir under
|
|
1012
|
-
// this trial's cwd; it is PREPENDED to whatever PATH the run uses below, so
|
|
1013
|
-
// the fakes win over the real binaries. Absent → no PATH change.
|
|
1014
|
-
const stubs = spec.stubs ?? [];
|
|
1015
|
-
const stubDir = stubs.length > 0
|
|
1016
|
-
? (0, tool_stub_js_1.stubBinDir)(stubs, (0, node_path_1.join)(cwd, ".vigiles-stubs"))
|
|
1017
|
-
: undefined;
|
|
1018
|
-
const prependPath = (path) => stubDir === undefined
|
|
1019
|
-
? (path ?? "")
|
|
1020
|
-
: path === undefined || path === ""
|
|
1021
|
-
? stubDir
|
|
1022
|
-
: `${stubDir}${node_path_1.delimiter}${path}`;
|
|
1023
|
-
const ephemeral = spec.ephemeralEnv === true;
|
|
1024
|
-
let env;
|
|
1025
|
-
let replaceEnv = false;
|
|
1026
|
-
if (ephemeral) {
|
|
1027
|
-
const home = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)(cwd, "home-"));
|
|
1028
|
-
// Carry the harness's own auth FILE (local OAuth) into the fresh HOME —
|
|
1029
|
-
// env-var/host-brokered auth is covered by ephemeralRunEnv's allowlist.
|
|
1030
|
-
seedEphemeralHome(home, process.env.HOME ?? (0, node_os_1.homedir)());
|
|
1031
|
-
env = ephemeralRunEnv(process.env, {
|
|
1032
|
-
home,
|
|
1033
|
-
allow: overlay ? Object.keys(overlay) : [],
|
|
1034
|
-
});
|
|
1035
|
-
if (overlay)
|
|
1036
|
-
Object.assign(env, overlay);
|
|
1037
|
-
// ephemeralRunEnv passes PATH through; prepend the stub dir over it.
|
|
1038
|
-
if (stubDir !== undefined)
|
|
1039
|
-
env.PATH = prependPath(env.PATH);
|
|
1040
|
-
replaceEnv = true;
|
|
1041
|
-
}
|
|
1042
|
-
else {
|
|
1043
|
-
// Legacy overlay path: `spawnAgent` spreads `{ ...process.env, ...env }`, so
|
|
1044
|
-
// set PATH in the overlay to the stub dir prepended over process.env.PATH.
|
|
1045
|
-
env =
|
|
1046
|
-
stubDir !== undefined
|
|
1047
|
-
? { ...overlay, PATH: prependPath(process.env.PATH) }
|
|
1048
|
-
: overlay;
|
|
1049
|
-
}
|
|
1138
|
+
const run = trialEnv(spec.env, home, overlay, stubDir?.binDir);
|
|
1050
1139
|
writeFiles(cwd, files);
|
|
1051
1140
|
const hasSettings = settings !== undefined;
|
|
1052
1141
|
if (hasSettings) {
|
|
1053
1142
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(cwd, "settings.json"), JSON.stringify(settings, null, 2).replaceAll("{cwd}", cwd));
|
|
1054
1143
|
}
|
|
1055
|
-
const out = await runWithCache({
|
|
1144
|
+
const { out, stubCalls } = await runWithCache({
|
|
1056
1145
|
task: spec.task,
|
|
1057
1146
|
cwd,
|
|
1058
1147
|
// A model comparison is a harness A/B: an arm may override the model.
|
|
@@ -1062,14 +1151,43 @@ async function executeTrial(spec, arm, trialIndex, runner, cfg) {
|
|
|
1062
1151
|
hasSettings,
|
|
1063
1152
|
pluginDir: arm.pluginDir,
|
|
1064
1153
|
timeoutMs: cfg.timeoutMs,
|
|
1065
|
-
env,
|
|
1066
|
-
replaceEnv,
|
|
1067
|
-
}, {
|
|
1068
|
-
|
|
1069
|
-
|
|
1154
|
+
env: run.env,
|
|
1155
|
+
replaceEnv: run.replaceEnv,
|
|
1156
|
+
}, {
|
|
1157
|
+
files,
|
|
1158
|
+
settings,
|
|
1159
|
+
trialIndex,
|
|
1160
|
+
env: run.keyEnv,
|
|
1161
|
+
stubs: stubs.map(stub_rules_js_1.encodeStub),
|
|
1162
|
+
runEnv: runEnvKeyView(spec.env),
|
|
1163
|
+
},
|
|
1164
|
+
// A rate-limited attempt is discarded and retried; its stub calls must
|
|
1165
|
+
// go with it, or the retry inherits a used-up `inOrder` answer and the
|
|
1166
|
+
// report counts calls from a run nobody scores.
|
|
1167
|
+
retrying(stubDir === undefined
|
|
1168
|
+
? undefined
|
|
1169
|
+
: () => {
|
|
1170
|
+
(0, tool_stub_js_1.resetStubCalls)(stubDir);
|
|
1171
|
+
}), cfg, () => (stubDir === undefined ? undefined : (0, tool_stub_js_1.readStubCalls)(stubDir)));
|
|
1172
|
+
const base = makeContext(cwd, out);
|
|
1173
|
+
const ctx = stubCalls === undefined ? base : { ...base, stubCalls };
|
|
1174
|
+
return {
|
|
1175
|
+
row: spec.measure(ctx),
|
|
1176
|
+
usage: ctx.usage,
|
|
1177
|
+
unanswered: [
|
|
1178
|
+
...(stubCalls ?? []),
|
|
1179
|
+
// A stub that could not write its log still printed its neutral line
|
|
1180
|
+
// into the agent's tool results: count those too, or a confined shell
|
|
1181
|
+
// would leave the report clean.
|
|
1182
|
+
...(0, stub_rules_js_1.unloggedMisses)(ctx.toolCalls.map((c) => c.resultText), stubCalls ?? [], stubs.map((s) => s.name)),
|
|
1183
|
+
].filter((c) => (0, stub_rules_js_1.isUnanswered)(c.outcome)),
|
|
1184
|
+
};
|
|
1070
1185
|
}
|
|
1071
1186
|
finally {
|
|
1072
1187
|
(0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
|
|
1188
|
+
(0, node_fs_1.rmSync)(home, { recursive: true, force: true });
|
|
1189
|
+
if (stubDir !== undefined)
|
|
1190
|
+
(0, tool_stub_js_1.removeStubDir)(stubDir);
|
|
1073
1191
|
}
|
|
1074
1192
|
}
|
|
1075
1193
|
// A signal in the captured streams that the model call was rate-limited /
|
|
@@ -1095,9 +1213,14 @@ const RATE_LIMIT_RE = /rate[ -]?limit(?:ed)?\b|rate_limit_error|\b429\b|overload
|
|
|
1095
1213
|
function isRateLimited(out) {
|
|
1096
1214
|
return RATE_LIMIT_RE.test(`${out.stderr ?? ""}\n${out.stdout}`);
|
|
1097
1215
|
}
|
|
1098
|
-
/**
|
|
1099
|
-
|
|
1216
|
+
/**
|
|
1217
|
+
* Call `runner`, retrying with exponential backoff while it looks rate-limited.
|
|
1218
|
+
* `beforeAttempt` runs before every attempt, the first included: it resets the
|
|
1219
|
+
* state a discarded attempt would otherwise leave to the next.
|
|
1220
|
+
*/
|
|
1221
|
+
async function runWithRetry(runArgs, runner, retries, baseMs, beforeAttempt) {
|
|
1100
1222
|
for (let attempt = 0;; attempt++) {
|
|
1223
|
+
beforeAttempt?.();
|
|
1101
1224
|
const out = await runner(runArgs);
|
|
1102
1225
|
if (!isRateLimited(out) || attempt >= retries)
|
|
1103
1226
|
return out;
|
|
@@ -1155,6 +1278,7 @@ function aggregateArms(armNames, results) {
|
|
|
1155
1278
|
metrics: aggregate(rows),
|
|
1156
1279
|
stats: aggregateStats(rows),
|
|
1157
1280
|
usage,
|
|
1281
|
+
unansweredStubCalls: done.flatMap((d) => d.unanswered),
|
|
1158
1282
|
};
|
|
1159
1283
|
}
|
|
1160
1284
|
return { arms, totalCostUsd };
|
|
@@ -1222,6 +1346,11 @@ async function withEvalLock(args, produce) {
|
|
|
1222
1346
|
if (decision.kind === "replay")
|
|
1223
1347
|
return decision.report;
|
|
1224
1348
|
const report = await produce();
|
|
1349
|
+
const refusal = args.refuse?.(report);
|
|
1350
|
+
if (refusal !== undefined) {
|
|
1351
|
+
emitLockMessage(`vigiles eval --${lock.mode}: the lock for "${args.name}" was NOT written — ${refusal}`, true);
|
|
1352
|
+
return report;
|
|
1353
|
+
}
|
|
1225
1354
|
const builtLock = (0, eval_lock_js_1.buildLock)({
|
|
1226
1355
|
name: args.name,
|
|
1227
1356
|
inputsHash,
|
|
@@ -1290,21 +1419,22 @@ function evalArmsInputs(spec, cfg) {
|
|
|
1290
1419
|
: undefined,
|
|
1291
1420
|
};
|
|
1292
1421
|
}
|
|
1293
|
-
// Tool stubs
|
|
1294
|
-
//
|
|
1295
|
-
//
|
|
1296
|
-
//
|
|
1297
|
-
//
|
|
1298
|
-
const stubs = [...
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
//
|
|
1302
|
-
//
|
|
1422
|
+
// Tool stubs are a model-facing input: a changed answer changes what the agent
|
|
1423
|
+
// sees. Folded in ENCODED (`encodeStub`), never as the raw rules —
|
|
1424
|
+
// `JSON.stringify(/^repos/)` is `{}`, so two specs differing only in a RegExp
|
|
1425
|
+
// token would hash the same and `--check` would pass a stale lock. Sorted by
|
|
1426
|
+
// name for a key that ignores declaration order.
|
|
1427
|
+
const stubs = [...spec.stubs]
|
|
1428
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
1429
|
+
.map(stub_rules_js_1.encodeStub);
|
|
1430
|
+
// Where the trials run (ephemeral + its seed, or inherit) moves behaviour, so
|
|
1431
|
+
// it is an input. The seed's CONTENTS are hashed — the author's input — never
|
|
1432
|
+
// the per-trial HOME path, which differs every run.
|
|
1303
1433
|
return {
|
|
1304
1434
|
task: spec.task,
|
|
1305
1435
|
arms,
|
|
1306
1436
|
stubs,
|
|
1307
|
-
|
|
1437
|
+
env: runEnvKeyView(spec.env),
|
|
1308
1438
|
};
|
|
1309
1439
|
}
|
|
1310
1440
|
// ---------------------------------------------------------------------------
|
|
@@ -1349,7 +1479,7 @@ const EVAL_SPEC_KEYS = {
|
|
|
1349
1479
|
maxCostUsd: true,
|
|
1350
1480
|
rateLimitRetries: true,
|
|
1351
1481
|
retryBackoffMs: true,
|
|
1352
|
-
|
|
1482
|
+
env: true,
|
|
1353
1483
|
stubs: true,
|
|
1354
1484
|
lock: true,
|
|
1355
1485
|
};
|
|
@@ -1443,7 +1573,45 @@ function assertKnownKeys(spec, known, at) {
|
|
|
1443
1573
|
" An unknown field is refused rather than ignored: a dropped field would " +
|
|
1444
1574
|
"make the run measure a setup you did not ask for.");
|
|
1445
1575
|
}
|
|
1576
|
+
/**
|
|
1577
|
+
* The author-facing failure for a report whose stubs left a call unanswered, or
|
|
1578
|
+
* undefined. One owner for the rule — `vigiles eval`'s exit 2 and the lock's
|
|
1579
|
+
* refusal both read it — so a consumer whose `assert` calls no helper still
|
|
1580
|
+
* fails. Pure.
|
|
1581
|
+
*/
|
|
1582
|
+
function unansweredInReport(report, stubs) {
|
|
1583
|
+
const calls = Object.values(report.arms).flatMap((a) => a.unansweredStubCalls);
|
|
1584
|
+
const message = (0, stub_rules_js_1.unansweredMessage)(calls, stubs);
|
|
1585
|
+
return message === undefined
|
|
1586
|
+
? undefined
|
|
1587
|
+
: `${message}\n The report is not a measurement: no lock is written, and \`vigiles eval\` exits 2.`;
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* `runEval` with its author-facing output, over any runner — so the real one and
|
|
1591
|
+
* a test's fake share one path. Besides the run it prints, to stderr, what the
|
|
1592
|
+
* caller must not miss: a skill arm that never activates, a stub call no rule
|
|
1593
|
+
* answered (the same text `vigiles eval` fails with — a script that calls
|
|
1594
|
+
* `runEval` itself, as the `withServices` examples do, gets the report back and
|
|
1595
|
+
* nothing else, so the library is not silent about it either), and what the
|
|
1596
|
+
* run spent.
|
|
1597
|
+
*/
|
|
1598
|
+
async function runEvalReporting(spec, runner) {
|
|
1599
|
+
warnUnregisteredSkillArms(spec.arms);
|
|
1600
|
+
const report = await runEvalWith(spec, runner);
|
|
1601
|
+
const unanswered = unansweredInReport(report, (0, eval_spec_parse_js_1.parseToolStubs)(spec.stubs, "runEval"));
|
|
1602
|
+
if (unanswered !== undefined)
|
|
1603
|
+
console.error(`⚠ runEval: ${unanswered}`);
|
|
1604
|
+
// Surface what the run spent — tokens + API-equivalent $, and a LOUD warning if
|
|
1605
|
+
// it was billed to a metered API key instead of the subscription. See eval-cost.ts.
|
|
1606
|
+
(0, eval_cost_js_1.emitCostSummary)((0, eval_cost_js_1.costFromEvalReport)(report));
|
|
1607
|
+
return report;
|
|
1608
|
+
}
|
|
1446
1609
|
async function runEvalWith(input, runner) {
|
|
1610
|
+
// A REMOVED field gets its rewrite, not "unknown field … did you mean".
|
|
1611
|
+
if (Object.hasOwn(input, "ephemeralEnv"))
|
|
1612
|
+
throw new Error("runEval: `ephemeralEnv` was replaced by `env`, which is required:\n" +
|
|
1613
|
+
' ephemeralEnv: true → env: { kind: "ephemeral" }\n' +
|
|
1614
|
+
' ephemeralEnv: false / absent → env: { kind: "inherit", reason: "…" } (your real HOME; the reason is printed with the report)');
|
|
1447
1615
|
// Refuse a stray field BEFORE spending a token: an eval file is plain JS, so a
|
|
1448
1616
|
// typo'd or misplaced key would otherwise vanish and the run would report a
|
|
1449
1617
|
// confident number about the wrong setup (issue #307).
|
|
@@ -1464,8 +1632,18 @@ async function runEvalWith(input, runner) {
|
|
|
1464
1632
|
// `skillsDir` packaged into a throwaway plugin) is resolved HERE, once — the
|
|
1465
1633
|
// one place that decides what `--plugin-dir` receives, so measure / measureArms
|
|
1466
1634
|
// / runEval cannot disagree about it. The throwaways are removed afterward.
|
|
1635
|
+
// `stubs` and `env` are parsed HERE, once: the old argv-blind stub shape, a
|
|
1636
|
+
// stateful RegExp, a missing `env`, a seed path outside HOME — each refused
|
|
1637
|
+
// with its rewrite before a token is spent. The runner trusts the result.
|
|
1638
|
+
const stubs = (0, eval_spec_parse_js_1.parseToolStubs)(input.stubs, "runEval");
|
|
1639
|
+
const env = (0, eval_spec_parse_js_1.parseRunEnv)(input.env, "runEval", EVAL_RUNTIME.runEnv?.keepHomeFiles ?? []);
|
|
1467
1640
|
const { arms: resolvedArms, packaged } = resolveArmInstalls(input.arms, input.stubSkillBodies ?? false);
|
|
1468
|
-
const spec = {
|
|
1641
|
+
const spec = {
|
|
1642
|
+
...input,
|
|
1643
|
+
arms: resolvedArms,
|
|
1644
|
+
stubs,
|
|
1645
|
+
env,
|
|
1646
|
+
};
|
|
1469
1647
|
try {
|
|
1470
1648
|
return await runResolvedEval(spec, runner);
|
|
1471
1649
|
}
|
|
@@ -1492,31 +1670,49 @@ async function runResolvedEval(spec, runner) {
|
|
|
1492
1670
|
if (cfg.cache !== "off" && !isDatedModel(cfg.model)) {
|
|
1493
1671
|
warnFloatingModel(cfg.model);
|
|
1494
1672
|
}
|
|
1495
|
-
const retrying = (a) => runWithRetry(a, runner, retries, backoffMs);
|
|
1673
|
+
const retrying = (beforeAttempt) => (a) => runWithRetry(a, runner, retries, backoffMs, beforeAttempt);
|
|
1496
1674
|
const units = buildUnits(spec.arms, trials);
|
|
1497
1675
|
let spent = 0;
|
|
1498
|
-
|
|
1676
|
+
// Why no NEW trial starts: the budget cap, or a stub call nobody scripted —
|
|
1677
|
+
// the report will be refused, so later trials would only spend. In-flight
|
|
1678
|
+
// trials finish either way.
|
|
1679
|
+
let stopped;
|
|
1499
1680
|
const worker = async (unit) => {
|
|
1500
|
-
if (
|
|
1681
|
+
if (stopped !== undefined)
|
|
1501
1682
|
return { armName: unit.armName, skipped: true };
|
|
1502
|
-
const { row, usage } = await executeTrial(spec, unit.arm, unit.trialIndex, retrying, cfg);
|
|
1683
|
+
const { row, usage, unanswered } = await executeTrial(spec, unit.arm, unit.trialIndex, retrying, cfg);
|
|
1503
1684
|
spent += usage.costUsd;
|
|
1504
|
-
if (spec.maxCostUsd !== undefined && spent >= spec.maxCostUsd)
|
|
1505
|
-
|
|
1506
|
-
|
|
1685
|
+
if (spec.maxCostUsd !== undefined && spent >= spec.maxCostUsd)
|
|
1686
|
+
stopped = "budget";
|
|
1687
|
+
else if (unanswered.length > 0)
|
|
1688
|
+
stopped = stopped ?? "unanswered";
|
|
1507
1689
|
if (spacing > 0)
|
|
1508
1690
|
await sleep(spacing);
|
|
1509
|
-
return { armName: unit.armName, skipped: false, row, usage };
|
|
1691
|
+
return { armName: unit.armName, skipped: false, row, usage, unanswered };
|
|
1510
1692
|
};
|
|
1511
1693
|
const lock = resolveLock(spec.lock);
|
|
1512
1694
|
// Resolve the lock inputs only when the lock is active (off skips the
|
|
1513
1695
|
// resolveHarness/hashDir work). `check` replays the committed report below
|
|
1514
1696
|
// without ever entering the run pool — so no model is driven in CI.
|
|
1515
1697
|
const inputs = lock.mode === "off" ? undefined : evalArmsInputs(spec, cfg);
|
|
1516
|
-
return withEvalLock({
|
|
1698
|
+
return withEvalLock({
|
|
1699
|
+
name: spec.name,
|
|
1700
|
+
inputs,
|
|
1701
|
+
model: cfg.model,
|
|
1702
|
+
effort: cfg.effort,
|
|
1703
|
+
lock,
|
|
1704
|
+
refuse: (report) => unansweredInReport(report, spec.stubs),
|
|
1705
|
+
}, async () => {
|
|
1517
1706
|
const results = await runPool(units, concurrency, worker);
|
|
1518
1707
|
const { arms, totalCostUsd } = aggregateArms(Object.keys(spec.arms), results);
|
|
1519
|
-
return {
|
|
1708
|
+
return {
|
|
1709
|
+
name: spec.name ?? "eval",
|
|
1710
|
+
trials,
|
|
1711
|
+
arms,
|
|
1712
|
+
totalCostUsd,
|
|
1713
|
+
aborted: stopped === "budget",
|
|
1714
|
+
env: runEnvReportView(spec.env),
|
|
1715
|
+
};
|
|
1520
1716
|
});
|
|
1521
1717
|
}
|
|
1522
1718
|
/** Render one metric: `name=mean±se pass^k=…` (se/pass^k shown when measured). */
|
|
@@ -1549,7 +1745,13 @@ function formatEvalReport(report) {
|
|
|
1549
1745
|
.map(([k, v]) => formatMetric(k, v, r.stats[k]))
|
|
1550
1746
|
.join(" ");
|
|
1551
1747
|
lines.push(` ${arm.padEnd(10)} ${parts}${formatUsage(r.usage)}`);
|
|
1748
|
+
// One line: the calls themselves are printed once, by `runEval`
|
|
1749
|
+
// (`unansweredInReport`), not again under every arm of the table.
|
|
1750
|
+
if (r.unansweredStubCalls.length > 0)
|
|
1751
|
+
lines.push(` ⚠ ${arm}: ${String(r.unansweredStubCalls.length)} stub call(s) went unanswered — the report is not a measurement`);
|
|
1552
1752
|
}
|
|
1753
|
+
if (report.env?.kind === "inherit")
|
|
1754
|
+
lines.push(inheritLine(report.env.reason));
|
|
1553
1755
|
return lines.join("\n");
|
|
1554
1756
|
}
|
|
1555
1757
|
/**
|
package/dist/harness-test.d.ts
CHANGED
|
@@ -204,7 +204,7 @@ export declare function parseClaudeRun(stdout: string): ParsedRun;
|
|
|
204
204
|
* else from the caller: no harness auth (the scripted mock needs none, and a
|
|
205
205
|
* config-dir variable would point the run back at the real config), no
|
|
206
206
|
* identity, no secrets. The same scrubbed shape as the eval tier's
|
|
207
|
-
* `
|
|
207
|
+
* `env: { kind: "ephemeral" }` (`scrubbedRunEnv`), without the auth.
|
|
208
208
|
*/
|
|
209
209
|
export type HarnessRunHome = {
|
|
210
210
|
readonly home: "inherit";
|
package/dist/services.d.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* a dev box with NO production access;
|
|
30
30
|
* - point the task at the disposable service's connection string ONLY;
|
|
31
31
|
* - keep real credentials OUT of the run (prod `DATABASE_URL`, cloud keys,
|
|
32
|
-
* `~/.ssh`) — pair it with the eval tier's `
|
|
32
|
+
* `~/.ssh`) — pair it with the eval tier's `env: { kind: "ephemeral" }` (throwaway HOME +
|
|
33
33
|
* cleared env) to scrub them so the model has no real keys to misuse.
|
|
34
34
|
* Treat it like running an untrusted script. A future increment adds an egress
|
|
35
35
|
* wall (the skill reaches only the model + the service); until then that job is
|
package/dist/services.js
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* a dev box with NO production access;
|
|
31
31
|
* - point the task at the disposable service's connection string ONLY;
|
|
32
32
|
* - keep real credentials OUT of the run (prod `DATABASE_URL`, cloud keys,
|
|
33
|
-
* `~/.ssh`) — pair it with the eval tier's `
|
|
33
|
+
* `~/.ssh`) — pair it with the eval tier's `env: { kind: "ephemeral" }` (throwaway HOME +
|
|
34
34
|
* cleared env) to scrub them so the model has no real keys to misuse.
|
|
35
35
|
* Treat it like running an untrusted script. A future increment adds an egress
|
|
36
36
|
* wall (the skill reaches only the model + the service); until then that job is
|