@sebastiantuyu/agest 0.3.3-next.11 → 0.3.3-next.12

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.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import type { CheckpointRecord } from "./types.js";
2
3
  /**
3
4
  * One record per `agent()` run, appended by the child process (see
4
5
  * AgentContext.execute → AGEST_SUMMARY_FILE). The parent reads them all back to
@@ -12,11 +13,14 @@ interface RunSummaryRecord {
12
13
  failed: number;
13
14
  duration: number;
14
15
  costUsd: number | null;
16
+ /** Full checkpoint payload the parent appends to the canonical run log. */
17
+ checkpoint?: CheckpointRecord;
15
18
  }
16
19
  export interface ParsedRunArgs {
17
20
  pattern?: string;
18
21
  targets: string[];
19
22
  full: boolean;
23
+ record: boolean;
20
24
  }
21
25
  /**
22
26
  * Extract the args that follow the command word from a full `process.argv`.
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "child_process";
3
3
  import { fileURLToPath } from "node:url";
4
- import { realpathSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { realpathSync, mkdtempSync, mkdirSync, readFileSync, appendFileSync, rmSync } from "node:fs";
5
+ import { randomUUID } from "node:crypto";
5
6
  import { tmpdir } from "node:os";
6
7
  import { join, dirname } from "node:path";
7
8
  import { main as stats } from "./stats.js";
@@ -22,6 +23,7 @@ export function parseRunArgs(args) {
22
23
  const targets = [];
23
24
  let pattern;
24
25
  let full = false;
26
+ let record = false;
25
27
  for (let i = 0; i < args.length; i++) {
26
28
  const a = args[i];
27
29
  if (a === "--pattern" || a === "-p") {
@@ -37,14 +39,17 @@ export function parseRunArgs(args) {
37
39
  else if (a === "--full") {
38
40
  full = true;
39
41
  }
42
+ else if (a === "--record") {
43
+ record = true;
44
+ }
40
45
  else {
41
46
  targets.push(a);
42
47
  }
43
48
  }
44
- return { pattern, targets, full };
49
+ return { pattern, targets, full, record };
45
50
  }
46
51
  async function run(args) {
47
- const { pattern, targets, full } = parseRunArgs(args);
52
+ const { pattern, targets, full, record } = parseRunArgs(args);
48
53
  const files = await discoverTestFiles(targets, { pattern });
49
54
  if (files.length === 0) {
50
55
  const effective = pattern ?? DEFAULT_PATTERN;
@@ -54,12 +59,17 @@ async function run(args) {
54
59
  // Each child appends a summary record here; the parent reads them back for
55
60
  // the aggregate footer. A unique dir keeps concurrent `agest run`s isolated.
56
61
  const summaryFile = join(mkdtempSync(join(tmpdir(), "agest-")), "summary.jsonl");
62
+ // One sweepId per invocation groups every checkpoint row from this run.
63
+ const sweepId = randomUUID();
57
64
  const childEnv = {
58
65
  ...process.env,
59
66
  AGEST_SUMMARY_FILE: summaryFile,
67
+ AGEST_SWEEP_ID: sweepId,
60
68
  // The test file renders its own output in a child process; propagate
61
69
  // --full so it emits the waterfall + full report rather than lean results.
62
70
  ...(full ? { AGEST_FULL: "1" } : {}),
71
+ // Opt-in: persist a full per-scene YAML snapshot per agent() execution.
72
+ ...(record ? { AGEST_RECORD: "1" } : {}),
63
73
  };
64
74
  let anyChildCrashed = false;
65
75
  // Run every file (vitest-style) instead of bailing on the first failure, so
@@ -78,6 +88,7 @@ async function run(args) {
78
88
  anyChildCrashed = true;
79
89
  }
80
90
  const records = readSummary(summaryFile);
91
+ writeCheckpoints(records);
81
92
  printRunSummary(records, files.length);
82
93
  try {
83
94
  rmSync(dirname(summaryFile), { recursive: true, force: true });
@@ -100,6 +111,27 @@ function readSummary(summaryFile) {
100
111
  return []; // no children wrote results (older lib, or all crashed early)
101
112
  }
102
113
  }
114
+ /**
115
+ * The parent is the single writer of the canonical run log: append every
116
+ * child's checkpoint record to `.reports/checkpoints.jsonl` in one buffer
117
+ * (race-free across the spawned children). Best-effort — never break a run.
118
+ */
119
+ function writeCheckpoints(records) {
120
+ const checkpoints = records
121
+ .map((r) => r.checkpoint)
122
+ .filter((c) => c != null);
123
+ if (checkpoints.length === 0)
124
+ return;
125
+ try {
126
+ const dir = join(process.cwd(), ".reports");
127
+ mkdirSync(dir, { recursive: true });
128
+ const lines = checkpoints.map((c) => JSON.stringify(c)).join("\n") + "\n";
129
+ appendFileSync(join(dir, "checkpoints.jsonl"), lines, "utf8");
130
+ }
131
+ catch {
132
+ /* ignore */
133
+ }
134
+ }
103
135
  /**
104
136
  * Aggregate every child's records into the footer totals. The footer only
105
137
  * shows when more than one case ran (multiple files, or one file with multiple
@@ -157,7 +189,10 @@ function printUsage() {
157
189
  agest run src/agest --pattern "**/*.test.ts"
