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.
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.cacheKey = cacheKey;
4
+ exports.readCache = readCache;
5
+ exports.writeCache = writeCache;
6
+ exports.snapshotDir = snapshotDir;
7
+ exports.restoreDir = restoreDir;
8
+ /**
9
+ * vigiles — record/replay cache for the eval tier.
10
+ *
11
+ * A real-model eval is slow and costs money, yet most iteration is on the
12
+ * `measure` function, not the model call. This cache records each trial's raw
13
+ * output AND its post-run filesystem, keyed on everything that determines the
14
+ * model's behaviour — `task`, the resolved fixture files + settings, model,
15
+ * tools, and the trial index — but DELIBERATELY NOT the `measure` function. So
16
+ * editing your metric and re-running re-scores the captured runs for free; the
17
+ * model is only re-called when a model-affecting input changes (or `cache:"off"`,
18
+ * which always re-samples for a fresh statistic).
19
+ *
20
+ * Restoring the post-run filesystem is what makes replay *sound*: `measure`
21
+ * routinely reads agent-produced files via `ctx.file()` / `ctx.sh("grep …")`, so
22
+ * a stdout-only cache would silently mis-score on replay. We snapshot the cwd's
23
+ * text files after the run and restore them into a fresh dir before re-scoring.
24
+ */
25
+ const node_fs_1 = require("node:fs");
26
+ const node_path_1 = require("node:path");
27
+ const hash_js_1 = require("./hash.js");
28
+ const MAX_SNAPSHOT_FILE_BYTES = 1024 * 1024;
29
+ const SKIP_DIRS = new Set(["node_modules", ".git"]);
30
+ /**
31
+ * Canonicalize a value so the key is stable regardless of object key order —
32
+ * recursively sorts object keys. Arrays keep order (it's significant for tools).
33
+ */
34
+ function canonical(value) {
35
+ if (Array.isArray(value))
36
+ return value.map(canonical);
37
+ if (value !== null && typeof value === "object") {
38
+ const obj = value;
39
+ const out = {};
40
+ for (const k of Object.keys(obj).sort())
41
+ out[k] = canonical(obj[k]);
42
+ return out;
43
+ }
44
+ return value;
45
+ }
46
+ /** Deterministic content hash of the key inputs (order-independent). */
47
+ function cacheKey(input) {
48
+ return (0, hash_js_1.sha256short)(JSON.stringify(canonical(input)));
49
+ }
50
+ /** Read a cached record by key, or null on miss / unreadable / malformed. */
51
+ function readCache(dir, key) {
52
+ const path = (0, node_path_1.join)(dir, `${key}.json`);
53
+ if (!(0, node_fs_1.existsSync)(path))
54
+ return null;
55
+ try {
56
+ return JSON.parse((0, node_fs_1.readFileSync)(path, "utf-8"));
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /** Write a cached record by key (creating the cache dir as needed). */
63
+ function writeCache(dir, key, record) {
64
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
65
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, `${key}.json`), JSON.stringify(record));
66
+ }
67
+ /** Snapshot the text files under `cwd` as `relativePath → contents` (bounded). */
68
+ function snapshotDir(cwd) {
69
+ const out = {};
70
+ const walk = (dir) => {
71
+ for (const entry of (0, node_fs_1.readdirSync)(dir)) {
72
+ if (SKIP_DIRS.has(entry))
73
+ continue;
74
+ const full = (0, node_path_1.join)(dir, entry);
75
+ const st = (0, node_fs_1.statSync)(full);
76
+ if (st.isDirectory())
77
+ walk(full);
78
+ else if (st.isFile() && st.size <= MAX_SNAPSHOT_FILE_BYTES) {
79
+ out[(0, node_path_1.relative)(cwd, full)] = (0, node_fs_1.readFileSync)(full, "utf-8");
80
+ }
81
+ }
82
+ };
83
+ walk((0, node_path_1.resolve)(cwd));
84
+ return out;
85
+ }
86
+ /** Restore a snapshot into `cwd`, recreating directories as needed. */
87
+ function restoreDir(cwd, files) {
88
+ for (const [rel, content] of Object.entries(files)) {
89
+ const full = (0, node_path_1.resolve)(cwd, rel);
90
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(full), { recursive: true });
91
+ (0, node_fs_1.writeFileSync)(full, content);
92
+ }
93
+ }
94
+ //# sourceMappingURL=eval-cache.js.map
package/dist/eval.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type ToolCall, type Trace } from "./harness-test.js";
2
+ import { type CacheMode } from "./eval-cache.js";
1
3
  /** One arm of the comparison: fixture overrides + settings (hooks) for this arm. */
