vigiles 2.4.0 → 2.6.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/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.runEval = runEval;
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,50 +49,87 @@ function writeFiles(cwd, files) {
39
49
  (0, node_fs_1.writeFileSync)(full, content);
40
50
  }
41
51
  }
42
- function spawnAgent(task, cwd, model, tools, hasSettings, pluginDir, timeoutMs) {
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
- ...(pluginDir !== undefined ? ["--plugin-dir", (0, node_path_1.resolve)(pluginDir)] : []),
54
- ...(hasSettings ? ["--settings", "settings.json"] : []),
68
+ ...(a.pluginDir !== undefined
69
+ ? ["--plugin-dir", (0, node_path_1.resolve)(a.pluginDir)]
70
+ : []),
71
+ ...(a.hasSettings ? ["--settings", "settings.json"] : []),
55
72
  "--allowedTools",
56
- ...tools,
73
+ ...a.tools,
57
74
  ];
58
75
  const child = (0, node_child_process_1.spawn)("claude", args, {
59
- cwd,
76
+ cwd: a.cwd,
60
77
  env: process.env,
61
78
  stdio: ["ignore", "pipe", "pipe"],
62
79
  });
63
80
  let stdout = "";
81
+ let stderr = "";
64
82
  child.stdout.on("data", (d) => (stdout += d.toString()));
65
- const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
83
+ child.stderr.on("data", (d) => (stderr += d.toString()));
84
+ const timer = setTimeout(() => child.kill("SIGKILL"), a.timeoutMs);
66
85
  child.on("close", (code) => {
67
86
  clearTimeout(timer);
68
- resolvePromise({ code: code ?? 0, stdout });
87
+ resolvePromise({ code: code ?? 0, stdout, stderr });
69
88
  });
70
89
  });
71
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 */
72
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
+ }
73
117
  function makeContext(cwd, out) {
74
- let turns = 0;
75
- try {
76
- turns = JSON.parse(out.stdout).num_turns ?? 0;
77
- }
78
- catch {
79
- /* non-JSON output */
80
- }
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 : "";
81
121
  return {
82
122
  cwd,
83
123
  exitCode: out.code,
84
124
  stdout: out.stdout,
85
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: [],
86
133
  file: (p) => {
87
134
  const f = (0, node_path_1.resolve)(cwd, p);
88
135
  return (0, node_fs_1.existsSync)(f) ? (0, node_fs_1.readFileSync)(f, "utf-8") : null;
@@ -143,66 +190,280 @@ function aggregateStats(rows) {
143
190
  const std = n > 1
144
191
  ? Math.sqrt(values.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1))
145
192
  : 0;
146
- out[k] = { mean, std, se: n > 0 ? std / Math.sqrt(n) : 0, n };
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 };
147
195
  }
148
196
  return out;
149
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
+ }
150
211
  /**
151
- * Run the eval: every arm × every trial against the real `claude` CLI, with the
152
- * metric computed per run and aggregated per arm. Requires `claude` on PATH and
153
- * working model auth (e.g. `ANTHROPIC_API_KEY`).
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.
154
216
  */
155
- async function runEval(spec) {
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) {
156
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;
157
412
  const model = spec.model ?? "haiku";
158
- const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash"];
413
+ const tools = spec.allowedTools ?? ["Read", "Edit", "Write", "Bash", "Skill"];
159
414
  const timeoutMs = spec.timeoutMs ?? 240000;
160
415
  const spacing = (spec.spacingSec ?? 4) * 1000;
161
- const arms = {};
162
- for (const [armName, arm] of Object.entries(spec.arms)) {
163
- const rows = [];
416
+ const perPrompt = [];
417
+ let firedTotal = 0;
418
+ let n = 0;
419
+ for (const prompt of spec.prompts) {
420
+ let fired = 0;
164
421
  for (let t = 0; t < trials; t++) {
165
- const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-eval-"));
422
+ const cwd = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "vigiles-trigger-"));
166
423
  try {
167
- const { files, settings } = (0, plugin_loader_js_1.resolveHarness)({
168
- plugin: arm.plugin,
169
- settings: arm.settings,
170
- files: { ...spec.fixture, ...arm.files },
424
+ const out = await runner({
425
+ task: prompt,
426
+ cwd,
427
+ model,
428
+ tools,
429
+ hasSettings: false,
430
+ pluginDir: spec.pluginDir,
431
+ timeoutMs,
171
432
  });
172
- writeFiles(cwd, files);
173
- const hasSettings = settings !== undefined;
174
- if (hasSettings) {
175
- (0, node_fs_1.writeFileSync)((0, node_path_1.join)(cwd, "settings.json"), JSON.stringify(settings, null, 2).replaceAll("{cwd}", cwd));
176
- }
177
- const out = await spawnAgent(spec.task, cwd, model, tools, hasSettings, arm.pluginDir, timeoutMs);
178
- rows.push(spec.measure(makeContext(cwd, out)));
433
+ if (spec.fired(makeContext(cwd, out)))
434
+ fired++;
179
435
  }
180
436
  finally {
181
437
  (0, node_fs_1.rmSync)(cwd, { recursive: true, force: true });
182
438
  await sleep(spacing);
183
439
  }
184
440
  }
185
- arms[armName] = {
186
- runs: rows.length,
187
- metrics: aggregate(rows),
188
- stats: aggregateStats(rows),
189
- };
441
+ perPrompt.push({
442
+ prompt,
443
+ fired,
444
+ trials,
445
+ rate: trials > 0 ? fired / trials : 0,
446
+ });
447
+ firedTotal += fired;
448
+ n += trials;
190
449
  }
191
- return { name: spec.name ?? "eval", trials, arms };
450
+ return { rate: n > 0 ? firedTotal / n : 0, n, perPrompt };
192
451
  }
193
- /** Format an eval report as a compact table for the console (mean ± se). */
194
- function formatEvalReport(report) {
195
- const lines = [`${report.name} (${String(report.trials)} trials/arm)`];
196
- for (const [arm, r] of Object.entries(report.arms)) {
197
- const parts = Object.entries(r.metrics)
198
- .map(([k, v]) => {
199
- const se = r.stats[k]?.se ?? 0;
200
- return se > 0
201
- ? `${k}=${v.toFixed(2)}±${se.toFixed(2)}`
202
- : `${k}=${v.toFixed(2)}`;
203
- })
204
- .join(" ");
205
- lines.push(` ${arm.padEnd(10)} ${parts}`);
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)}`);
206
467
  }
207
468
  return lines.join("\n");
208
469
  }