@axlsdk/eval 0.23.3 → 0.24.1

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.js CHANGED
@@ -1,12 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  aggregateRuns,
4
+ compareItemErrorRates,
5
+ describeItemErrorRate,
4
6
  evalCompare,
7
+ evaluateItemErrorRateGate,
5
8
  evaluateScorerErrorRateGate,
9
+ formatPercent,
10
+ isBudgetStopped,
11
+ isErrorRateLimit,
12
+ readAccounting,
6
13
  rescore,
7
14
  runEval,
8
- scorerCounts
9
- } from "./chunk-AFUNZE7W.js";
15
+ scorerCounts,
16
+ serializeRequestRecords
17
+ } from "./chunk-HKEAPM4B.js";
10
18
 
11
19
  // src/cli.ts
12
20
  import { readdirSync, statSync } from "fs";
@@ -71,6 +79,12 @@ function validateEvalConfig(cfg) {
71
79
  return `exports an invalid failOnScorerErrorRate (${typeof v === "number" ? v : typeof v}) \u2014 must be a number between 0 and 1.`;
72
80
  }
73
81
  }
82
+ {
83
+ const v = c.failOnItemErrorRate;
84
+ if (v != null && !isErrorRateLimit(v)) {
85
+ return `exports an invalid failOnItemErrorRate (${typeof v === "number" ? v : typeof v}) \u2014 must be a number between 0 and 1 (1 disables the gate).`;
86
+ }
87
+ }
74
88
  return void 0;
75
89
  }
76
90
 
@@ -83,8 +97,11 @@ var KNOWN_FLAGS = /* @__PURE__ */ new Set([
83
97
  "--threshold",
84
98
  "--runs",
85
99
  "--capture-traces",
100
+ "--capture-requests",
86
101
  "--concurrency",
87
- "--scorers"
102
+ "--scorers",
103
+ "--budget",
104
+ "--max-item-error-rate"
88
105
  ]);
89
106
  var VALUE_FLAGS = /* @__PURE__ */ new Set([
90
107
  "--output",
@@ -92,7 +109,9 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
92
109
  "--conditions",
93
110
  "--runs",
94
111
  "--concurrency",
95
- "--scorers"
112
+ "--scorers",
113
+ "--budget",
114
+ "--max-item-error-rate"
96
115
  ]);