2
4
  export interface EvalArm {
3
5
  /** Files written on top of the base fixture for this arm. */
@@ -19,15 +21,31 @@ export interface EvalArm {
19
21
  */
20
22
  readonly pluginDir?: string;
21
23
  }
22
- /** Context handed to `measure` after a run, to compute that run's metrics. */
23
- export interface RunContext {
24
+ /** Per-run resource use, parsed from the terminal `result` event (0 when absent). */
25
+ export interface EvalUsage {
26
+ /** `total_cost_usd` reported by claude. */
27
+ readonly costUsd: number;
28
+ /** Wall-clock `duration_ms` of the run. */
29
+ readonly durationMs: number;
30
+ readonly inputTokens: number;
31
+ readonly outputTokens: number;
32
+ }
33
+ /**
34
+ * Context handed to `measure` after a run, to compute that run's metrics. It is
35
+ * a `Trace` (so the bare predicates `usedTool` / `skillResolved` / `toolCount` /
36
+ * `toolUsedWith` from `harness-assert.ts` run over it, the same as over a
37
+ * `runHarnessTest` result) plus the eval-only `sh` end-state probe and `usage`.
38
+ */
39
+ export interface RunContext extends Trace {
24
40
  readonly cwd: string;
25
41
  readonly exitCode: number;
26
42
  readonly stdout: string;
27
43
  /** `num_turns` reported by claude, or 0. */
28
44
  readonly turns: number;
29
- /** Contents of a file under the working dir, or null if absent. */
30
- file(path: string): string | null;
45
+ /** The tools the agent invoked, each paired with its result (parsed from the stream). */
46
+ readonly toolCalls: readonly ToolCall[];
47
+ /** Cost / latency / tokens for this run (use as metrics, e.g. `{ cost: ctx.usage.costUsd }`). */
48
+ readonly usage: EvalUsage;
31
49
  /** Run a shell command in the working dir; returns trimmed stdout ("" on error). */
32
50
  sh(command: string): string;
33
51
  }
@@ -52,6 +70,32 @@ export interface EvalSpec<M extends Metrics> {
52
70
  readonly timeoutMs?: number;
53
71
  /** Seconds to wait between runs (avoid rate-limit bursts). Default 4. */
54
72
  readonly spacingSec?: number;
73
+ /**
74
+ * Record/replay cache mode. Default `"off"` (always re-sample). `"readwrite"`
75
+ * records each trial (output + post-run files) and replays it on a matching
76
+ * re-run — so editing `measure` re-scores for free; the model is re-called only
77
+ * when a model-affecting input changes. `"read"` replays but never records.
78
+ * The cache key excludes `measure`, so changing your metric still hits.
79
+ */
80
+ readonly cache?: CacheMode;
81
+ /** Where cache records live. Default `.vigiles/eval-cache` under cwd. */
82
+ readonly cacheDir?: string;
83
+ /**
84
+ * How many trials to run at once (across all arms × trials). Default 1 (fully
85
+ * sequential — the safe, no-surprise default). Raise it to cut wall-clock time;
86
+ * rate-limit bursts are absorbed by the retry/backoff below.
87
+ */
88
+ readonly concurrency?: number;
89
+ /**
90
+ * Abort the run once measured cost reaches this many USD. In-flight trials
91
+ * finish; remaining ones are skipped and `report.aborted` is set. Needs the
92
+ * model to report `total_cost_usd` (the eval tier does).
93
+ */
94
+ readonly maxCostUsd?: number;
95
+ /** Retries on a detected rate-limit/overload before giving up. Default 3. */
96
+ readonly rateLimitRetries?: number;
97
+ /** Base backoff ms (doubled each retry). Default 1000. */
98
+ readonly retryBackoffMs?: number;
55
99
  }
56
100
  /** Per-metric summary statistics across an arm's runs. */
57
101
  export interface MetricStat {
@@ -63,6 +107,21 @@ export interface MetricStat {
63
107
  readonly se: number;
64
108
  /** Number of runs the metric was observed in. */
65
109
  readonly n: number;
110
+ /**
111
+ * pass^k (τ-bench): 1 if the metric succeeded on EVERY trial, else 0. The
112
+ * reliability question a non-deterministic harness needs — "worked every time"
113
+ * is not "worked on average". A trial counts as a success when its value is
114
+ * truthy (booleans true, counts > 0), so model your metric as success/fail.
115
+ */
116
+ readonly passK: number;
117
+ }
118
+ /** Aggregated cost / latency / tokens across an arm's runs. */
119
+ export interface ArmUsage {
120
+ readonly totalCostUsd: number;
121
+ readonly meanCostUsd: number;
122
+ readonly meanDurationMs: number;
123
+ readonly totalInputTokens: number;
124
+ readonly totalOutputTokens: number;
66
125
  }
67
126
  export interface ArmReport {
68
127
  readonly runs: number;
@@ -70,12 +129,51 @@ export interface ArmReport {
70
129
  readonly metrics: Record<string, number>;
71
130
  /** Per-metric mean / std / se / n, so an A/B gap can be read for significance. */
72
131
  readonly stats: Record<string, MetricStat>;
132
+ /** Cost / latency / token totals + means for this arm. */
133
+ readonly usage: ArmUsage;
73
134
  }
74
135
  export interface EvalReport {
75
136
  readonly name: string;
76
137
  readonly trials: number;
77
138
  readonly arms: Record<string, ArmReport>;
139
+ /** Total measured cost across every arm × trial (0 when usage wasn't reported). */
140
+ readonly totalCostUsd: number;
141
+ /** True if a `maxCostUsd` budget cap stopped the run before all trials ran. */
142
+ readonly aborted: boolean;
78
143
  }
144
+ /** The raw output of one trial: the agent's exit code + captured streams. */
145
+ export interface RunOut {
146
+ code: number;
147
+ stdout: string;
148
+ /** Captured stderr, when the runner provides it (used for rate-limit detection). */
149
+ stderr?: string;
150
+ }
151
+ /** The per-trial arguments handed to an {@link AgentRunner}. */
152
+ export interface AgentRunArgs {
153
+ readonly task: string;
154
+ readonly cwd: string;
155
+ readonly model: string;
156
+ readonly tools: readonly string[];
157
+ readonly hasSettings: boolean;
158
+ readonly pluginDir: string | undefined;
159
+ readonly timeoutMs: number;
160
+ }
161
+ /**
162
+ * Runs one trial and returns its raw output. The default ({@link spawnAgent})
163
+ * drives the real `claude` CLI; `runEvalWith` takes one explicitly, so the eval
164
+ * orchestration is testable without a model (pass a fake returning canned
165
+ * stream-json) and a custom runtime can be plugged in.
166
+ */
167
+ export type AgentRunner = (args: AgentRunArgs) => Promise<RunOut>;
168
+ /**
169
+ * Run the eval: every arm × every trial against the real `claude` CLI, with the
170
+ * metric computed per run and aggregated per arm. Requires `claude` on PATH and
171
+ * working model auth (e.g. `ANTHROPIC_API_KEY`). Thin wrapper over
172
+ * {@link runEvalWith} with the real agent runner.
173
+ */
174
+ export declare function runEval<M extends Metrics>(spec: EvalSpec<M>): Promise<EvalReport>;
175
+ /** Parse per-run cost/latency/tokens from a stream — pure, model-free. */
176
+ export declare function parseUsage(stdout: string): EvalUsage;
79
177
  /** Aggregate per-run metrics: mean for numbers, fraction-true (0..1) for booleans. */
80
178
  export declare function aggregate(rows: readonly Metrics[]): Record<string, number>;
81
179
  /**
@@ -84,12 +182,77 @@ export declare function aggregate(rows: readonly Metrics[]): Record<string, numb
84
182
  * a difference smaller than the combined se is not yet significant.
85
183
  */
86
184
  export declare function aggregateStats(rows: readonly Metrics[]): Record<string, MetricStat>;
185
+ /** Aggregate per-run usage into an arm's cost / latency / token totals + means. */
186
+ export declare function aggregateUsage(usages: readonly EvalUsage[]): ArmUsage;
187
+ /** Whether a run's captured output looks like a rate-limit / overload. Pure. */
188
+ export declare function isRateLimited(out: RunOut): boolean;
189
+ /** Map `worker` over `items` with at most `concurrency` in flight, order preserved. */
190
+ export declare function runPool<T, R>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<R>): Promise<R[]>;
87
191
  /**
88
- * Run the eval: every arm × every trial against the real `claude` CLI, with the
89
- * metric computed per run and aggregated per arm. Requires `claude` on PATH and
90
- * working model auth (e.g. `ANTHROPIC_API_KEY`).
192
+ * The eval orchestration — every arm × trial via `runner`, run through the cache
193
+ * and a rate-limit retry, with at most `concurrency` in flight and an optional
194
+ * `maxCostUsd` budget cap; metric + usage computed per run and aggregated per
195
+ * arm. Exported with an injectable `runner` so the loop, `measure` context,
196
+ * caching, pooling, and aggregation are unit-testable without spawning a model
197
+ * (pass a fake returning canned stream-json). `runEval` is this with the real
198
+ * agent runner.
91
199
  */
92
- export declare function runEval<M extends Metrics>(spec: EvalSpec<M>): Promise<EvalReport>;
93
- /** Format an eval report as a compact table for the console (mean ± se). */
200
+ export declare function runEvalWith<M extends Metrics>(spec: EvalSpec<M>, runner: AgentRunner): Promise<EvalReport>;
201
+ /** Format an eval report as a compact table for the console (mean ± se, pass^k). */
94
202
  export declare function formatEvalReport(report: EvalReport): string;
203
+ /**
204
+ * Measure how reliably a skill/behaviour *triggers*. A skill's value is its
205
+ * description firing on the right task — the #1 documented skill-authoring pain —
206
+ * and that's a property of the real model, not the wiring (which the
207
+ * deterministic tier already proves). Install the plugin natively (`pluginDir`),
208
+ * give a set of varied `prompts`, and a `fired` predicate over the run's `Trace`
209
+ * (reuse the bare predicates, e.g. `(t) => skillResolved(t, "x:y")`).
210
+ */
211
+ export interface TriggerRateSpec {
212
+ /** Plugin dir installed natively (`--plugin-dir`) so its skills/commands activate. */
213
+ readonly pluginDir: string;
214
+ /** The varied prompts to test the trigger against. */
215
+ readonly prompts: readonly string[];
216
+ /** Did the behaviour fire on this run? e.g. `(t) => skillResolved(t, "x:y")`. */
217
+ readonly fired: (trace: Trace) => boolean;
218
+ /** Trials per prompt. Default 1. */
219
+ readonly trials?: number;
220
+ /** Model alias. Default "haiku". */
221
+ readonly model?: string;
222
+ /** Tools the agent may use. Default: Read Edit Write Bash Skill. */
223
+ readonly allowedTools?: readonly string[];
224
+ /** Per-run timeout ms. Default 240000. */
225
+ readonly timeoutMs?: number;
226
+ /** Seconds to wait between runs (avoid rate-limit bursts). Default 4. */
227
+ readonly spacingSec?: number;
228
+ }
229
+ /** Per-prompt trigger result: how many of its trials fired. */
230
+ export interface PromptTriggerStat {
231
+ readonly prompt: string;
232
+ readonly fired: number;
233
+ readonly trials: number;
234
+ /** `fired / trials` (0 when no trials). */
235
+ readonly rate: number;
236
+ }
237
+ export interface TriggerRateReport {
238
+ /** Overall fraction of runs in which the behaviour fired (0..1). */
239
+ readonly rate: number;
240
+ /** Total runs (prompts × trials). */
241
+ readonly n: number;
242
+ readonly perPrompt: readonly PromptTriggerStat[];
243
+ }
244
+ /**
245
+ * Trigger-rate orchestration — every prompt × trial via `runner`, the `fired`
246
+ * predicate evaluated per run and aggregated into an overall + per-prompt rate.
247
+ * Exported with an injectable `runner` so the loop is unit-testable without a
248
+ * model; `measureTriggerRate` is this with the real agent runner.
249
+ */
250
+ export declare function measureTriggerRateWith(spec: TriggerRateSpec, runner: AgentRunner): Promise<TriggerRateReport>;
251
+ /**
252
+ * Measure a skill/behaviour's real trigger rate across prompts × trials against
253
+ * the real `claude` CLI. Requires `claude` + model auth.
254
+ */
255
+ export declare function measureTriggerRate(spec: TriggerRateSpec): Promise<TriggerRateReport>;
256
+ /** Format a trigger-rate report: overall %, then each prompt's rate. */
257
+ export declare function formatTriggerRateReport(report: TriggerRateReport): string;
95
258
  //# sourceMappingURL=eval.d.ts.map