vigiles 2.3.0 → 2.5.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/README.md +214 -190
- package/dist/agent-result.d.ts +40 -0
- package/dist/agent-result.js +97 -0
- package/dist/agent-runtime.d.ts +64 -0
- package/dist/agent-runtime.js +147 -0
- package/dist/cli.js +155 -1
- package/dist/compile.d.ts +32 -3
- package/dist/compile.js +268 -0
- package/dist/eval-cache.d.ts +33 -0
- package/dist/eval-cache.js +94 -0
- package/dist/eval.d.ts +180 -9
- package/dist/eval.js +319 -57
- package/dist/harness-assert.d.ts +175 -6
- package/dist/harness-assert.js +355 -4
- package/dist/harness-test.d.ts +130 -5
- package/dist/harness-test.js +205 -32
- package/dist/judge.js +2 -0
- package/dist/linters.d.ts +6 -0
- package/dist/linters.js +1 -0
- package/dist/mcp.d.ts +48 -0
- package/dist/mcp.js +247 -0
- package/dist/mock-entry.d.ts +2 -0
- package/dist/mock-entry.js +36 -0
- package/dist/mock-model.d.ts +29 -0
- package/dist/mock-model.js +40 -0
- package/dist/plugin-loader.js +51 -17
- package/dist/sandbox.d.ts +76 -0
- package/dist/sandbox.js +241 -0
- package/dist/spec.d.ts +130 -0
- package/dist/spec.js +55 -0
- package/dist/stats.d.ts +49 -0
- package/dist/stats.js +109 -0
- package/package.json +7 -3
package/dist/eval.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runEval = runEval;
|
|
4
|
+
exports.parseUsage = parseUsage;
|
|
3
5
|
exports.aggregate = aggregate;
|
|
4
6
|
exports.aggregateStats = aggregateStats;
|
|
5
|
-
exports.
|
|
7
|
+
exports.aggregateUsage = aggregateUsage;
|
|
8
|
+
exports.isRateLimited = isRateLimited;
|
|
9
|
+
exports.runPool = runPool;
|
|
10
|
+
exports.runEvalWith = runEvalWith;
|
|
6
11
|
exports.formatEvalReport = formatEvalReport;
|
|
12
|
+
exports.measureTriggerRateWith = measureTriggerRateWith;
|
|
13
|
+
exports.measureTriggerRate = measureTriggerRate;
|
|
14
|
+
exports.formatTriggerRateReport = formatTriggerRateReport;
|
|
7
15
|
/**
|
|
8
16
|
* vigiles — Claude Code harness *evals*.
|
|
9
17
|
*
|
|
@@ -32,6 +40,8 @@ const node_fs_1 = require("node:fs");
|
|
|
32
40
|
const node_os_1 = require("node:os");
|
|
33
41
|
const node_path_1 = require("node:path");
|
|
34
42
|
const plugin_loader_js_1 = require("./plugin-loader.js");
|
|
43
|
+
const harness_test_js_1 = require("./harness-test.js");
|
|
44
|
+
const eval_cache_js_1 = require("./eval-cache.js");
|
|
35
45
|
function writeFiles(cwd, files) {
|
|
36
46
|
for (const [p, content] of Object.entries(files)) {
|
|
37
47
|
const full = (0, node_path_1.resolve)(cwd, p);
|
|
@@ -39,49 +49,87 @@ function writeFiles(cwd, files) {
|
|
|
39
49
|
(0, node_fs_1.writeFileSync)(full, content);
|
|
40
50
|
}
|
|
41
51
|
}
|
|
42
|
-
|
|
52
|
+
/* v8 ignore start -- real claude subprocess; exercised by bench/, not the unit gate */
|
|
53
|
+
function spawnAgent(a) {
|
|
43
54
|
return new Promise((resolvePromise) => {
|
|
44
55
|
const args = [
|
|
45
56
|
"-p",
|
|
46
|
-
task,
|
|
57
|
+
a.task,
|
|
58
|
+
// stream-json (+ --verbose, required with -p) so the per-turn tool_use
|
|
59
|
+
// events survive into `ctx.toolCalls` — the unified Trace, same as the
|
|
60
|
+
// harness tier. The terminal `result` event still carries num_turns/output.
|
|
47
61
|
"--output-format",
|
|
48
|
-
"json",
|
|
62
|
+
"stream-json",
|
|
63
|
+
"--verbose",
|
|
49
64
|
"--model",
|
|
50
|
-
model,
|
|
65
|
+
a.model,
|
|
51
66
|
"--permission-mode",
|
|
52
67
|
"acceptEdits",
|
|
53
|
-
...(
|
|
68
|
+
...(a.pluginDir !== undefined
|
|
69
|
+
? ["--plugin-dir", (0, node_path_1.resolve)(a.pluginDir)]
|
|
70
|
+
: []),
|
|
71
|
+
...(a.hasSettings ? ["--settings", "settings.json"] : []),
|
|
54
72
|
"--allowedTools",
|
|
55
|
-
...tools,
|
|
73
|
+
...a.tools,
|
|
56
74
|
];
|
|
57
75
|
const child = (0, node_child_process_1.spawn)("claude", args, {
|
|
58
|
-
cwd,
|
|
76
|
+
cwd: a.cwd,
|
|
59
77
|
env: process.env,
|
|
60
78
|
stdio: ["ignore", "pipe", "pipe"],
|
|
61
79
|
});
|
|
62
80
|
let stdout = "";
|
|
81
|
+
let stderr = "";
|
|
63
82
|
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
64
|
-
|
|
83
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
84
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), a.timeoutMs);
|
|
65
85
|
child.on("close", (code) => {
|
|
66
86
|
clearTimeout(timer);
|
|
67
|
-
resolvePromise({ code: code ?? 0, stdout });
|
|
87
|
+
resolvePromise({ code: code ?? 0, stdout, stderr });
|
|
68
88
|
});
|
|
69
89
|
});
|
|
70
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Run the eval: every arm × every trial against the real `claude` CLI, with the
|
|
93
|
+
* metric computed per run and aggregated per arm. Requires `claude` on PATH and
|
|
94
|
+
* working model auth (e.g. `ANTHROPIC_API_KEY`). Thin wrapper over
|
|
95
|
+
* {@link runEvalWith} with the real agent runner.
|
|
96
|
+
*/
|
|
97
|
+
async function runEval(spec) {
|
|
98
|
+
return runEvalWith(spec, spawnAgent);
|
|
99
|
+
}
|
|
100
|
+
/* v8 ignore stop */
|
|
71
101
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
102
|
+
/** Pull cost / latency / tokens out of a parsed `result` event (0 when absent). */
|
|
103
|
+
function usageFrom(result) {
|
|
104
|
+
const num = (v) => (typeof v === "number" ? v : 0);
|
|
105
|
+
const usage = (result?.usage ?? {});
|
|
106
|
+
return {
|
|
107
|
+
costUsd: num(result?.total_cost_usd),
|
|
108
|
+
durationMs: num(result?.duration_ms),
|
|
109
|
+
inputTokens: num(usage.input_tokens),
|
|
110
|
+
outputTokens: num(usage.output_tokens),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Parse per-run cost/latency/tokens from a stream — pure, model-free. */
|
|
114
|
+
function parseUsage(stdout) {
|
|
115
|
+
return usageFrom((0, harness_test_js_1.parseResultEvent)(stdout));
|
|
116
|
+
}
|
|
72
117
|
function makeContext(cwd, out) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
/* non-JSON output */
|
|
79
|
-
}
|
|
118
|
+
const result = (0, harness_test_js_1.parseResultEvent)(out.stdout);
|
|
119
|
+
const turns = typeof result?.num_turns === "number" ? result.num_turns : 0;
|
|
120
|
+
const output = typeof result?.result === "string" ? result.result : "";
|
|
80
121
|
return {
|
|
81
122
|
cwd,
|
|
82
123
|
exitCode: out.code,
|
|
83
124
|
stdout: out.stdout,
|
|
84
125
|
turns,
|
|
126
|
+
toolCalls: (0, harness_test_js_1.parseToolCalls)(out.stdout),
|
|
127
|
+
hooks: (0, harness_test_js_1.parseHooks)(out.stdout),
|
|
128
|
+
output,
|
|
129
|
+
usage: usageFrom(result),
|
|
130
|
+
// The eval tier drives the real API (no mock between claude and the model),
|
|
131
|
+
// so the requests can't be captured here — modelRequests is harness-tier only.
|
|
132
|
+
modelRequests: [],
|
|
85
133
|
file: (p) => {
|
|
86
134
|
const f = (0, node_path_1.resolve)(cwd, p);
|
|
87
135
|
return (0, node_fs_1.existsSync)(f) ? (0, node_fs_1.readFileSync)(f, "utf-8") : null;
|
|
@@ -142,66 +190,280 @@ function aggregateStats(rows) {
|
|
|
142
190
|
const std = n > 1
|
|
143
191
|
? Math.sqrt(values.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1))
|
|
144
192
|
: 0;
|
|
145
|
-
|
|
193
|
+
const passK = n > 0 && values.every((v) => v > 0) ? 1 : 0;
|
|
194
|
+
out[k] = { mean, std, se: n > 0 ? std / Math.sqrt(n) : 0, n, passK };
|
|
146
195
|
}
|
|
147
196
|
return out;
|
|
148
197
|
}
|
|
198
|
+
/** Aggregate per-run usage into an arm's cost / latency / token totals + means. */
|
|
199
|
+
function aggregateUsage(usages) {
|
|
200
|
+
const n = usages.length;
|
|
201
|
+
const sum = (f) => usages.reduce((a, u) => a + f(u), 0);
|
|
202
|
+
const totalCostUsd = sum((u) => u.costUsd);
|
|
203
|
+
return {
|
|
204
|
+
totalCostUsd,
|
|
205
|
+
meanCostUsd: n > 0 ? totalCostUsd / n : 0,
|
|
206
|
+
meanDurationMs: n > 0 ? sum((u) => u.durationMs) / n : 0,
|
|
207
|
+
totalInputTokens: sum((u) => u.inputTokens),
|
|
208
|
+
totalOutputTokens: sum((u) => u.outputTokens),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
149
211
|
/**
|
|
150
|
-
* Run
|
|
151
|
-
*
|
|
152
|
-
*
|
|
212
|
+
* Run one trial through the cache: on a hit, restore the recorded post-run
|
|
213
|
+
* filesystem into `cwd` and return the recorded output (no model call); on a
|
|
214
|
+
* miss, run the agent and (in `readwrite`) record output + cwd snapshot. The
|
|
215
|
+
* cache key excludes `measure`, so editing the metric still replays.
|
|
153
216
|
*/
|
|
154
|
-
async function
|
|
217
|
+
async function runWithCache(runArgs, keyParts, runner, cfg) {
|
|
218
|
+
if (cfg.cache === "off")
|
|
219
|
+
return runner(runArgs);
|
|
220
|
+
const key = (0, eval_cache_js_1.cacheKey)({
|
|
221
|
+
task: runArgs.task,
|
|
222
|
+
model: runArgs.model,
|
|
223
|
+
tools: runArgs.tools,
|
|
224
|
+
files: keyParts.files,
|
|
225
|
+
settings: keyParts.settings,
|
|
226
|
+
trialIndex: keyParts.trialIndex,
|
|
227
|
+
});
|
|
228
|
+
const hit = (0, eval_cache_js_1.readCache)(cfg.cacheDir, key);
|
|
229
|
+
if (hit) {
|
|
230
|
+
(0, eval_cache_js_1.restoreDir)(runArgs.cwd, hit.files);
|
|
231
|
+
return hit.out;
|
|
232
|
+
}
|
|
233
|
+
const out = await runner(runArgs);
|
|
234
|
+
if (cfg.cache === "readwrite") {
|
|
235
|
+
(0, eval_cache_js_1.writeCache)(cfg.cacheDir, key, { out, files: (0, eval_cache_js_1.snapshotDir)(runArgs.cwd) });
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
/** Execute one trial in a fresh sandbox; returns its metric row + usage. */
|
|
240
|
+
async function executeTrial(spec, arm, trialIndex, runner, cfg) {
|
|
241
|
+
const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-eval-"));
|
|
242
|
+
try {
|
|
243
|
+
const { files, settings } = (0, plugin_loader_js_1.resolveHarness)({
|
|
244
|
+
plugin: arm.plugin,
|
|
245
|
+
settings: arm.settings,
|
|
246
|
+
files: { ...spec.fixture, ...arm.files },
|
|
247
|
+
});
|
|
248
|
+
writeFiles(cwd, files);
|
|
249
|
+
const hasSettings = settings !== undefined;
|
|
250
|
+
if (hasSettings) {
|
|
251
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(cwd, "settings.json"), JSON.stringify(settings, null, 2).replaceAll("{cwd}", cwd));
|
|
252
|
+
}
|
|
253
|
+
const out = await runWithCache({
|
|
254
|
+
task: spec.task,
|
|
255
|
+
cwd,
|
|
256
|
+
model: cfg.model,
|
|
257
|
+
tools: cfg.tools,
|
|
258
|
+
hasSettings,
|
|
259
|
+
pluginDir: arm.pluginDir,
|
|
260
|
+
timeoutMs: cfg.timeoutMs,
|
|
261
|
+
}, { files, settings, trialIndex }, runner, cfg);
|
|
262
|
+
const ctx = makeContext(cwd, out);
|
|
263
|
+
return { row: spec.measure(ctx), usage: ctx.usage };
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
(0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// A signal in the captured streams that the model call was rate-limited /
|
|
270
|
+
// overloaded — worth a backoff + retry rather than counting as a real sample.
|
|
271
|
+
const RATE_LIMIT_RE = /rate.?limit|\b429\b|overloaded|too many requests/i;
|
|
272
|
+
/** Whether a run's captured output looks like a rate-limit / overload. Pure. */
|
|
273
|
+
function isRateLimited(out) {
|
|
274
|
+
return RATE_LIMIT_RE.test(`${out.stderr ?? ""}\n${out.stdout}`);
|
|
275
|
+
}
|
|
276
|
+
/** Call `runner`, retrying with exponential backoff while it looks rate-limited. */
|
|
277
|
+
async function runWithRetry(runArgs, runner, retries, baseMs) {
|
|
278
|
+
for (let attempt = 0;; attempt++) {
|
|
279
|
+
const out = await runner(runArgs);
|
|
280
|
+
if (!isRateLimited(out) || attempt >= retries)
|
|
281
|
+
return out;
|
|
282
|
+
await sleep(baseMs * 2 ** attempt);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Map `worker` over `items` with at most `concurrency` in flight, order preserved. */
|
|
286
|
+
async function runPool(items, concurrency, worker) {
|
|
287
|
+
const results = new Array(items.length);
|
|
288
|
+
let next = 0;
|
|
289
|
+
const drain = async () => {
|
|
290
|
+
for (;;) {
|
|
291
|
+
const i = next++;
|
|
292
|
+
const item = items[i];
|
|
293
|
+
if (i >= items.length || item === undefined)
|
|
294
|
+
return;
|
|
295
|
+
results[i] = await worker(item);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
const workers = Math.max(1, Math.min(concurrency, items.length || 1));
|
|
299
|
+
await Promise.all(Array.from({ length: workers }, drain));
|
|
300
|
+
return results;
|
|
301
|
+
}
|
|
302
|
+
/** Flatten arms × trials into a single work list (so concurrency spans both). */
|
|
303
|
+
function buildUnits(arms, trials) {
|
|
304
|
+
const units = [];
|
|
305
|
+
for (const [armName, arm] of Object.entries(arms)) {
|
|
306
|
+
for (let t = 0; t < trials; t++)
|
|
307
|
+
units.push({ armName, arm, trialIndex: t });
|
|
308
|
+
}
|
|
309
|
+
return units;
|
|
310
|
+
}
|
|
311
|
+
/** Group completed (non-skipped) unit results by arm and aggregate each. */
|
|
312
|
+
function aggregateArms(armNames, results) {
|
|
313
|
+
const arms = {};
|
|
314
|
+
let totalCostUsd = 0;
|
|
315
|
+
for (const armName of armNames) {
|
|
316
|
+
const done = results.filter((r) => !r.skipped && r.armName === armName);
|
|
317
|
+
const rows = done.map((d) => d.row);
|
|
318
|
+
const usage = aggregateUsage(done.map((d) => d.usage));
|
|
319
|
+
totalCostUsd += usage.totalCostUsd;
|
|
320
|
+
arms[armName] = {
|
|
321
|
+
runs: rows.length,
|
|
322
|
+
metrics: aggregate(rows),
|
|
323
|
+
stats: aggregateStats(rows),
|
|
324
|
+
usage,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return { arms, totalCostUsd };
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* The eval orchestration — every arm × trial via `runner`, run through the cache
|
|
331
|
+
* and a rate-limit retry, with at most `concurrency` in flight and an optional
|
|
332
|
+
* `maxCostUsd` budget cap; metric + usage computed per run and aggregated per
|
|
333
|
+
* arm. Exported with an injectable `runner` so the loop, `measure` context,
|
|
334
|
+
* caching, pooling, and aggregation are unit-testable without spawning a model
|
|
335
|
+
* (pass a fake returning canned stream-json). `runEval` is this with the real
|
|
336
|
+
* agent runner.
|
|
337
|
+
*/
|
|
338
|
+
async function runEvalWith(spec, runner) {
|
|
155
339
|
const trials = spec.trials ?? 5;
|
|
340
|
+
const spacing = (spec.spacingSec ?? 4) * 1000;
|
|
341
|
+
const concurrency = spec.concurrency ?? 1;
|
|
342
|
+
const retries = spec.rateLimitRetries ?? 3;
|
|
343
|
+
const backoffMs = spec.retryBackoffMs ?? 1000;
|
|
344
|
+
const cfg = {
|
|
345
|
+
model: spec.model ?? "haiku",
|
|
346
|
+
tools: spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"],
|
|
347
|
+
timeoutMs: spec.timeoutMs ?? 240000,
|
|
348
|
+
cache: spec.cache ?? "off",
|
|
349
|
+
cacheDir: spec.cacheDir ?? (0, node_path_1.resolve)(process.cwd(), ".vigiles", "eval-cache"),
|
|
350
|
+
};
|
|
351
|
+
const retrying = (a) => runWithRetry(a, runner, retries, backoffMs);
|
|
352
|
+
const units = buildUnits(spec.arms, trials);
|
|
353
|
+
let spent = 0;
|
|
354
|
+
let aborted = false;
|
|
355
|
+
const worker = async (unit) => {
|
|
356
|
+
if (aborted)
|
|
357
|
+
return { armName: unit.armName, skipped: true };
|
|
358
|
+
const { row, usage } = await executeTrial(spec, unit.arm, unit.trialIndex, retrying, cfg);
|
|
359
|
+
spent += usage.costUsd;
|
|
360
|
+
if (spec.maxCostUsd !== undefined && spent >= spec.maxCostUsd) {
|
|
361
|
+
aborted = true;
|
|
362
|
+
}
|
|
363
|
+
if (spacing > 0)
|
|
364
|
+
await sleep(spacing);
|
|
365
|
+
return { armName: unit.armName, skipped: false, row, usage };
|
|
366
|
+
};
|
|
367
|
+
const results = await runPool(units, concurrency, worker);
|
|
368
|
+
const { arms, totalCostUsd } = aggregateArms(Object.keys(spec.arms), results);
|
|
369
|
+
return { name: spec.name ?? "eval", trials, arms, totalCostUsd, aborted };
|
|
370
|
+
}
|
|
371
|
+
/** Render one metric: `name=mean±se pass^k=…` (se/pass^k shown when measured). */
|
|
372
|
+
function formatMetric(name, mean, stat) {
|
|
373
|
+
const base = stat && stat.se > 0
|
|
374
|
+
? `${name}=${mean.toFixed(2)}±${stat.se.toFixed(2)}`
|
|
375
|
+
: `${name}=${mean.toFixed(2)}`;
|
|
376
|
+
return stat && stat.n > 0 ? `${base} pass^k=${String(stat.passK)}` : base;
|
|
377
|
+
}
|
|
378
|
+
/** Compact tokens like `3.4k`; whole numbers under 1000 stay as-is. */
|
|
379
|
+
function fmtTokens(n) {
|
|
380
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
|
381
|
+
}
|
|
382
|
+
/** A `($0.0123 · 1.2s/run · 3.4k tok)` suffix, or "" when no usage was reported. */
|
|
383
|
+
function formatUsage(u) {
|
|
384
|
+
if (u.totalCostUsd === 0 && u.totalInputTokens + u.totalOutputTokens === 0) {
|
|
385
|
+
return "";
|
|
386
|
+
}
|
|
387
|
+
const tok = fmtTokens(u.totalInputTokens + u.totalOutputTokens);
|
|
388
|
+
return ` ($${u.totalCostUsd.toFixed(4)} · ${(u.meanDurationMs / 1000).toFixed(1)}s/run · ${tok} tok)`;
|
|
389
|
+
}
|
|
390
|
+
/** Format an eval report as a compact table for the console (mean ± se, pass^k). */
|
|
391
|
+
function formatEvalReport(report) {
|
|
392
|
+
const header = report.totalCostUsd > 0
|
|
393
|
+
? `${report.name} (${String(report.trials)} trials/arm) — $${report.totalCostUsd.toFixed(4)} total`
|
|
394
|
+
: `${report.name} (${String(report.trials)} trials/arm)`;
|
|
395
|
+
const lines = [header];
|
|
396
|
+
for (const [arm, r] of Object.entries(report.arms)) {
|
|
397
|
+
const parts = Object.entries(r.metrics)
|
|
398
|
+
.map(([k, v]) => formatMetric(k, v, r.stats[k]))
|
|
399
|
+
.join(" ");
|
|
400
|
+
lines.push(` ${arm.padEnd(10)} ${parts}${formatUsage(r.usage)}`);
|
|
401
|
+
}
|
|
402
|
+
return lines.join("\n");
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Trigger-rate orchestration — every prompt × trial via `runner`, the `fired`
|
|
406
|
+
* predicate evaluated per run and aggregated into an overall + per-prompt rate.
|
|
407
|
+
* Exported with an injectable `runner` so the loop is unit-testable without a
|
|
408
|
+
* model; `measureTriggerRate` is this with the real agent runner.
|
|
409
|
+
*/
|
|
410
|
+
async function measureTriggerRateWith(spec, runner) {
|
|
411
|
+
const trials = spec.trials ?? 1;
|
|
156
412
|
const model = spec.model ?? "haiku";
|
|
157
|
-
const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"];
|
|
413
|
+
const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash", "Skill"];
|
|
158
414
|
const timeoutMs = spec.timeoutMs ?? 240000;
|
|
159
415
|
const spacing = (spec.spacingSec ?? 4) * 1000;
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
416
|
+
const perPrompt = [];
|
|
417
|
+
let firedTotal = 0;
|
|
418
|
+
let n = 0;
|
|
419
|
+
for (const prompt of spec.prompts) {
|
|
420
|
+
let fired = 0;
|
|
163
421
|
for (let t = 0; t < trials; t++) {
|
|
164
|
-
const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-
|
|
422
|
+
const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-trigger-"));
|
|
165
423
|
try {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
424
|
+
const out = await runner({
|
|
425
|
+
task: prompt,
|
|
426
|
+
cwd,
|
|
427
|
+
model,
|
|
428
|
+
tools,
|
|
429
|
+
hasSettings: false,
|
|
430
|
+
pluginDir: spec.pluginDir,
|
|
431
|
+
timeoutMs,
|
|
170
432
|
});
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (hasSettings) {
|
|
174
|
-
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(cwd, "settings.json"), JSON.stringify(settings, null, 2).replaceAll("{cwd}", cwd));
|
|
175
|
-
}
|
|
176
|
-
const out = await spawnAgent(spec.task, cwd, model, tools, hasSettings, timeoutMs);
|
|
177
|
-
rows.push(spec.measure(makeContext(cwd, out)));
|
|
433
|
+
if (spec.fired(makeContext(cwd, out)))
|
|
434
|
+
fired++;
|
|
178
435
|
}
|
|
179
436
|
finally {
|
|
180
437
|
(0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
|
|
181
438
|
await sleep(spacing);
|
|
182
439
|
}
|
|
183
440
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
441
|
+
perPrompt.push({
|
|
442
|
+
prompt,
|
|
443
|
+
fired,
|
|
444
|
+
trials,
|
|
445
|
+
rate: trials > 0 ? fired / trials : 0,
|
|
446
|
+
});
|
|
447
|
+
firedTotal += fired;
|
|
448
|
+
n += trials;
|
|
189
449
|
}
|
|
190
|
-
return {
|
|
450
|
+
return { rate: n > 0 ? firedTotal / n : 0, n, perPrompt };
|
|
191
451
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
452
|
+
/* v8 ignore start -- real claude subprocess; thin wrapper over measureTriggerRateWith */
|
|
453
|
+
/**
|
|
454
|
+
* Measure a skill/behaviour's real trigger rate across prompts × trials against
|
|
455
|
+
* the real `claude` CLI. Requires `claude` + model auth.
|
|
456
|
+
*/
|
|
457
|
+
async function measureTriggerRate(spec) {
|
|
458
|
+
return measureTriggerRateWith(spec, spawnAgent);
|
|
459
|
+
}
|
|
460
|
+
/* v8 ignore stop */
|
|
461
|
+
/** Format a trigger-rate report: overall %, then each prompt's rate. */
|
|
462
|
+
function formatTriggerRateReport(report) {
|
|
463
|
+
const pct = (report.rate * 100).toFixed(0);
|
|
464
|
+
const lines = [`trigger-rate: ${pct}% (${String(report.n)} runs)`];
|
|
465
|
+
for (const p of report.perPrompt) {
|
|
466
|
+
lines.push(` ${p.rate.toFixed(2)} ${p.prompt.slice(0, 60)}`);
|
|
205
467
|
}
|
|
206
468
|
return lines.join("\n");
|
|
207
469
|
}
|
package/dist/harness-assert.d.ts
CHANGED
|
@@ -13,9 +13,13 @@
|
|
|
13
13
|
* `expect(...).toHaveCreated(...)` sugar. The signature is identical for
|
|
14
14
|
* vitest and jest, so the same object supports both.
|
|
15
15
|
*/
|
|
16
|
-
import { type HarnessTestSpec, type HarnessTestResult } from "./harness-test.js";
|
|
17
|
-
import type { EvalReport } from "./eval.js";
|
|
16
|
+
import { type HarnessTestSpec, type HarnessTestResult, type ToolCall, type Trace } from "./harness-test.js";
|
|
17
|
+
import type { EvalReport, TriggerRateReport } from "./eval.js";
|
|
18
18
|
import type { HookRunResult } from "./run-hook.js";
|
|
19
|
+
import type { OutputContract } from "./spec.js";
|
|
20
|
+
import { type ParsedAgentResult } from "./agent-result.js";
|
|
21
|
+
export { compareArms } from "./stats.js";
|
|
22
|
+
export type { Comparison } from "./stats.js";
|
|
19
23
|
/**
|
|
20
24
|
* Run a harness test, hand the result to `fn`, and always clean up the sandbox.
|
|
21
25
|
* Returns whatever `fn` returns. Use this instead of calling `cleanup()` by
|
|
@@ -32,18 +36,184 @@ export declare function assertServedTurns(r: HarnessTestResult, n: number): void
|
|
|
32
36
|
export declare function assertHookBlocked(r: HookRunResult): void;
|
|
33
37
|
/** Assert a `runHook` result allowed (did not block). */
|
|
34
38
|
export declare function assertHookAllowed(r: HookRunResult): void;
|
|
39
|
+
/**
|
|
40
|
+
* Assert the worker's output is a success result, and return its `value`. With a
|
|
41
|
+
* `contract`, the value is validated against the success shape (a wrong/missing
|
|
42
|
+
* field fails the assertion). A malformed or error result throws.
|
|
43
|
+
*/
|
|
44
|
+
export declare function assertAgentOk(output: string, contract?: OutputContract): Record<string, unknown>;
|
|
45
|
+
/**
|
|
46
|
+
* Assert the worker's output is an error result, and return its `error`. The
|
|
47
|
+
* railway's error track — proves the worker reported failure with rich detail
|
|
48
|
+
* (not that it crashed or returned prose). A malformed or success result throws.
|
|
49
|
+
*/
|
|
50
|
+
export declare function assertAgentErr(output: string, contract?: OutputContract): Record<string, unknown>;
|
|
51
|
+
/**
|
|
52
|
+
* Assert the parsed result satisfies `predicate` — the general form, for
|
|
53
|
+
* checking rich detail (e.g. `(r) => r.kind === "ok" && r.value.files.length > 0`).
|
|
54
|
+
*/
|
|
55
|
+
export declare function assertAgentResult(output: string, predicate: (r: ParsedAgentResult) => boolean, contract?: OutputContract): void;
|
|
56
|
+
/**
|
|
57
|
+
* Did the agent invoke a tool whose name matches `name` (string = exact,
|
|
58
|
+
* RegExp = test)? The predicate behind `assertToolUsed` / `assertToolNotUsed`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function usedTool(trace: Trace, name: string | RegExp): boolean;
|
|
61
|
+
/** How many tools matching `name` the agent invoked. Behind `assertToolCount`. */
|
|
62
|
+
export declare function toolCount(trace: Trace, name: string | RegExp): number;
|
|
63
|
+
/**
|
|
64
|
+
* Did the `Skill` tool resolve `skill` (e.g. `"superpowers:test-driven-development"`)
|
|
65
|
+
* without error? The skill-activation predicate behind `assertSkillResolved`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function skillResolved(trace: Trace, skill: string): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Did the agent invoke a tool matching `name` whose INPUT satisfies
|
|
70
|
+
* `inputMatcher` — a tool-ARGUMENT predicate (DeepEval-style), e.g. an `Edit`
|
|
71
|
+
* that targeted the right file. The predicate behind `assertToolUsedWith`.
|
|
72
|
+
*/
|
|
73
|
+
export declare function toolUsedWith(trace: Trace, name: string | RegExp, inputMatcher: (input: unknown) => boolean): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Does the agent's final answer (`trace.output`) contain `needle` (string =
|
|
76
|
+
* substring, RegExp = test)? The output predicate behind `assertOutputContains`
|
|
77
|
+
* — the DeepEval-style "what did the agent actually say" check.
|
|
78
|
+
*/
|
|
79
|
+
export declare function outputContains(trace: Trace, needle: string | RegExp): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Did ANY request the model received contain `needle` — searching the system
|
|
82
|
+
* prompt and every message across all requests? The predicate that proves
|
|
83
|
+
* injected context *reached the model*: a SessionStart hook's `additionalContext`
|
|
84
|
+
* or a slash command's expansion. Harness tier only — the eval tier drives the
|
|
85
|
+
* real API, so its `modelRequests` (and this) is empty. Behind `assertRequestContains`.
|
|
86
|
+
*/
|
|
87
|
+
export declare function requestContains(trace: Trace, needle: string | RegExp): boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Did a hook matching `name` fire? Matches against both the hook label
|
|
90
|
+
* (`"PreToolUse:Edit"`) and the bare event (`"PreToolUse"`), so `/PreToolUse/`
|
|
91
|
+
* or `"PreToolUse:Edit"` both work. The predicate behind `assertHookFired`.
|
|
92
|
+
*/
|
|
93
|
+
export declare function hookFired(trace: Trace, name: string | RegExp): boolean;
|
|
94
|
+
/** Did a hook matching `name` fire AND block (exit ≠ 0 / outcome "error")? */
|
|
95
|
+
export declare function hookBlocked(trace: Trace, name: string | RegExp): boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Assert the agent invoked a tool whose name matches `name` (string = exact,
|
|
98
|
+
* RegExp = test) — e.g. a skill (`"Skill"`), an MCP tool (`/^mcp__github__/`), or
|
|
99
|
+
* a subagent (`"Task"`). Needs `transcript: true`. The action invariant the
|
|
100
|
+
* skill/MCP/command surfaces are really about.
|
|
101
|
+
*/
|
|
102
|
+
export declare function assertToolUsed(trace: Trace, name: string | RegExp): void;
|
|
103
|
+
/**
|
|
104
|
+
* Assert the agent did NOT invoke any tool matching `name` — the safety negative
|
|
105
|
+
* (e.g. a destructive MCP tool was never called). "File unchanged" can pass by
|
|
106
|
+
* accident; "the tool was never used" is the real invariant. Needs `transcript`.
|
|
107
|
+
*/
|
|
108
|
+
export declare function assertToolNotUsed(trace: Trace, name: string | RegExp): void;
|
|
109
|
+
/**
|
|
110
|
+
* Assert the `Skill` tool resolved `skill` (e.g. `"superpowers:test-driven-development"`)
|
|
111
|
+
* without error — the correct skill-activation invariant, vs. grepping the body.
|
|
112
|
+
*/
|
|
113
|
+
export declare function assertSkillResolved(trace: Trace, skill: string): void;
|
|
114
|
+
/**
|
|
115
|
+
* Assert the agent invoked a tool matching `name` whose INPUT satisfies
|
|
116
|
+
* `inputMatcher` — a tool-ARGUMENT invariant (DeepEval-style). Asserts not just
|
|
117
|
+
* *that* a tool ran but *with what args*, e.g. an `Edit` that targeted the right
|
|
118
|
+
* file: `assertToolUsedWith(r, "Edit", (i) => (i as { file_path?: string })
|
|
119
|
+
* .file_path === "src/x.ts")`. Needs `transcript`.
|
|
120
|
+
*/
|
|
121
|
+
export declare function assertToolUsedWith(trace: Trace, name: string | RegExp, inputMatcher: (input: unknown) => boolean, message?: string): void;
|
|
122
|
+
/** Assert the agent's final answer contains `needle` (string substring / RegExp). */
|
|
123
|
+
export declare function assertOutputContains(trace: Trace, needle: string | RegExp): void;
|
|
124
|
+
/**
|
|
125
|
+
* Assert some request the model received contained `needle` — the "did the
|
|
126
|
+
* injected context land" invariant (SessionStart `additionalContext`, slash
|
|
127
|
+
* command expansion). Harness tier only; a zero-request trace fails with a hint
|
|
128
|
+
* that the eval tier can't capture requests.
|
|
129
|
+
*/
|
|
130
|
+
export declare function assertRequestContains(trace: Trace, needle: string | RegExp): void;
|
|
131
|
+
/**
|
|
132
|
+
* Assert a hook matching `name` fired (and, with `{ blocked: true }`, that it
|
|
133
|
+
* blocked) — the honest hook-firing check, recorded from the run's stream rather
|
|
134
|
+
* than inferred from a marker file the hook had to write. Needs `transcript`.
|
|
135
|
+
*/
|
|
136
|
+
export declare function assertHookFired(trace: Trace, name: string | RegExp, opts?: {
|
|
137
|
+
blocked?: boolean;
|
|
138
|
+
}): void;
|
|
139
|
+
/**
|
|
140
|
+
* Assert how many tools matching `name` the agent invoked is within bounds — a
|
|
141
|
+
* budget invariant (e.g. `{ max: 1 }` = "at most one Write", `{ exactly: 0 }` =
|
|
142
|
+
* "never touched it"). Catches runaway loops and wasted work. Needs `transcript`.
|
|
143
|
+
*/
|
|
144
|
+
export declare function assertToolCount(trace: Trace, name: string | RegExp, bounds: {
|
|
145
|
+
min?: number;
|
|
146
|
+
max?: number;
|
|
147
|
+
exactly?: number;
|
|
148
|
+
}): void;
|
|
149
|
+
/**
|
|
150
|
+
* Assert the named tools occurred in this order (as a subsequence — gaps allowed)
|
|
151
|
+
* — an ordering invariant. e.g. `["Read", "Edit"]` checks a Read came before an
|
|
152
|
+
* Edit. For a stricter rule (every Edit preceded by a Read), use `assertToolCalls`.
|
|
153
|
+
* Needs `transcript`.
|
|
154
|
+
*/
|
|
155
|
+
export declare function assertToolSequence(trace: Trace, names: ReadonlyArray<string | RegExp>): void;
|
|
156
|
+
/**
|
|
157
|
+
* The escape hatch: assert any custom invariant over the full list of tool calls
|
|
158
|
+
* the agent made — for rules the helpers above don't express, e.g. "every Edit
|
|
159
|
+
* was preceded by a Read of that file". Needs `transcript`.
|
|
160
|
+
*/
|
|
161
|
+
export declare function assertToolCalls(trace: Trace, predicate: (calls: readonly ToolCall[]) => boolean, message?: string): void;
|
|
162
|
+
/**
|
|
163
|
+
* Did `arm` succeed on EVERY trial for `metric` — τ-bench pass^k = 1? The
|
|
164
|
+
* reliability predicate over an eval report (vs. `improvement`, which reads the
|
|
165
|
+
* mean gap). Reads `report.arms[arm].stats[metric].passK`.
|
|
166
|
+
*/
|
|
167
|
+
export declare function reliable(report: EvalReport, arm: string, metric: string): boolean;
|
|
168
|
+
/**
|
|
169
|
+
* Assert `arm` passed `metric` on every trial (pass^k = 1) — the reliability
|
|
170
|
+
* gate for a non-deterministic harness ("worked every time", not "on average").
|
|
171
|
+
*/
|
|
172
|
+
export declare function assertReliable(report: EvalReport, opts: {
|
|
173
|
+
arm: string;
|
|
174
|
+
metric: string;
|
|
175
|
+
}): void;
|
|
35
176
|
/** The gap on `metric` between two arms (arm − baseline). */
|
|
36
177
|
export declare function improvement(report: EvalReport, baseline: string, arm: string, metric: string): number;
|
|
37
178
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
179
|
+
* Did `arm` *significantly* beat `baseline` on `metric` — a positive gap whose
|
|
180
|
+
* two-sided Welch t-test p-value is below `alpha` (default 0.05)? The grounded
|
|
181
|
+
* upgrade over `improvement`: the noise floor is computed from the arms' spread,
|
|
182
|
+
* not hand-fed. False when either arm/metric is missing. See `src/stats.ts`.
|
|
183
|
+
*/
|
|
184
|
+
export declare function significantlyBeats(report: EvalReport, baseline: string, arm: string, metric: string, alpha?: number): boolean;
|
|
185
|
+
/**
|
|
186
|
+
* Assert `arm` significantly beats `baseline` on `metric` (positive gap, p < α).
|
|
187
|
+
* The statistical gate for a non-deterministic A/B — "the gap clears the noise",
|
|
188
|
+
* with the noise floor computed, not supplied. The honest version of
|
|
189
|
+
* `assertImproves(..., { by: se })`.
|
|
190
|
+
*/
|
|
191
|
+
export declare function assertSignificant(report: EvalReport, opts: {
|
|
192
|
+
baseline: string;
|
|
193
|
+
arm: string;
|
|
194
|
+
metric: string;
|
|
195
|
+
alpha?: number;
|
|
196
|
+
}): void;
|
|
197
|
+
/**
|
|
198
|
+
* Assert `arm` beats `baseline` on `metric`. By default just a positive gap > `by`
|
|
199
|
+
* (pass the combined se to clear the noise floor by hand). Pass `{ significant:
|
|
200
|
+
* true }` to demand a Welch t-test at `alpha` instead — the computed noise floor.
|
|
41
201
|
*/
|
|
42
202
|
export declare function assertImproves(report: EvalReport, opts: {
|
|
43
203
|
baseline: string;
|
|
44
204
|
arm: string;
|
|
45
205
|
metric: string;
|
|
46
206
|
by?: number;
|
|
207
|
+
significant?: boolean;
|
|
208
|
+
alpha?: number;
|
|
209
|
+
}): void;
|
|
210
|
+
/**
|
|
211
|
+
* Assert a skill/behaviour triggered on at least `min` (0..1) of its runs — the
|
|
212
|
+
* reliability gate for a skill's *activation* (does its description fire on the
|
|
213
|
+
* task), over a {@link TriggerRateReport} from `measureTriggerRate`.
|
|
214
|
+
*/
|
|
215
|
+
export declare function assertTriggerRate(report: TriggerRateReport, opts: {
|
|
216
|
+
min: number;
|
|
47
217
|
}): void;
|
|
48
218
|
interface MatcherOutput {
|
|
49
219
|
pass: boolean;
|
|
@@ -64,5 +234,4 @@ export declare const vigilesMatchers: {
|
|
|
64
234
|
toBlock(received: HookRunResult): MatcherOutput;
|
|
65
235
|
toBeatBaseline(received: EvalReport, baseline: string, arm: string, metric: string, by?: number): MatcherOutput;
|
|
66
236
|
};
|
|
67
|
-
export {};
|
|
68
237
|
//# sourceMappingURL=harness-assert.d.ts.map
|