97
116
  function envInt(name) {
98
117
  const raw = process.env[name];
@@ -101,14 +120,25 @@ function envInt(name) {
101
120
  if (!Number.isFinite(n) || n <= 0) return void 0;
102
121
  return n;
103
122
  }
123
+ function parseErrorRateFlag(flag, raw) {
124
+ const n = /^\d*\.?\d+$/.test(raw.trim()) ? Number(raw) : NaN;
125
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
126
+ console.error(`Error: ${flag} must be a number in [0, 1], got "${raw}"`);
127
+ process.exit(1);
128
+ }
129
+ return n;
130
+ }
104
131
  function parseEvalArgs(args) {
105
132
  let outputPath;
106
133
  let configArg;
107
134
  let conditions = [];
108
135
  let runs = 1;
109
136
  let captureTraces = false;
137
+ let captureRequests = false;
110
138
  let concurrency;
111
139
  let scorerNames;
140
+ let budget;
141
+ let maxItemErrorRate;
112
142
  const paths = [];
113
143
  for (let i = 0; i < args.length; i++) {
114
144
  const arg = args[i];
@@ -129,6 +159,10 @@ function parseEvalArgs(args) {
129
159
  } else {
130
160
  concurrency = n;
131
161
  }
162
+ } else if (arg === "--budget") {
163
+ budget = value;
164
+ } else if (arg === "--max-item-error-rate") {
165
+ maxItemErrorRate = parseErrorRateFlag(arg, value);
132
166
  } else if (arg === "--scorers") {
133
167
  scorerNames = [
134
168
  ...new Set(
@@ -140,6 +174,8 @@ function parseEvalArgs(args) {
140
174
  }
141
175
  } else if (arg === "--capture-traces") {
142
176
  captureTraces = true;
177
+ } else if (arg === "--capture-requests") {
178
+ captureRequests = true;
143
179
  } else if (arg.startsWith("--")) {
144
180
  if (!KNOWN_FLAGS.has(arg)) {
145
181
  console.error(`Unknown flag: ${arg}`);
@@ -155,13 +191,90 @@ function parseEvalArgs(args) {
155
191
  conditions,
156
192
  runs,
157
193
  captureTraces,
194
+ captureRequests,
158
195
  concurrency,
159
196
  scorerNames,
197
+ budget,
198
+ maxItemErrorRate,
160
199
  paths
161
200
  };
162
201
  }
163
202
 
164
203
  // src/cli-format.ts
204
+ function formatKnownSpend(accounting) {
205
+ const cost = `$${accounting.knownCost.toFixed(2)}`;
206
+ if (accounting.completeness === "complete") return cost;
207
+ if (accounting.completeness === "unverified") return `${cost} (unverified)`;
208
+ const reasons = Object.entries(accounting.reasons).map(([reason, count]) => `${count} ${reason}`).join(", ");
209
+ return `${cost} (incomplete: ${reasons || "unknown spend"})`;
210
+ }
211
+ function formatBudgetLine(accounting) {
212
+ const budget = accounting.budget;
213
+ if (!budget) return void 0;
214
+ const limit = `$${budget.limit.toFixed(2)}`;
215
+ const spent = `$${budget.knownSpend.toFixed(2)}`;
216
+ if (budget.status === "open") {
217
+ return ` Budget: ${spent} of ${limit} (open)`;
218
+ }
219
+ const by = budget.closedBy ? `, first observed by ${budget.closedBy}` : "";
220
+ return ` Budget: STOPPED \u2014 ${spent} known spend against a ${limit} limit, $${budget.knownOvershoot.toFixed(2)} over${by}`;
221
+ }
222
+ function formatCoverageLine(result) {
223
+ const items = result.summary.coverage?.items;
224
+ if (!items) return void 0;
225
+ const parts = [
226
+ ["failed", items.failed],
227
+ ["cancelled", items.cancelled],
228
+ ["budget-skipped", items.budget_skipped],
229
+ ["budget-interrupted", items.budget_interrupted]
230
+ ].filter(([, n]) => n > 0).map(([label, n]) => `${n} ${label}`);
231
+ if (parts.length === 0) return void 0;
232
+ const rate = result.summary.itemErrorRate;
233
+ const rateSuffix = rate ? ` \u2014 item error rate ${formatPercent(rate.rate)} (limit ${formatPercent(rate.limit)})` : "";
234
+ const line = ` Items: ${items.completed} completed, ${parts.join(", ")}${rateSuffix}`;
235
+ const causes = formatFailureCauses(result);
236
+ return causes ? `${line}
237
+ ${causes}` : line;
238
+ }
239
+ function formatFailureCauses(result) {
240
+ const counts = /* @__PURE__ */ new Map();
241
+ for (const item of result.items) {
242
+ if (item.outcome !== "failed") continue;
243
+ const f = item.failure;
244
+ let label = "other";
245
+ if (f?.status !== void 0) {
246
+ const status = f.status === 0 ? "network" : String(f.status);
247
+ label = f.provider ? `${status} (${f.provider})` : status;
248
+ }
249
+ counts.set(label, (counts.get(label) ?? 0) + 1);
250
+ }
251
+ if (counts.size === 0) return void 0;
252
+ const groups = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([label, n]) => `${n} \xD7 ${label}`);
253
+ return ` Failure causes: ${groups.join(", ")}`;
254
+ }
255
+ function itemErrorRateMessage(result, label) {
256
+ const rate = result.summary.itemErrorRate;
257
+ if (!rate?.exceeded) return null;
258
+ return `[axl-eval] ITEM ERROR RATE EXCEEDED: ${label} \u2014 ${rate.failed} of ${rate.attempted} attempted item(s) failed in the workflow (item error rate ${formatPercent(rate.rate)}), over the ${formatPercent(rate.limit)} limit; the scores cover only the surviving items. Raise failOnItemErrorRate or pass --max-item-error-rate <0..1> to accept it (1 disables the gate).`;
259
+ }
260
+ function budgetStopMessage(result, label) {
261
+ const budget = readAccounting(result).budget;
262
+ if (!budget || !isBudgetStopped({ budget, coverage: result.summary.coverage })) return null;
263
+ const items = result.summary.coverage?.items;
264
+ const stopped = items ? ` ${items.budget_skipped} case(s) never started, ${items.budget_interrupted} stopped mid-flight, ${items.completed} completed.` : "";
265
+ return `[axl-eval] BUDGET STOPPED: ${label} \u2014 known spend $${budget.knownSpend.toFixed(2)} reached the $${budget.limit.toFixed(2)} limit (over by $${budget.knownOvershoot.toFixed(2)}).${stopped} The run is incomplete by design; this is NOT a model or scorer failure.`;
266
+ }
267
+ function isTotalWipeout(result) {
268
+ const { count, failures } = result.summary;
269
+ if (count === 0) return false;
270
+ const coverage = result.summary.coverage?.items;
271
+ if (coverage) {
272
+ const budgetStopped = coverage.budget_skipped + coverage.budget_interrupted;
273
+ if (coverage.completed > 0 || budgetStopped > 0) return false;
274
+ return coverage.failed === count;
275
+ }
276
+ return failures >= count;
277
+ }
165
278
  function formatModelTimingLines(modelTiming, nameWidth) {
166
279
  if (!modelTiming) return [];
167
280
  return Object.entries(modelTiming).map(([model, t]) => {
@@ -171,6 +284,7 @@ function formatModelTimingLines(modelTiming, nameWidth) {
171
284
  }
172
285
  parts.push(`queued ${formatMs(t.queuedMs.mean)}`);
173
286
  parts.push(`retries ${formatMs(t.retryMs.mean)}`);
287
+ if (t.rateLimitRetries) parts.push(`rate-limited ${t.rateLimitRetries}\xD7`);
174
288
  const calls = `${t.calls} call${t.calls === 1 ? "" : "s"}`;
175
289
  return ` ${model.padEnd(Math.max(nameWidth - 2, 0))} ${parts.join(" \xB7 ")} (${calls}, mean/p95 per call)`;
176
290
  });
@@ -231,7 +345,16 @@ Usage:
231
345
  (success + failure). Adds memory
232
346
  overhead proportional to dataset size
233
347
  x turns x agents; off by default.
348
+ axl-eval <path> --budget <amount> Stop admitting spend once known cost
349
+ reaches the limit (e.g. "$1", "0.50").
350
+ Overrides the eval file's budget and
351
+ applies PER RUN under --runs. A
352
+ budget-stopped run exits 1 with a
353
+ distinct BUDGET STOPPED line.
234
354
  axl-eval rescore <results> <eval-file> Re-run scorers on saved outputs
355
+ axl-eval rescore <results> <eval-file> --budget <amount> Limit the NEW judging
356
+ spend only; the source run's cost is
357
+ not counted against it.
235
358
  axl-eval compare <a> <b> Compare two eval result files
236
359
  axl-eval compare <a> <b> --threshold <v> Set regression threshold (global or per-scorer)
237
360
  axl-eval compare <a> <b> --fail-on-regression Exit 1 if regressions
@@ -246,7 +369,8 @@ Config auto-detection (when --config is not specified):
246
369
  ${CONFIG_CANDIDATES.join(" -> ")}
247
370
 
248
371
  When a config is found, the exported AxlRuntime is passed to executeWorkflow
249
- and cost is tracked automatically via runtime.trackCost().
372
+ and spend is measured by the runner's accounting scope (a "cost" your callback
373
+ returns is recorded under item.callerReport, never used as the measurement).
250
374
  When no config is found, a bare AxlRuntime is created (providers from env vars).
251
375
  `);
252
376
  process.exit(0);
@@ -296,17 +420,27 @@ function scorerFilteredScorers(r) {
296
420
  if (!meta || meta.scorerFiltered !== true) return void 0;
297
421
  return Array.isArray(meta.scorersRun) ? meta.scorersRun : [];
298
422
  }
423
+ function parseMaxItemErrorRate(args) {
424
+ const idx = args.indexOf("--max-item-error-rate");
425
+ if (idx === -1) return void 0;
426
+ if (idx + 1 >= args.length) {
427
+ console.error("Error: --max-item-error-rate requires a value in [0, 1]");
428
+ process.exit(1);
429
+ }
430
+ return parseErrorRateFlag("--max-item-error-rate", args[idx + 1]);
431
+ }
299
432
  async function runCompare(args) {
300
433
  const failOnRegression = args.includes("--fail-on-regression");
301
434
  const thresholds = parseThresholdArg(args);
302
435
  const maxScorerErrorRate = parseMaxScorerErrorRate(args);
303
- const valueFlags = /* @__PURE__ */ new Set(["--threshold", "--max-scorer-error-rate"]);
436
+ const maxItemErrorRate = parseMaxItemErrorRate(args);
437
+ const valueFlags = /* @__PURE__ */ new Set(["--threshold", "--max-scorer-error-rate", "--max-item-error-rate"]);
304
438
  const files = args.filter(
305
439
  (a, i) => !a.startsWith("--") && !(i > 0 && valueFlags.has(args[i - 1]))
306
440
  );
307
441
  if (files.length !== 2) {
308
442
  console.error(
309
- "Usage: axl-eval compare <baseline.json> <candidate.json> [--threshold <value>] [--fail-on-regression] [--max-scorer-error-rate <0..1>]"
443
+ "Usage: axl-eval compare <baseline.json> <candidate.json> [--threshold <value>] [--fail-on-regression] [--max-scorer-error-rate <0..1>] [--max-item-error-rate <0..1>]"
310
444
  );
311
445
  process.exit(1);
312
446
  }
@@ -371,9 +505,13 @@ Compare: baseline (${comparison.baseline.id.slice(0, 8)}) -> candidate (${compar
371
505
  if (comparison.cost) {
372
506
  const c = comparison.cost;
373
507
  const sign = c.delta > 0 ? "+" : "";
508
+ const change = c.deltaPercent === null ? `${sign}$${Math.abs(c.delta).toFixed(2)}, no % from a $0 baseline` : `${sign}${c.deltaPercent.toFixed(1)}%`;
374
509
  console.log(
375
- ` Cost: baseline $${c.baselineTotal.toFixed(2)} -> candidate $${c.candidateTotal.toFixed(2)} (${sign}${c.deltaPercent.toFixed(1)}%)`
510
+ ` Cost: baseline $${c.baselineTotal.toFixed(2)} -> candidate $${c.candidateTotal.toFixed(2)} (${change})`
376
511
  );
512
+ if (!c.certified) {
513
+ console.log(` not certified: ${c.reason ?? "costs are not comparable"}`);
514
+ }
377
515
  }
378
516
  const baselineRef = Array.isArray(baseline) ? baseline[0] : baseline;
379
517
  const stable = Math.max(
@@ -418,6 +556,17 @@ Compare: baseline (${comparison.baseline.id.slice(0, 8)}) -> candidate (${compar
418
556
  const reason = evaluateScorerErrorRateGate(comparison, maxScorerErrorRate);
419
557
  if (reason) refuseToGate(reason);
420
558
  }
559
+ const itemRates = compareItemErrorRates(baseline, candidate, maxItemErrorRate);
560
+ const multiRunCompare = itemRates.some((r) => r.runIndex > 0);
561
+ for (const rate of itemRates) {
562
+ if (rate.failed > 0 && !rate.exceeded) {
563
+ console.error(
564
+ `[axl-eval] WARNING: ${describeItemErrorRate(rate, multiRunCompare)}, within the ${formatPercent(rate.limit)} limit; its scores cover only the surviving items.`
565
+ );
566
+ }
567
+ }
568
+ const coverageReason = evaluateItemErrorRateGate(baseline, candidate, maxItemErrorRate);
569
+ if (coverageReason) refuseToGate(coverageReason);
421
570
  if (failOnRegression && comparison.regressions.length > 0) {
422
571
  const hasSignificance = scorerNames.some((n) => comparison.scorers[n].significant != null);
423
572
  if (hasSignificance) {
@@ -431,7 +580,16 @@ Compare: baseline (${comparison.baseline.id.slice(0, 8)}) -> candidate (${compar
431
580
  }
432
581
  }
433
582
  async function runRescore(args, signal) {
434
- const { outputPath, configArg, conditions, concurrency, scorerNames, paths } = parseEvalArgs(args);
583
+ const {
584
+ outputPath,
585
+ configArg,
586
+ conditions,
587
+ concurrency,
588
+ scorerNames,
589
+ budget,
590
+ maxItemErrorRate,
591
+ paths
592
+ } = parseEvalArgs(args);
435
593
  if (paths.length < 2) {
436
594
  console.error("Usage: axl-eval rescore <results.json> <eval-file> [--output <file>]");
437
595
  process.exit(1);
@@ -442,11 +600,18 @@ async function runRescore(args, signal) {
442
600
  );
443
601
  process.exit(1);
444
602
  }
603
+ if (maxItemErrorRate != null) {
604
+ console.error(
605
+ "Error: --max-item-error-rate is not supported with rescore; item failures belong to the source run \u2014 gate them with `axl-eval compare`."
606
+ );
607
+ process.exit(1);
608
+ }
445
609
  const itemConcurrency = concurrency ?? envInt("AXL_EVAL_CONCURRENCY");
446
610
  const [resultsPath, evalFilePath] = paths;
447
611
  const raw = JSON.parse(await readFileAsync(resultsPath, "utf-8"));
448
612
  const results = Array.isArray(raw) ? raw : [raw];
449
613
  const runtime = await getRuntime(configArg, conditions);
614
+ let exitCode = 0;
450
615
  try {
451
616
  const mod = await importModule(path.resolve(evalFilePath), import.meta.url);
452
617
  const evalConfig = pickDefault(mod);
@@ -461,29 +626,49 @@ async function runRescore(args, signal) {
461
626
  process.exit(1);
462
627
  }
463
628
  const rescored = [];
464
- for (const resultData of results) {
465
- if (signal.aborted) break;
466
- rescored.push(
467
- await rescore(resultData, evalConfig.scorers, runtime, {
468
- signal,
469
- ...itemConcurrency != null ? { concurrency: itemConcurrency } : {}
470
- })
471
- );
472
- }
473
- for (const r of rescored) {
474
- console.log("\n" + formatTable(r) + "\n");
629
+ let budgetUsable = true;
630
+ try {
631
+ for (const resultData of results) {
632
+ if (signal.aborted) break;
633
+ rescored.push(
634
+ await rescore(resultData, evalConfig.scorers, runtime, {
635
+ signal,
636
+ ...itemConcurrency != null ? { concurrency: itemConcurrency } : {},
637
+ // Gates the NEW judging spend only — the source run's cost is history
638
+ // and is not counted against this limit.
639
+ ...budget != null ? { budget } : {}
640
+ })
641
+ );
642
+ }
643
+ } catch (err) {
644
+ if (err?.code === "INVALID_BUDGET") {
645
+ console.error(`[axl-eval] ${err instanceof Error ? err.message : String(err)}`);
646
+ exitCode = 1;
647
+ budgetUsable = false;
648
+ } else {
649
+ throw err;
650
+ }
475
651
  }
476
- if (outputPath) {
477
- const output = Array.isArray(raw) ? rescored : rescored[0];
478
- const outputDir = path.dirname(path.resolve(outputPath));
479
- await mkdir(outputDir, { recursive: true });
480
- await writeFileAsync(outputPath, JSON.stringify(output, null, 2), "utf-8");
481
- console.log(`Rescored results saved to ${outputPath}`);
652
+ if (budgetUsable) {
653
+ for (const r of rescored) {
654
+ console.log("\n" + formatTable(r) + "\n");
655
+ }
656
+ if (outputPath) {
657
+ const output = Array.isArray(raw) ? rescored : rescored[0];
658
+ const outputDir = path.dirname(path.resolve(outputPath));
659
+ await mkdir(outputDir, { recursive: true });
660
+ await writeFileAsync(outputPath, JSON.stringify(output, null, 2), "utf-8");
661
+ console.log(`Rescored results saved to ${outputPath}`);
662
+ }
663
+ for (const r of rescored) {
664
+ if (reportBudgetStop(r, `rescore ${resultsPath}`)) exitCode = 1;
665
+ }
482
666
  }
483
667
  } finally {
484
668
  await runtime.shutdown().catch(() => {
485
669
  });
486
670
  }
671
+ if (exitCode !== 0) process.exit(exitCode);
487
672
  }
488
673
  function collectEvalFiles(p) {
489
674
  if (p.includes("*")) return expandGlob(p, process.cwd());
@@ -541,11 +726,15 @@ function formatTable(result) {
541
726
  }
542
727
  lines.push(...formatModelTimingLines(result.summary.modelTiming, maxNameLen));
543
728
  const durationSec = (result.duration / 1e3).toFixed(1);
544
- const costStr = result.totalCost > 0 ? `$${result.totalCost.toFixed(2)}` : "$0.00";
729
+ const accounting = readAccounting(result);
545
730
  lines.push("");
546
731
  lines.push(
547
- ` Failures: ${result.summary.failures}/${result.summary.count} | Cost: ${costStr} | Duration: ${durationSec}s`
732
+ ` Failures: ${result.summary.failures}/${result.summary.count} | Cost: ${formatKnownSpend(accounting)} | Duration: ${durationSec}s`
548
733
  );
734
+ const budgetLine = formatBudgetLine(accounting);
735
+ if (budgetLine) lines.push(budgetLine);
736
+ const coverageLine = formatCoverageLine(result);
737
+ if (coverageLine) lines.push(coverageLine);
549
738
  const itemsWithErrors = result.items.filter((i) => i.scorerErrors?.length);
550
739
  if (itemsWithErrors.length > 0) {
551
740
  const uniqueErrors = [...new Set(itemsWithErrors.flatMap((i) => i.scorerErrors))];
@@ -589,10 +778,11 @@ function formatMultiRunTable(summary) {
589
778
  ` ${"Timing".padEnd(maxNameLen)} ${((summary.timing.mean / 1e3).toFixed(2) + " \xB1 " + (summary.timing.std / 1e3).toFixed(2) + "s").padStart(colWidth)}`
590
779
  );
591
780
  }
592
- const costStr = summary.totalCost > 0 ? `$${summary.totalCost.toFixed(2)}` : "$0.00";
593
781
  const durationStr = (summary.totalDuration / 1e3).toFixed(1);
594
782
  lines.push("");
595
- lines.push(` Total Cost: ${costStr} | Total Duration: ${durationStr}s`);
783
+ lines.push(
784
+ ` Total Cost: ${formatKnownSpend(summary.accounting)} | Total Duration: ${durationStr}s`
785
+ );
596
786
  return lines.join("\n");
597
787
  }
598
788
  function reportDegraded(result, label) {
@@ -617,11 +807,22 @@ function reportFullySkippedScorers(result, label) {
617
807
  }
618
808
  }
619
809
  }
810
+ function reportBudgetStop(result, label) {
811
+ const message = budgetStopMessage(result, label);
812
+ if (!message) return false;
813
+ console.error(message);
814
+ return true;
815
+ }
816
+ function reportItemErrorRate(result, label, wipeout) {
817
+ const message = itemErrorRateMessage(result, label);
818
+ if (!message) return false;
819
+ if (!wipeout) console.error(message);
820
+ return true;
821
+ }
620
822
  function reportTotalWipeout(result, label) {
621
- const { count, failures } = result.summary;
622
- if (count === 0 || failures < count) return false;
823
+ if (!isTotalWipeout(result)) return false;
623
824
  console.error(
624
- `[axl-eval] FAILED: ${label} \u2014 all ${count} item(s) errored in the workflow (0 succeeded); the eval produced no scorable output.`
825
+ `[axl-eval] FAILED: ${label} \u2014 all ${result.summary.count} item(s) errored in the workflow (0 succeeded); the eval produced no scorable output.`
625
826
  );
626
827
  return true;
627
828
  }
@@ -685,6 +886,27 @@ async function getRuntime(configArg, conditions) {
685
886
  const { AxlRuntime } = await import("@axlsdk/axl");
686
887
  return new AxlRuntime();
687
888
  }
889
+ async function writeRequestSidecar(runtime, outputPath, results) {
890
+ const artifactIds = results.map((result) => result.diagnostics?.artifactId).filter((id) => typeof id === "string" && id !== "");
891
+ if (artifactIds.length === 0) return;
892
+ const store = runtime.getDiagnosticArtifactStore();
893
+ if (!store) return;
894
+ const records = [];
895
+ for (const artifactId of artifactIds) {
896
+ const opened = await store.open(artifactId).catch(() => void 0);
897
+ if (!opened) continue;
898
+ for await (const line of opened.lines) {
899
+ try {
900
+ records.push(JSON.parse(line));
901
+ } catch {
902
+ }
903
+ }
904
+ }
905
+ if (records.length === 0) return;
906
+ const sidecarPath = outputPath.replace(/\.json$/i, "") + ".requests.jsonl";
907
+ await writeFileAsync(sidecarPath, serializeRequestRecords(records), "utf-8");
908
+ console.log(`Captured requests saved to ${sidecarPath}`);
909
+ }
688
910
  async function runEvalCommand(args, signal) {
689
911
  const {
690
912
  outputPath,
@@ -692,8 +914,11 @@ async function runEvalCommand(args, signal) {
692
914
  conditions,
693
915
  runs,
694
916
  captureTraces,
917
+ captureRequests,
695
918
  concurrency,
696
919
  scorerNames,
920
+ budget,
921
+ maxItemErrorRate,
697
922
  paths
698
923
  } = parseEvalArgs(args);
699
924
  if (paths.length === 0) {
@@ -728,6 +953,8 @@ async function runEvalCommand(args, signal) {
728
953
  continue;
729
954
  }
730
955
  evalConfig.concurrency = concurrency ?? envInt("AXL_EVAL_CONCURRENCY") ?? evalConfig.concurrency ?? 5;
956
+ if (budget != null) evalConfig.budget = budget;
957
+ if (maxItemErrorRate != null) evalConfig.failOnItemErrorRate = maxItemErrorRate;
731
958
  if (scorerNames?.length) {
732
959
  const available = evalConfig.scorers.map((s) => s.name);
733
960
  const unknown = scorerNames.filter((n) => !available.includes(n));
@@ -756,27 +983,11 @@ async function runEvalCommand(args, signal) {
756
983
  }
757
984
  let executeWorkflow;
758
985
  if (customExecute) {
759
- executeWorkflow = async (input, rt) => {
760
- const {
761
- result,
762
- cost: trackedCost,
763
- metadata
764
- } = await runtime.trackExecution(async () => {
765
- return customExecute(input, rt);
766
- });
767
- return {
768
- output: result.output,
769
- cost: result.cost ?? trackedCost,
770
- metadata: result.metadata ?? metadata
771
- };
772
- };
986
+ executeWorkflow = async (input, rt) => customExecute(input, rt);
773
987
  } else if (runtime.getWorkflow(evalConfig.workflow)) {
774
- executeWorkflow = async (input) => {
775
- const { result, cost, metadata } = await runtime.trackExecution(async () => {
776
- return runtime.execute(evalConfig.workflow, input);
777
- });
778
- return { output: result, cost, metadata };
779
- };
988
+ executeWorkflow = async (input) => ({
989
+ output: await runtime.execute(evalConfig.workflow, input)
990
+ });
780
991
  } else {
781
992
  const exportShape = (() => {
782
993
  const top = Object.keys(mod).filter((k) => k !== "default");
@@ -798,7 +1009,11 @@ async function runEvalCommand(args, signal) {
798
1009
  failedFiles++;
799
1010
  continue;
800
1011
  }
801
- const runOptions = { signal, ...captureTraces ? { captureTraces: true } : {} };
1012
+ const runOptions = {
1013
+ signal,
1014
+ ...captureTraces ? { captureTraces: true } : {},
1015
+ ...captureRequests ? { captureRequests: true } : {}
1016
+ };
802
1017
  const scorersRun = scorerNames?.length ? evalConfig.scorers.map((s) => s.name) : void 0;
803
1018
  const stampFiltered = (result) => {
804
1019
  if (scorersRun) {
@@ -851,11 +1066,13 @@ async function runEvalCommand(args, signal) {
851
1066
  console.log("\n" + formatMultiRunTable(summary) + "\n");
852
1067
  for (const r of runResults) results.push(r);
853
1068
  let anyFailing = false;
854
- for (const r of runResults) {
1069
+ for (const [i, r] of runResults.entries()) {
1070
+ const budgetStopped = reportBudgetStop(r, filePath);
855
1071
  const wipeout = reportTotalWipeout(r, filePath);
1072
+ const thinned = reportItemErrorRate(r, `${filePath} run ${i + 1}/${runs}`, wipeout);
856
1073
  const degraded = reportDegraded(r, filePath);
857
1074
  reportFullySkippedScorers(r, filePath);
858
- if (wipeout || degraded) anyFailing = true;
1075
+ if (budgetStopped || wipeout || thinned || degraded) anyFailing = true;
859
1076
  }
860
1077
  if (anyFailing && !partial) failedFiles++;
861
1078
  } else {
@@ -863,10 +1080,12 @@ async function runEvalCommand(args, signal) {
863
1080
  stampFiltered(result);
864
1081
  results.push(result);
865
1082
  console.log("\n" + formatTable(result) + "\n");
1083
+ const budgetStopped = reportBudgetStop(result, filePath);
866
1084
  const wipeout = reportTotalWipeout(result, filePath);
1085
+ const thinned = reportItemErrorRate(result, filePath, wipeout);
867
1086
  const degraded = reportDegraded(result, filePath);
868
1087
  reportFullySkippedScorers(result, filePath);
869
- if (wipeout || degraded) failedFiles++;
1088
+ if (budgetStopped || wipeout || thinned || degraded) failedFiles++;
870
1089
  }
871
1090
  } catch (err) {
872
1091
  console.error(
@@ -881,6 +1100,7 @@ async function runEvalCommand(args, signal) {
881
1100
  await mkdir(outputDir, { recursive: true });
882
1101
  await writeFileAsync(outputPath, JSON.stringify(output, null, 2), "utf-8");
883
1102
  console.log(`Results saved to ${outputPath}`);
1103
+ await writeRequestSidecar(runtime, outputPath, results);
884
1104
  }
885
1105
  } finally {
886
1106
  await runtime.shutdown().catch(() => {