158
190
  agest run "tests/**/*.agest.ts" path/to/file.agest.ts
159
191
  agest run tests/ --full # also print waterfall + full report
192
+ agest run tests/ --record # also save a full per-scene snapshot
160
193
  stats Show aggregated test statistics
194
+ agest stats --suite <suiteHash> # filter to one suite's history
195
+ agest stats --export-csv [path] # flatten the run log to CSV
161
196
  preview Generate an HTML report preview
162
197
  `);
163
198
  }
package/dist/context.d.ts CHANGED
@@ -53,5 +53,14 @@ export declare class AgentContext<T = string> {
53
53
  execute(): Promise<AgentReport<T>>;
54
54
  }
55
55
  export declare function hashPromptOnly(prompt: string): string;
56
+ /**
57
+ * Identity hash of the test suite: prompts, suite names, assertion field names +
58
+ * bodies (`fn.toString()`), and schema presence. Makes the suite a first-class
59
+ * dimension so a comparison can never silently span a changed set of scenes.
60
+ * `fn.toString()` is formatting/closure-sensitive — it over-segments (a cosmetic
61
+ * edit yields a new hash) but never silently merges two different suites, which
62
+ * is the safe direction.
63
+ */
64
+ export declare function computeSuiteHash(definitions: SceneDefinition[]): string;
56
65
  export declare function setContext(ctx: AgentContext<any> | null): void;
57
66
  export declare function getContext(): AgentContext<any>;
package/dist/context.js CHANGED
@@ -1,8 +1,10 @@
1
- import { createHash } from "crypto";
1
+ import { createHash, randomUUID } from "crypto";
2
2
  import { appendFileSync } from "node:fs";
3
+ import { relative } from "node:path";
3
4
  import { executeScene } from "./runner";
4
5
  import { resolveText } from "./resolve";
5
- import { formatReport, writeReport, writeDiffEntry } from "./reporter";
6
+ import { formatReport, writeSnapshot, appendCheckpoint, writeDiffEntry } from "./reporter";
7
+ import { wilsonInterval } from "./reports";
6
8
  import { logger, c } from "./logger";
7
9
  import { loadConfig } from "./config";
8
10
  import { setPricingOverrides } from "./pricing";
@@ -240,6 +242,9 @@ export class AgentContext {
240
242
  : undefined;
241
243
  const firstMeta = results.find((r) => r.response.metadata)?.response
242
244
  .metadata;
245
+ // Config identity. suiteHash + judge + runs complete the dimension set so
246
+ // comparisons never silently span a changed suite or sampling config.
247
+ // `temperature` is read opportunistically (open metadata map) when present.
243
248
  const dimensions = {};
244
249
  if (firstMeta?.model)
245
250
  dimensions.model = firstMeta.model;
@@ -249,6 +254,28 @@ export class AgentContext {
249
254
  dimensions.tools = [...firstMeta.tools].sort().join(",");
250
255
  else
251
256
  dimensions.tools = "none";
257
+ dimensions.suiteHash = computeSuiteHash(definitions);
258
+ dimensions.judge = config.judge?.model ?? "none";
259
+ dimensions.runs = String(config.runs ?? 1);
260
+ const temperature = firstMeta?.temperature;
261
+ if (temperature != null)
262
+ dimensions.temperature = String(temperature);
263
+ // Report-level statistical honesty. The trial basis is Σ runs across every
264
+ // scene (a multi-run scene contributes N trials), not just case count.
265
+ const casesPassed = results.filter((r) => r.passed).length;
266
+ let trials = 0;
267
+ let trialPasses = 0;
268
+ for (const r of results) {
269
+ if (r.runs && r.runs.length) {
270
+ trials += r.runs.length;
271
+ trialPasses += r.runs.filter((x) => x.passed).length;
272
+ }
273
+ else {
274
+ trials += 1;
275
+ trialPasses += r.passed ? 1 : 0;
276
+ }
277
+ }
278
+ const wilson = wilsonInterval(trialPasses, trials);
252
279
  const report = {
253
280
  name: this._name,
254
281
  model: firstMeta?.model,
@@ -266,6 +293,10 @@ export class AgentContext {
266
293
  timestamp: new Date().toISOString(),
267
294
  duration: Math.round(totalDuration),
268
295
  totalCases: results.length,
296
+ casesPassed,
297
+ runsPerScene: config.runs ?? 1,
298
+ wilsonLow: wilson.low,
299
+ wilsonHigh: wilson.high,
269
300
  averageInputTokensPerCase,
270
301
  averageOutputTokensPerCase,
271
302
  totalInputTokens,
@@ -287,28 +318,70 @@ export class AgentContext {
287
318
  const costSummary = totalCostUsd != null ? ` ${c.dim("·")} ${c.green(`$${Number(totalCostUsd.toFixed(4))}`)}` : "";
288
319
  logger.info(`${rateColor(`${passed}/${results.length} passed`)} ${c.dim(`(${(successRate * 100).toFixed(0)}%)`)} ${c.dim("·")} ${c.dim(`${Math.round(totalDuration)}ms`)}${costSummary}`);
289
320
  }
290
- const filepath = await writeReport(formatted, report.timestamp, report.name, report.dimensions);
291
- logger.info(`${c.dim("Report saved to:")} ${c.cyan(filepath)}${full ? "" : c.dim(" (run with --full to print it)")}`);
292
- // When launched by `agest run`, append a record so the parent can print a
293
- // cross-file aggregate footer. Best-effort: never let it break a run.
321
+ // A unique runId per agent() execution names the optional snapshot (so
322
+ // snapshots never clobber) and tags the checkpoint record.
323
+ const sweepId = process.env.AGEST_SWEEP_ID;
324
+ const runId = `${sweepId ?? "local"}-${randomUUID().slice(0, 8)}`;
325
+ // Heavy per-scene snapshot is opt-in via --record (AGEST_RECORD).
326
+ let recordPath;
327
+ if (process.env.AGEST_RECORD === "1") {
328
+ const snapPath = await writeSnapshot(formatted, runId);
329
+ recordPath = relative(process.cwd(), snapPath);
330
+ logger.info(`${c.dim("Snapshot saved to:")} ${c.cyan(recordPath)}`);
331
+ }
332
+ const checkpoint = {
333
+ runId,
334
+ sweepId,
335
+ timestamp: report.timestamp,
336
+ agentName: this._name,
337
+ model: report.model,
338
+ systemPromptHash: report.systemPromptHash,
339
+ tools: report.tools,
340
+ dimensions,
341
+ runsPerScene: report.runsPerScene,
342
+ totalCases: report.totalCases,
343
+ casesPassed,
344
+ successRate,
345
+ wilsonLow: report.wilsonLow,
346
+ wilsonHigh: report.wilsonHigh,
347
+ durationMs: Math.round(totalDuration),
348
+ costUsd: totalCostUsd ?? null,
349
+ totalInputTokens,
350
+ totalOutputTokens,
351
+ avgInputTokensPerCase: averageInputTokensPerCase,
352
+ avgOutputTokensPerCase: averageOutputTokensPerCase,
353
+ recordPath,
354
+ };
355
+ // When launched by `agest run`, hand the record to the parent (single writer
356
+ // of the checkpoint log) and let it print the cross-file footer. Standalone
357
+ // (`tsx foo.agest.ts`), this process is the lone writer — append directly.
358
+ // Best-effort throughout: never let persistence break a run.
294
359
  const summaryFile = process.env.AGEST_SUMMARY_FILE;
295
360
  if (summaryFile) {
296
- const passed = results.filter((r) => r.passed).length;
297
361
  try {
298
362
  appendFileSync(summaryFile, JSON.stringify({
299
363
  file: process.argv[1],
300
364
  name: this._name,
301
365
  total: results.length,
302
- passed,
303
- failed: results.length - passed,
366
+ passed: casesPassed,
367
+ failed: results.length - casesPassed,
304
368
  duration: Math.round(totalDuration),
305
369
  costUsd: totalCostUsd ?? null,
370
+ checkpoint,
306
371
  }) + "\n");
307
372
  }
308
373
  catch {
309
374
  /* ignore */
310
375
  }
311
376
  }
377
+ else {
378
+ try {
379
+ await appendCheckpoint(checkpoint);
380
+ }
381
+ catch {
382
+ /* ignore */
383
+ }
384
+ }
312
385
  return report;
313
386
  }
314
387
  }
@@ -319,6 +392,23 @@ function hashPrompt(prompt, model) {
319
392
  export function hashPromptOnly(prompt) {
320
393
  return createHash("sha256").update(prompt).digest("hex").slice(0, 12);
321
394
  }
395
+ /**
396
+ * Identity hash of the test suite: prompts, suite names, assertion field names +
397
+ * bodies (`fn.toString()`), and schema presence. Makes the suite a first-class
398
+ * dimension so a comparison can never silently span a changed set of scenes.
399
+ * `fn.toString()` is formatting/closure-sensitive — it over-segments (a cosmetic
400
+ * edit yields a new hash) but never silently merges two different suites, which
401
+ * is the safe direction.
402
+ */
403
+ export function computeSuiteHash(definitions) {
404
+ const canonical = definitions.map((d) => ({
405
+ prompt: d.prompt,
406
+ suite: d.suite ?? null,
407
+ schema: d.schema ? "1" : "0",
408
+ assertions: d.assertions.map((a) => ({ field: a.field, fn: a.fn.toString() })),
409
+ }));
410
+ return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 12);
411
+ }
322
412
  // The active context is a runtime singleton holding an executor of arbitrary
323
413
  // value type, so `any` is the honest type for the holder. The generic flows
324
414
  // through `agent()` → `AgentContext<T>` → the report at the call site.
package/dist/preview.js CHANGED
@@ -1,8 +1,8 @@
1
- import { readFile, writeFile } from "fs/promises";
2
- import { join, relative } from "path";
1
+ import { writeFile } from "fs/promises";
2
+ import { join } from "path";
3
3
  import os from "os";
4
4
  import { exec } from "child_process";
5
- import { parseReport, findReports, loadDiffEntry, wilsonLowerBound, computeDiff, formatDuration, ensureDimensions, findVaryingDimensions, groupByDimension, findControlledPairs, diffConfigs, } from "./reports.js";
5
+ import { loadReports, loadDiffEntry, wilsonLowerBound, computeDiff, formatDuration, findVaryingDimensions, groupByDimension, findControlledPairs, diffConfigs, } from "./reports.js";
6
6
  // ---------------------------------------------------------------------------
7
7
  // Helpers
8
8
  // ---------------------------------------------------------------------------
@@ -1313,17 +1313,11 @@ function generateHTML(groups, totalReports) {
1313
1313
  // ---------------------------------------------------------------------------
1314
1314
  async function main() {
1315
1315
  const cwd = process.cwd();
1316
- const files = await findReports(cwd);
1317
- if (files.length === 0) {
1316
+ const reports = await loadReports(cwd);
1317
+ if (reports.length === 0) {
1318
1318
  console.log("\n No reports found. Run some agent tests first.\n");
1319
1319
  return;
1320
1320
  }
1321
- const reports = await Promise.all(files.map(async (f) => {
1322
- const content = await readFile(f, "utf-8");
1323
- return parseReport(content, relative(cwd, f));
1324
- }));
1325
- // Ensure all reports have dimensions (backward compat)
1326
- await Promise.all(reports.map((r) => ensureDimensions(r)));
1327
1321
  // Group by agent name, sort each group oldest -> newest
1328
1322
  const groupMap = new Map();
1329
1323
  for (const r of reports) {
@@ -1,4 +1,15 @@
1
- import type { AgentReport } from "./types";
1
+ import type { AgentReport, CheckpointRecord } from "./types";
2
2
  export declare function formatReport(report: AgentReport<unknown>): string;
3
- export declare function writeReport(content: string, timestamp: string, name?: string, dimensions?: Record<string, string>): Promise<string>;
3
+ /**
4
+ * Write a full per-scene YAML report under `.reports/runs/<runId>.yaml`. The
5
+ * runId is unique per `agent()` execution, so snapshots never clobber and need
6
+ * no locking. Only written when a run opts in via `--record`.
7
+ */
8
+ export declare function writeSnapshot(content: string, runId: string): Promise<string>;
9
+ /**
10
+ * Append one record to the canonical append-only run log
11
+ * (`.reports/checkpoints.jsonl`). Used on the standalone path (a lone test-file
12
+ * process). When launched by `agest run`, the PARENT owns this write instead.
13
+ */
14
+ export declare function appendCheckpoint(record: CheckpointRecord): Promise<void>;
4
15
  export declare function writeDiffEntry(hash: string, systemPrompt: string, tools: string[], model?: string): Promise<void>;
package/dist/reporter.js CHANGED
@@ -1,5 +1,4 @@
1
- import { access, mkdir, writeFile } from "fs/promises";
2
- import { createHash } from "crypto";
1
+ import { access, appendFile, mkdir, writeFile } from "fs/promises";
3
2
  import { join } from "path";
4
3
  import { resolveText } from "./resolve";
5
4
  export function formatReport(report) {
@@ -153,29 +152,28 @@ function formatUsd(n) {
153
152
  function escapeYaml(s) {
154
153
  return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
155
154
  }
156
- export async function writeReport(content, timestamp, name, dimensions) {
157
- const reportsDir = join(process.cwd(), ".reports");
158
- await mkdir(reportsDir, { recursive: true });
159
- const safename = name ? `-${name.replace(/[^a-zA-Z0-9_-]/g, "_")}` : "";
160
- let filename;
161
- if (dimensions && Object.keys(dimensions).length > 0) {
162
- const sorted = Object.entries(dimensions).sort(([a], [b]) => a.localeCompare(b));
163
- const dimHash = createHash("sha256").update(JSON.stringify(sorted)).digest("hex").slice(0, 8);
164
- filename = `report${safename}-${dimHash}.yaml`;
165
- }
166
- else {
167
- const safestamp = timestamp.replace(/[:.]/g, "-");
168
- filename = `report${safename}-${safestamp}.yaml`;
169
- }
170
- const filepath = join(reportsDir, filename);
171
- try {
172
- await access(filepath);
173
- console.warn(`\x1b[33m⚠ Overwriting previous report for ${name ?? "unnamed"} (same config)\x1b[0m`);
174
- }
175
- catch { }
155
+ /**
156
+ * Write a full per-scene YAML report under `.reports/runs/<runId>.yaml`. The
157
+ * runId is unique per `agent()` execution, so snapshots never clobber and need
158
+ * no locking. Only written when a run opts in via `--record`.
159
+ */
160
+ export async function writeSnapshot(content, runId) {
161
+ const runsDir = join(process.cwd(), ".reports", "runs");
162
+ await mkdir(runsDir, { recursive: true });
163
+ const filepath = join(runsDir, `${runId}.yaml`);
176
164
  await writeFile(filepath, content, "utf-8");
177
165
  return filepath;
178
166
  }
167
+ /**
168
+ * Append one record to the canonical append-only run log
169
+ * (`.reports/checkpoints.jsonl`). Used on the standalone path (a lone test-file
170
+ * process). When launched by `agest run`, the PARENT owns this write instead.
171
+ */
172
+ export async function appendCheckpoint(record) {
173
+ const reportsDir = join(process.cwd(), ".reports");
174
+ await mkdir(reportsDir, { recursive: true });
175
+ await appendFile(join(reportsDir, "checkpoints.jsonl"), JSON.stringify(record) + "\n", "utf-8");
176
+ }
179
177
  export async function writeDiffEntry(hash, systemPrompt, tools, model) {
180
178
  const diffDir = join(process.cwd(), ".diff");
181
179
  await mkdir(diffDir, { recursive: true });
package/dist/reports.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CheckpointRecord } from "./types";
1
2
  export interface ParsedSuiteResult {
2
3
  name: string;
3
4
  successRate: number;
@@ -127,9 +128,36 @@ export declare function findVaryingDimensions(reports: ParsedReport[]): string[]
127
128
  * Group reports by the value of a specific dimension.
128
129
  */
129
130
  export declare function groupByDimension(reports: ParsedReport[], dimension: string): Map<string, ParsedReport[]>;
131
+ /** Wilson interval from a pass count over a trial count. */
132
+ export declare function wilsonInterval(passes: number, total: number): {
133
+ low: number;
134
+ high: number;
135
+ };
130
136
  /**
131
137
  * Wilson score interval lower bound at 95% confidence.
132
138
  * Gives a conservative success rate estimate that accounts for sample size.
133
139
  */
134
140
  export declare function wilsonLowerBound(successRate: number, totalCases: number): number;
141
+ /** Walk for `.reports/checkpoints.jsonl` files, depth-limited like findReports. */
142
+ export declare function findCheckpointFiles(dir: string, depth?: number): Promise<string[]>;
143
+ /**
144
+ * Read every checkpoint record from the log(s) under `cwd`. Malformed lines
145
+ * (e.g. a crash mid-append) are skipped defensively rather than failing the
146
+ * whole read.
147
+ */
148
+ export declare function readCheckpoints(cwd: string): Promise<CheckpointRecord[]>;
149
+ /**
150
+ * Adapt a checkpoint record to the ParsedReport shape the stats/preview
151
+ * renderers consume. When the record references a `--record` snapshot, the
152
+ * per-scene detail (scenes / suites / failed cases) is loaded lazily from it;
153
+ * otherwise the lightweight (record-only) view is returned.
154
+ */
155
+ export declare function checkpointToReport(rec: CheckpointRecord): Promise<ParsedReport>;
156
+ /**
157
+ * Load all runs as ParsedReports: the canonical checkpoint log (primary) plus
158
+ * any legacy `report-*.yaml` still on disk (backward compat). Snapshots under
159
+ * `.reports/runs/` are NOT scanned directly — they are reached via a record's
160
+ * recordPath, so they never double-count.
161
+ */
162
+ export declare function loadReports(cwd: string): Promise<ParsedReport[]>;
135
163
  export declare function formatDuration(ms: number): string;
package/dist/reports.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "crypto";
2
2
  import { readdir, readFile } from "fs/promises";
3
- import { join } from "path";
3
+ import { join, relative } from "path";
4
4
  export function extractField(content, key) {
5
5
  const regex = new RegExp(`^ ${key}:\\s*(.+)$`, "m");
6
6
  const match = content.match(regex);
@@ -474,20 +474,161 @@ export function groupByDimension(reports, dimension) {
474
474
  }
475
475
  return groups;
476
476
  }
477
+ /**
478
+ * Wilson score interval bounds at 95% confidence for an observed rate `p` over
479
+ * `total` trials. The single source of truth for Wilson math — both the
480
+ * lower-bound helper and the runner's per-scene significance delegate here.
481
+ */
482
+ function wilsonBounds(p, total) {
483
+ if (total === 0)
484
+ return { low: 0, high: 0 };
485
+ const z = 1.96;
486
+ const denominator = 1 + (z * z) / total;
487
+ const centre = p + (z * z) / (2 * total);
488
+ const spread = z * Math.sqrt((p * (1 - p) + (z * z) / (4 * total)) / total);
489
+ const clamp = (x) => Math.max(0, Math.min(1, x));
490
+ return {
491
+ low: clamp((centre - spread) / denominator),
492
+ high: clamp((centre + spread) / denominator),
493
+ };
494
+ }
495
+ /** Wilson interval from a pass count over a trial count. */
496
+ export function wilsonInterval(passes, total) {
497
+ return wilsonBounds(total === 0 ? 0 : passes / total, total);
498
+ }
477
499
  /**
478
500
  * Wilson score interval lower bound at 95% confidence.
479
501
  * Gives a conservative success rate estimate that accounts for sample size.
480
502
  */
481
503
  export function wilsonLowerBound(successRate, totalCases) {
482
- if (totalCases === 0)
483
- return 0;
484
- const z = 1.96;
485
- const p = successRate;
486
- const denominator = 1 + (z * z) / totalCases;
487
- const centre = p + (z * z) / (2 * totalCases);
488
- const spread = z * Math.sqrt((p * (1 - p) + (z * z) / (4 * totalCases)) / totalCases);
489
- const lower = (centre - spread) / denominator;
490
- return Math.max(0, Math.min(1, lower));
504
+ return wilsonBounds(successRate, totalCases).low;
505
+ }
506
+ // ---------------------------------------------------------------------------
507
+ // Checkpoint log (canonical run store: .reports/checkpoints.jsonl)
508
+ // ---------------------------------------------------------------------------
509
+ /** Walk for `.reports/checkpoints.jsonl` files, depth-limited like findReports. */
510
+ export async function findCheckpointFiles(dir, depth = 0) {
511
+ if (depth > 6)
512
+ return [];
513
+ const SKIP = new Set(["node_modules", "dist", ".git", ".pnpm"]);
514
+ const out = [];
515
+ let entries;
516
+ try {
517
+ entries = await readdir(dir, { withFileTypes: true });
518
+ }
519
+ catch {
520
+ return [];
521
+ }
522
+ for (const entry of entries) {
523
+ if (SKIP.has(entry.name))
524
+ continue;
525
+ const full = join(dir, entry.name);
526
+ if (entry.isDirectory()) {
527
+ if (entry.name === ".reports") {
528
+ const files = await readdir(full);
529
+ if (files.includes("checkpoints.jsonl")) {
530
+ out.push(join(full, "checkpoints.jsonl"));
531
+ }
532
+ }
533
+ else if (!entry.name.startsWith(".")) {
534
+ out.push(...(await findCheckpointFiles(full, depth + 1)));
535
+ }
536
+ }
537
+ }
538
+ return out;
539
+ }
540
+ /**
541
+ * Read every checkpoint record from the log(s) under `cwd`. Malformed lines
542
+ * (e.g. a crash mid-append) are skipped defensively rather than failing the
543
+ * whole read.
544
+ */
545
+ export async function readCheckpoints(cwd) {
546
+ const files = await findCheckpointFiles(cwd);
547
+ const all = [];
548
+ for (const f of files) {
549
+ let content;
550
+ try {
551
+ content = await readFile(f, "utf-8");
552
+ }
553
+ catch {
554
+ continue;
555
+ }
556
+ for (const line of content.split("\n")) {
557
+ const trimmed = line.trim();
558
+ if (!trimmed)
559
+ continue;
560
+ try {
561
+ all.push(JSON.parse(trimmed));
562
+ }
563
+ catch {
564
+ /* skip malformed line */
565
+ }
566
+ }
567
+ }
568
+ return all;
569
+ }
570
+ /**
571
+ * Adapt a checkpoint record to the ParsedReport shape the stats/preview
572
+ * renderers consume. When the record references a `--record` snapshot, the
573
+ * per-scene detail (scenes / suites / failed cases) is loaded lazily from it;
574
+ * otherwise the lightweight (record-only) view is returned.
575
+ */
576
+ export async function checkpointToReport(rec) {
577
+ let scenes;
578
+ let suites;
579
+ let failedCases = [];
580
+ if (rec.recordPath) {
581
+ try {
582
+ const content = await readFile(join(process.cwd(), rec.recordPath), "utf-8");
583
+ const snap = parseReport(content, rec.recordPath);
584
+ scenes = snap.scenes;
585
+ suites = snap.suites;
586
+ failedCases = snap.failedCases;
587
+ }
588
+ catch {
589
+ /* snapshot missing — fall back to the lightweight view */
590
+ }
591
+ }
592
+ return {
593
+ name: rec.agentName,
594
+ systemPromptHash: rec.systemPromptHash,
595
+ promptHash: rec.dimensions?.prompt,
596
+ dimensions: rec.dimensions,
597
+ tools: rec.tools,
598
+ model: rec.dimensions?.model ?? rec.model ?? "unknown",
599
+ successRate: rec.successRate,
600
+ totalCases: rec.totalCases,
601
+ failedCasesCount: Math.max(0, rec.totalCases - rec.casesPassed),
602
+ failedCases,
603
+ duration: rec.durationMs,
604
+ timestamp: rec.timestamp,
605
+ averageInputTokensPerCase: rec.avgInputTokensPerCase,
606
+ averageOutputTokensPerCase: rec.avgOutputTokensPerCase,
607
+ totalInputTokens: rec.totalInputTokens,
608
+ totalOutputTokens: rec.totalOutputTokens,
609
+ totalCostUsd: rec.costUsd ?? undefined,
610
+ scenes,
611
+ suites,
612
+ source: rec.recordPath ?? rec.runId,
613
+ };
614
+ }
615
+ /**
616
+ * Load all runs as ParsedReports: the canonical checkpoint log (primary) plus
617
+ * any legacy `report-*.yaml` still on disk (backward compat). Snapshots under
618
+ * `.reports/runs/` are NOT scanned directly — they are reached via a record's
619
+ * recordPath, so they never double-count.
620
+ */
621
+ export async function loadReports(cwd) {
622
+ const records = await readCheckpoints(cwd);
623
+ const fromCheckpoints = await Promise.all(records.map((r) => checkpointToReport(r)));
624
+ const legacyFiles = await findReports(cwd);
625
+ const legacy = await Promise.all(legacyFiles.map(async (f) => {
626
+ const content = await readFile(f, "utf-8");
627
+ const r = parseReport(content, relative(cwd, f));
628
+ await ensureDimensions(r);
629
+ return r;
630
+ }));
631
+ return [...fromCheckpoints, ...legacy];
491
632
  }
492
633
  export function formatDuration(ms) {
493
634
  if (ms < 1000)
package/dist/runner.js CHANGED
@@ -2,6 +2,7 @@ import { collectPendingJudgements } from "./assertions";
2
2
  import { callJudge, resolveJudgeExecutor } from "./judge";
3
3
  import { resolveValue, resolveText, serializeValue, navigatePath } from "./resolve";
4
4
  import { validateAgainstSchema } from "./schema";
5
+ import { wilsonInterval } from "./reports";
5
6
  const DEFAULT_SCENE_TIMEOUT = 10_000;
6
7
  /**
7
8
  * Extract a named field from an agent response for assertion.
@@ -31,23 +32,6 @@ export function extractField(response, field) {
31
32
  }
32
33
  }
33
34
  }
34
- /**
35
- * Compute Wilson score interval lower bound.
36
- * Measures confidence that the true pass rate is above 50% (random chance).
37
- * z = 1.96 for 95% confidence level.
38
- */
39
- function wilsonSignificance(passes, total) {
40
- if (total === 0)
41
- return 0;
42
- const z = 1.96;
43
- const p = passes / total;
44
- const denominator = 1 + (z * z) / total;
45
- const centre = p + (z * z) / (2 * total);
46
- const spread = z * Math.sqrt((p * (1 - p) + (z * z) / (4 * total)) / total);
47
- const lower = (centre - spread) / denominator;
48
- // Return the lower bound clamped to [0, 1]
49
- return Math.max(0, Math.min(1, lower));
50
- }
51
35
  async function executeSingleRun(executor, scene, timeoutMs, turns, judgeConfig) {
52
36
  // The empty sentinel uses the `text` branch of the union so it is a valid
53
37
  // AgentResponse<T> for ANY T (there is no native value yet — the executor
@@ -178,7 +162,7 @@ export async function executeScene(executor, scene, globalTimeout, judgeConfig,
178
162
  const passes = runs.filter((r) => r.passed).length;
179
163
  const passRate = passes / runs.length;
180
164
  const totalDuration = runs.reduce((sum, r) => sum + r.duration, 0);
181
- const statisticalSignificance = wilsonSignificance(passes, runs.length);
165
+ const statisticalSignificance = wilsonInterval(passes, runs.length).low;
182
166
  // Use the last run's response as representative
183
167
  const lastRun = runs[runs.length - 1];
184
168
  // Overall pass = majority passed (> 50%)
package/dist/stats.js CHANGED
@@ -1,6 +1,6 @@
1
- import { readdir, readFile, rm } from "fs/promises";
1
+ import { readdir, writeFile, rm } from "fs/promises";
2
2
  import { join, relative } from "path";
3
- import { parseReport, findReports, loadDiffEntry, computeDiff, formatDuration, ensureDimensions, findVaryingDimensions, groupByDimension, findControlledPairs, diffConfigs, } from "./reports.js";
3
+ import { loadReports, readCheckpoints, loadDiffEntry, computeDiff, formatDuration, findVaryingDimensions, groupByDimension, findControlledPairs, diffConfigs, } from "./reports.js";
4
4
  function avg(nums) {
5
5
  return nums.length === 0
6
6
  ? undefined
@@ -193,28 +193,71 @@ async function purge(cwd) {
193
193
  // ---------------------------------------------------------------------------
194
194
  // Main
195
195
  // ---------------------------------------------------------------------------
196
+ /** Quote a CSV cell per RFC-4180 when it contains a comma, quote, or newline. */
197
+ function csvCell(v) {
198
+ const s = v == null ? "" : String(v);
199
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
200
+ }
201
+ /**
202
+ * Flatten the JSONL run log to a CSV projection on demand — the only place CSV
203
+ * escaping lives. For spreadsheet/eyeball use; the JSONL log stays canonical.
204
+ */
205
+ async function exportCsv(cwd, outPath) {
206
+ const records = await readCheckpoints(cwd);
207
+ if (records.length === 0) {
208
+ console.log("\n No checkpoints to export. Run some agent tests first.\n");
209
+ return;
210
+ }
211
+ const header = [
212
+ "runId", "sweepId", "timestamp", "agentName", "suiteHash", "model", "promptHash",
213
+ "tools", "judge", "runs", "runsPerScene", "totalCases", "casesPassed", "successRate",
214
+ "wilsonLow", "wilsonHigh", "durationMs", "costUsd", "totalInputTokens",
215
+ "totalOutputTokens", "recordPath",
216
+ ];
217
+ const rows = records.map((r) => [
218
+ r.runId, r.sweepId, r.timestamp, r.agentName,
219
+ r.dimensions?.suiteHash, r.dimensions?.model, r.dimensions?.prompt,
220
+ Array.isArray(r.tools) ? r.tools.join("|") : r.dimensions?.tools,
221
+ r.dimensions?.judge, r.dimensions?.runs, r.runsPerScene, r.totalCases,
222
+ r.casesPassed, r.successRate, r.wilsonLow, r.wilsonHigh, r.durationMs,
223
+ r.costUsd, r.totalInputTokens, r.totalOutputTokens, r.recordPath,
224
+ ].map(csvCell).join(","));
225
+ const csv = [header.join(","), ...rows].join("\n") + "\n";
226
+ await writeFile(join(cwd, outPath), csv, "utf-8");
227
+ console.log(`\n Exported ${records.length} checkpoint${records.length !== 1 ? "s" : ""} to ${outPath}\n`);
228
+ }
196
229
  async function main() {
197
230
  const args = process.argv.slice(2);
198
231
  const agentFlagIdx = args.indexOf("--agent");
199
232
  const agentFilter = agentFlagIdx !== -1 ? args[agentFlagIdx + 1] : undefined;
200
233
  const modelFlagIdx = args.indexOf("--model");
201
234
  const modelFilter = modelFlagIdx !== -1 ? args[modelFlagIdx + 1] : undefined;
235
+ const suiteFlagIdx = args.indexOf("--suite");
236
+ const suiteFilter = suiteFlagIdx !== -1 ? args[suiteFlagIdx + 1] : undefined;
202
237
  if (args.includes("--purge")) {
203
238
  await purge(process.cwd());
204
239
  return;
205
240
  }
206
241
  const cwd = process.cwd();
207
- const files = await findReports(cwd);
208
- if (files.length === 0) {
242
+ const csvFlagIdx = args.indexOf("--export-csv");
243
+ if (csvFlagIdx !== -1) {
244
+ const next = args[csvFlagIdx + 1];
245
+ const outPath = next && !next.startsWith("--") ? next : "agest-checkpoints.csv";
246
+ await exportCsv(cwd, outPath);
247
+ return;
248
+ }
249
+ let reports = await loadReports(cwd);
250
+ if (reports.length === 0) {
209
251
  console.log("\n No reports found. Run some agent tests first.\n");
210
252
  return;
211
253
  }
212
- let reports = await Promise.all(files.map(async (f) => {
213
- const content = await readFile(f, "utf-8");
214
- return parseReport(content, relative(cwd, f));
215
- }));
216
- // Ensure all reports have dimensions (backward compat)
217
- await Promise.all(reports.map((r) => ensureDimensions(r)));
254
+ if (suiteFilter) {
255
+ reports = reports.filter((r) => r.dimensions?.suiteHash === suiteFilter);
256
+ if (reports.length === 0) {
257
+ console.log(`\n No reports found for suite "${suiteFilter}".\n`);
258
+ return;
259
+ }
260
+ }
218
261
  if (agentFilter) {
219
262
  reports = reports.filter((r) => r.name?.toLowerCase() === agentFilter.toLowerCase());
220
263
  if (reports.length === 0) {
package/dist/types.d.ts CHANGED
@@ -135,6 +135,13 @@ export interface AgentReport<T = string> {
135
135
  timestamp: string;
136
136
  duration: number;
137
137
  totalCases: number;
138
+ /** Cases that passed (totalCases - failures). Persisted for statistical honesty. */
139
+ casesPassed?: number;
140
+ /** Configured runs per scene (sampling count) — affects the trial basis below. */
141
+ runsPerScene?: number;
142
+ /** Wilson score interval (95%) across all trials = Σ scene runs. */
143
+ wilsonLow?: number;
144
+ wilsonHigh?: number;
138
145
  averageInputTokensPerCase?: number;
139
146
  averageOutputTokensPerCase?: number;
140
147
  totalInputTokens?: number;
@@ -142,4 +149,34 @@ export interface AgentReport<T = string> {
142
149
  totalCostUsd?: number;
143
150
  results: SceneResult<T>[];
144
151
  }
152
+ /**
153
+ * One append-only line in `.reports/checkpoints.jsonl` — the canonical run log.
154
+ * Lightweight (cost + identity + stats), written on every run. Structured fields
155
+ * (`dimensions`, `tools`) stay native; adding a field later needs no migration.
156
+ */
157
+ export interface CheckpointRecord {
158
+ runId: string;
159
+ sweepId?: string;
160
+ timestamp: string;
161
+ agentName?: string;
162
+ model?: string;
163
+ systemPromptHash?: string;
164
+ tools?: string[];
165
+ /** Config identity map: { suiteHash, model, prompt, tools, judge, runs }. */
166
+ dimensions: Record<string, string>;
167
+ runsPerScene?: number;
168
+ totalCases: number;
169
+ casesPassed: number;
170
+ successRate: number;
171
+ wilsonLow?: number;
172
+ wilsonHigh?: number;
173
+ durationMs: number;
174
+ costUsd?: number | null;
175
+ totalInputTokens?: number;
176
+ totalOutputTokens?: number;
177
+ avgInputTokensPerCase?: number;
178
+ avgOutputTokensPerCase?: number;
179
+ /** Relative path to the full YAML snapshot, set only when run with --record. */
180
+ recordPath?: string;
181
+ }
145
182
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sebastiantuyu/agest",
3
- "version": "0.3.3-next.11",
3
+ "version": "0.3.3-next.12",
4
4
  "description": "A testing library for agents",
5
5
  "repository": {
6
6
  "type": "git",