@foldrun/core 2.10.1 → 2.11.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.
Files changed (63) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.js +3 -0
  3. package/dist/src/catalog.d.ts +3 -0
  4. package/dist/src/catalog.js +4 -1
  5. package/dist/src/deploy.js +16 -0
  6. package/dist/src/eval-compare.d.ts +74 -0
  7. package/dist/src/eval-compare.js +187 -0
  8. package/dist/src/evals.d.ts +62 -3
  9. package/dist/src/evals.js +161 -36
  10. package/dist/src/flow-lint.js +4 -0
  11. package/dist/src/frontmatter-fields.js +4 -1
  12. package/dist/src/git-hook.d.ts +1 -0
  13. package/dist/src/git-hook.js +70 -0
  14. package/dist/src/git.d.ts +37 -0
  15. package/dist/src/git.js +101 -0
  16. package/dist/src/gitrepo.d.ts +113 -0
  17. package/dist/src/gitrepo.js +404 -0
  18. package/dist/src/grades.d.ts +118 -0
  19. package/dist/src/grades.js +481 -0
  20. package/dist/src/judge-verdict.d.ts +1 -0
  21. package/dist/src/judge-verdict.js +3 -1
  22. package/dist/src/online-checks.d.ts +4 -0
  23. package/dist/src/online-checks.js +5 -1
  24. package/dist/src/openapi.d.ts +4 -0
  25. package/dist/src/openapi.js +5 -1
  26. package/dist/src/platform.d.ts +12 -3
  27. package/dist/src/platform.js +9 -1
  28. package/dist/src/starter.js +5 -0
  29. package/dist/src/store.d.ts +8 -0
  30. package/dist/src/store.js +5 -1
  31. package/dist/src/tar.d.ts +30 -0
  32. package/dist/src/tar.js +112 -0
  33. package/dist/src/tool-test.js +19 -7
  34. package/dist/src/translator.d.ts +4 -0
  35. package/dist/src/translator.js +22 -3
  36. package/dist/src/trigger-filter.d.ts +28 -0
  37. package/dist/src/trigger-filter.js +272 -0
  38. package/dist/src/trigger-log.d.ts +1 -1
  39. package/dist/src/triggers.d.ts +2 -0
  40. package/dist/src/triggers.js +13 -2
  41. package/dist/src/untrusted-fetch.d.ts +16 -0
  42. package/dist/src/untrusted-fetch.js +60 -2
  43. package/index.ts +3 -0
  44. package/package.json +2 -2
  45. package/src/catalog.ts +5 -1
  46. package/src/deploy.ts +10 -0
  47. package/src/eval-compare.ts +243 -0
  48. package/src/evals.ts +195 -38
  49. package/src/flow-lint.ts +4 -0
  50. package/src/frontmatter-fields.ts +4 -1
  51. package/src/grades.ts +532 -0
  52. package/src/judge-verdict.ts +4 -2
  53. package/src/online-checks.ts +5 -1
  54. package/src/openapi.ts +6 -1
  55. package/src/platform.ts +16 -4
  56. package/src/starter.ts +5 -0
  57. package/src/store.ts +11 -1
  58. package/src/tool-test.ts +21 -7
  59. package/src/translator.ts +24 -3
  60. package/src/trigger-filter.ts +264 -0
  61. package/src/trigger-log.ts +1 -0
  62. package/src/triggers.ts +14 -2
  63. package/src/untrusted-fetch.ts +65 -2
@@ -54,6 +54,17 @@ export function chainedFlows(tenant, workspace, finished) {
54
54
  f.name !== finished.flow &&
55
55
  (f.on === "any" || f.on === endingOf(finished)));
56
56
  }
57
+ /** What a chained flow's `filter:` reads: the run that just settled. */
58
+ export function chainFields(finished) {
59
+ return {
60
+ run: finished.id,
61
+ flow: finished.flow,
62
+ status: finished.status,
63
+ ending: endingOf(finished),
64
+ verdict: runVerdict(finished) ?? null,
65
+ summary: finished.summary ?? null,
66
+ };
67
+ }
57
68
  /**
58
69
  * Start every flow chained on a run that just settled. The finished run's
59
70
  * one-line summary and its final result are the task; the run id rides
@@ -73,7 +84,7 @@ export async function fireChainedFlows(tenant, workspace, finished) {
73
84
  (result ? `\n${result}` : "");
74
85
  for (const flow of flows) {
75
86
  try {
76
- const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "previous_run" });
87
+ const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "previous_run", fields: chainFields(finished) });
77
88
  if (!gate.admit) {
78
89
  console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
79
90
  continue;
@@ -138,7 +149,7 @@ export async function fireStorageTriggers(tenant, workspace, paths, by) {
138
149
  const body = `by: ${by}\n${hits.map((h) => `- storage/${h}`).join("\n")}`;
139
150
  // A deploy that writes forty files under one prefix is one event, not
140
151
  // forty runs — which is exactly what `debounce:` is for.
141
- const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "storage_event" });
152
+ const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "storage_event", fields: { by, paths: hits } });
142
153
  if (!gate.admit) {
143
154
  console.log(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
144
155
  continue;
@@ -1,6 +1,20 @@
1
1
  /** How many redirects one request may follow. fetch's own limit is 20;
2
2
  * nothing a tenant points the platform at needs more than a couple. */
3
3
  export declare const MAX_REDIRECTS = 5;
4
+ /** What one answer may be, read, unless the caller says otherwise. */
5
+ export declare const MAX_BYTES: number;
6
+ export declare class ResponseTooLarge extends Error {
7
+ constructor(url: string, maxBytes: number);
8
+ }
9
+ /**
10
+ * `res`, its body read to at most `maxBytes`. The status and headers stand;
11
+ * the body errors with ResponseTooLarge at the limit — `text()`, `json()`
12
+ * and a reader all reject, never resolve short. A declared length past the
13
+ * limit errors at the first read, before a byte is fetched; an undeclared
14
+ * one is counted as it streams (after decompression, so a small gzip that
15
+ * unpacks huge is caught too). Infinity: no limit.
16
+ */
17
+ export declare function capBody(res: Response, maxBytes: number, url: string): Response;
4
18
  export declare class RedirectRefused extends Error {
5
19
  constructor(message: string);
6
20
  }
@@ -8,4 +22,6 @@ export declare function fetchUntrusted(url: string, init?: RequestInit, opts?: {
8
22
  /** The request carries a credential (in its body or its headers):
9
23
  * follow a redirect only within the origin it was sent to. */
10
24
  sameOrigin?: boolean;
25
+ /** The most the answer may be, read; MAX_BYTES unless said. */
26
+ maxBytes?: number;
11
27
  }): Promise<Response>;
@@ -14,6 +14,12 @@
14
14
  // the first one was. A hop to another origin keeps only the headers that
15
15
  // cannot carry a credential, and a caller whose request IS a credential
16
16
  // (a token exchange, a provider key in a header) follows none at all.
17
+ //
18
+ // And every answer is read to a limit (maxBytes, 1 MiB unless the caller
19
+ // says otherwise): a tenant's URL can answer forever, and the worker or web
20
+ // tier holding it all in memory is the platform's problem, not the tenant's.
21
+ // Past the limit the body errors — a caller never gets the first megabyte
22
+ // passed off as the whole answer.
17
23
  import { platform } from "./platform.js";
18
24
  /** How many redirects one request may follow. fetch's own limit is 20;
19
25
  * nothing a tenant points the platform at needs more than a couple. */
@@ -23,6 +29,57 @@ const REDIRECT = new Set([301, 302, 303, 307, 308]);
23
29
  * credential. Everything else — Authorization, a cookie, an x-api-key, a
24
30
  * webhook's signature — stays with the origin it was meant for. */
25
31
  const CROSS_ORIGIN_SAFE = new Set(["accept", "accept-language", "content-type", "user-agent"]);
32
+ /** What one answer may be, read, unless the caller says otherwise. */
33
+ export const MAX_BYTES = 1024 * 1024;
34
+ export class ResponseTooLarge extends Error {
35
+ constructor(url, maxBytes) {
36
+ super(`${url} answered more than ${sizeLabel(maxBytes)} — not read`);
37
+ this.name = "ResponseTooLarge";
38
+ }
39
+ }
40
+ const sizeLabel = (n) => (n % (1024 * 1024) === 0 ? `${n / (1024 * 1024)} MiB` : `${n} bytes`);
41
+ /** Statuses whose Response may carry no body (the constructor throws on one). */
42
+ const NULL_BODY = new Set([101, 204, 205, 304]);
43
+ /**
44
+ * `res`, its body read to at most `maxBytes`. The status and headers stand;
45
+ * the body errors with ResponseTooLarge at the limit — `text()`, `json()`
46
+ * and a reader all reject, never resolve short. A declared length past the
47
+ * limit errors at the first read, before a byte is fetched; an undeclared
48
+ * one is counted as it streams (after decompression, so a small gzip that
49
+ * unpacks huge is caught too). Infinity: no limit.
50
+ */
51
+ export function capBody(res, maxBytes, url) {
52
+ if (!Number.isFinite(maxBytes) || !res.body || NULL_BODY.has(res.status))
53
+ return res;
54
+ const declared = Number(res.headers.get("content-length") ?? NaN);
55
+ const tooLong = Number.isFinite(declared) && declared > maxBytes;
56
+ const reader = res.body.getReader();
57
+ let seen = 0;
58
+ const body = new ReadableStream({
59
+ async pull(controller) {
60
+ if (tooLong) {
61
+ reader.cancel().catch(() => { });
62
+ return controller.error(new ResponseTooLarge(url, maxBytes));
63
+ }
64
+ const { done, value } = await reader.read();
65
+ if (done)
66
+ return controller.close();
67
+ seen += value.byteLength;
68
+ if (seen > maxBytes) {
69
+ reader.cancel().catch(() => { });
70
+ return controller.error(new ResponseTooLarge(url, maxBytes));
71
+ }
72
+ controller.enqueue(value);
73
+ },
74
+ cancel(reason) {
75
+ return reader.cancel(reason);
76
+ },
77
+ });
78
+ const out = new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
79
+ // Where the answer came from, as the original said.
80
+ Object.defineProperty(out, "url", { value: res.url });
81
+ return out;
82
+ }
26
83
  export class RedirectRefused extends Error {
27
84
  constructor(message) {
28
85
  super(message);
@@ -30,13 +87,14 @@ export class RedirectRefused extends Error {
30
87
  }
31
88
  }
32
89
  export async function fetchUntrusted(url, init = {}, opts = {}) {
90
+ const maxBytes = opts.maxBytes ?? MAX_BYTES;
33
91
  let current = url;
34
92
  let req = { ...init };
35
93
  for (let hop = 0;; hop++) {
36
- const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" });
94
+ const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, { maxBytes });
37
95
  const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
38
96
  if (!location)
39
- return res;
97
+ return capBody(res, maxBytes, current);
40
98
  // The body of a redirect is never read; free the socket.
41
99
  await res.body?.cancel().catch(() => { });
42
100
  if (hop >= MAX_REDIRECTS)
package/index.ts CHANGED
@@ -18,7 +18,10 @@ export * from "./src/starter.ts";
18
18
  export * from "./src/runner.ts";
19
19
  export * from "./src/okf.ts";
20
20
  export * from "./src/evals.ts";
21
+ export * from "./src/grades.ts";
22
+ export * from "./src/eval-compare.ts";
21
23
  export * from "./src/flow-lint.ts";
24
+ export * from "./src/trigger-filter.ts";
22
25
  export * from "./src/catalog.ts";
23
26
  export * from "./src/model-probe.ts";
24
27
  export * from "./src/providers.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "2.10.1",
3
+ "version": "2.11.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@anthropic-ai/claude-agent-sdk": "^0.3.296",
57
57
  "gray-matter": "^4.0.3",
58
- "zod": "4.4.3"
58
+ "zod": "4.6.7"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^26",
package/src/catalog.ts CHANGED
@@ -121,6 +121,10 @@ export function readCatalog(baseUrl: string): Catalog | null {
121
121
  }
122
122
  }
123
123
 
124
+ /** The most a gateway's model list may be, read: OpenRouter's is about
125
+ * 800 KB and grows, near fetchUntrusted's 1 MiB default. */
126
+ export const CATALOG_MAX_BYTES = 8 * 1024 * 1024;
127
+
124
128
  /**
125
129
  * The cache, refreshed over the network when it is older than the TTL.
126
130
  * A failed fetch keeps the stale copy — offline is a reason to be out of
@@ -134,7 +138,7 @@ export async function loadCatalog(baseUrl: string): Promise<Catalog | null> {
134
138
  try {
135
139
  const res = await fetchUntrusted(`${trimSlashes(baseUrl)}/v1/models`, {
136
140
  signal: AbortSignal.timeout(10_000),
137
- });
141
+ }, { maxBytes: CATALOG_MAX_BYTES });
138
142
  if (!res.ok) throw new Error(`${res.status}`);
139
143
  const models = parseModels(await res.json());
140
144
  if (!models.length) throw new Error("empty model list");
package/src/deploy.ts CHANGED
@@ -636,6 +636,16 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
636
636
  }
637
637
  }
638
638
 
639
+ // An eval whose settings cannot be read: a `sample:` past 100, a grader
640
+ // (`trigger: runs`) with a task nothing receives or a file:/run: check
641
+ // that would act on the platform's host. Refused where they are written.
642
+ for (const f of files) {
643
+ if (!/^evals\/[^/]+\.md$/.test(f.path)) continue;
644
+ let info;
645
+ try { info = parseEval(f.path, f.content); } catch { continue; }
646
+ for (const p of info.problems) at(f.path, p);
647
+ }
648
+
639
649
  // OKF conformance, which needs the files as a tree. Staged in a temp
640
650
  // directory and thrown away — nothing touches the live workspace until every
641
651
  // check above has passed.
@@ -0,0 +1,243 @@
1
+ // Model comparisons: one eval's cases, run under two to four model settings,
2
+ // side by side (feature 2, 24 Oct 2026).
3
+ //
4
+ // "Could this step run on the fast model?" was a guess: switch it, watch a
5
+ // week of desks, switch it back. A comparison answers it from the cases you
6
+ // already have — each configuration runs every case, `repeat` times, and the
7
+ // result says per configuration how many passed, what it cost (the runs and
8
+ // their judges) and how long a run took, and per case where they differ.
9
+ //
10
+ // The rules, settled in a Codex debate (24 Oct 2026):
11
+ // · The model is forced on the steps (every step of a flow eval, or only
12
+ // `agent`'s), so it wins over the agent's own `model:` and the flow's.
13
+ // · Every run is a test run, whatever the eval or the flow says about
14
+ // `live:` — a comparison is never the reason something was sent.
15
+ // · The judge is the eval's own, the same for every configuration: what
16
+ // changes is the subject, never the grader.
17
+ // · Bounded twice: at most MAX_TRIALS case runs, and `maxUsd` — once the
18
+ // runs and judges have spent it, the rest are recorded as not run.
19
+ // · A run's time is from a worker picking it up to its end; queueing is
20
+ // left out, so a busy account does not read as a slow model.
21
+ // · The repeats interleave (each configuration once, then again), so a
22
+ // provider that slows down mid-way slows every configuration alike.
23
+ // · Graders and saved inputs are not comparable: they have no cases to run.
24
+ // · Nothing here writes the eval's latest result or its history.
25
+
26
+ import { runEval, type EvalInfo, type EvalResult } from "./evals.ts";
27
+ import { resolveEffort, type Effort } from "./store.ts";
28
+ import { loadFlow } from "./runner.ts";
29
+
30
+ export const MAX_CONFIGS = 4;
31
+ export const MAX_REPEAT = 5;
32
+ export const MAX_TRIALS = 60;
33
+ export const DEFAULT_MAX_USD = 5;
34
+ export const MAX_MAX_USD = 100;
35
+
36
+ export interface CompareConfig {
37
+ /** A tier (fast, default, max) or a model id. */
38
+ model: string;
39
+ effort?: Effort | null;
40
+ /** Flow evals: only this agent's steps take the model. */
41
+ agent?: string | null;
42
+ }
43
+
44
+ export interface ComparePlan {
45
+ configs: CompareConfig[];
46
+ repeat: number;
47
+ maxUsd: number;
48
+ /** cases × configs × repeat. */
49
+ trials: number;
50
+ }
51
+
52
+ export interface CompareCaseRow {
53
+ case: string;
54
+ /** Per configuration, in order: passed of trials. */
55
+ passed: number[];
56
+ trials: number[];
57
+ }
58
+
59
+ export interface CompareConfigResult extends CompareConfig {
60
+ label: string;
61
+ trials: number;
62
+ passed: number;
63
+ /** Trials that were not run (the cap) or could not run (an error before any check). */
64
+ notRun: number;
65
+ /** passed / decided trials; null when none ran. */
66
+ rate: number | null;
67
+ /** The runs and their judges, USD. */
68
+ costUsd: number;
69
+ judgeCostUsd: number;
70
+ /** Per case run that finished, the mean, USD and seconds. */
71
+ avgCostUsd: number | null;
72
+ avgSecs: number | null;
73
+ /** Every run this configuration started, for opening one. */
74
+ runIds: string[];
75
+ }
76
+
77
+ export interface EvalComparison {
78
+ eval: string;
79
+ target: { flow: string | null; agent: string | null };
80
+ judge: { model: string; effort: Effort | null };
81
+ startedAt: string;
82
+ finishedAt: string;
83
+ repeat: number;
84
+ maxUsd: number;
85
+ spentUsd: number;
86
+ /** Why it stopped early, when it did. */
87
+ stopped: string | null;
88
+ configs: CompareConfigResult[];
89
+ cases: CompareCaseRow[];
90
+ }
91
+
92
+ function refuse(message: string, status: number): Error {
93
+ return Object.assign(new Error(message), { status });
94
+ }
95
+
96
+ const MODEL = /^[A-Za-z0-9][\w.:/-]{0,99}$/;
97
+
98
+ export const configLabel = (c: CompareConfig) =>
99
+ `${c.model}${c.effort ? ` · ${c.effort}` : ""}${c.agent ? ` · ${c.agent} only` : ""}`;
100
+
101
+ /**
102
+ * Read and bound a request. Throws with a status: 400 for a shape, 413 when
103
+ * it is too big to run. The eval must have cases and not be a grader.
104
+ */
105
+ export function planComparison(info: EvalInfo, body: unknown): ComparePlan {
106
+ if (info.trigger === "runs") throw refuse(`${info.name} grades real runs — it has no cases to compare models on`, 400);
107
+ if (info.inputs) throw refuse(`${info.name} is a file of saved inputs, not an eval`, 400);
108
+ if (info.cases.length === 0) throw refuse(`${info.name} has no cases`, 400);
109
+ // A comparison promises test runs only; a run: check is a command on the
110
+ // platform's host, run again for every case of every setting and repeat.
111
+ const runs = info.cases.find((c) => c.expect.some((a) => a.type === "run"));
112
+ if (runs) throw refuse(`${info.name} has a run: check (case "${runs.name}") — a comparison repeats every check many times and run: executes a command, so it cannot be compared; use contains, matches or judge`, 400);
113
+ const b = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
114
+ if (!Array.isArray(b.configs) || b.configs.length < 2) throw refuse("expected { configs: [{ model, effort?, agent? }, …] } — two to four settings to compare", 400);
115
+ if (b.configs.length > MAX_CONFIGS) throw refuse(`at most ${MAX_CONFIGS} settings in one comparison`, 400);
116
+ const configs: CompareConfig[] = b.configs.map((raw, i) => {
117
+ const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
118
+ if (typeof r.model !== "string" || !MODEL.test(r.model.trim())) throw refuse(`configs[${i}].model is a tier (fast, default, max) or a model id`, 400);
119
+ let effort: Effort | null = null;
120
+ if (r.effort !== undefined && r.effort !== null && r.effort !== "") {
121
+ effort = resolveEffort(r.effort);
122
+ if (!effort) throw refuse(`configs[${i}].effort is low, medium, high, xhigh or max`, 400);
123
+ }
124
+ let agent: string | null = null;
125
+ if (r.agent !== undefined && r.agent !== null && r.agent !== "") {
126
+ if (!info.flow) throw refuse(`configs[${i}].agent picks one agent's steps of a flow eval — ${info.name} tests agent ${info.agent} already`, 400);
127
+ if (typeof r.agent !== "string" || !/^[a-z0-9][a-z0-9-]{0,60}$/.test(r.agent)) throw refuse(`configs[${i}].agent is an agent's name`, 400);
128
+ agent = r.agent;
129
+ }
130
+ return { model: r.model.trim(), effort, agent };
131
+ });
132
+ const keys = configs.map(configLabel);
133
+ if (new Set(keys).size !== keys.length) throw refuse("two settings are the same — each one should differ", 400);
134
+ const repeat = b.repeat === undefined ? 1 : Number(b.repeat);
135
+ if (!Number.isInteger(repeat) || repeat < 1 || repeat > MAX_REPEAT) throw refuse(`repeat is a whole number from 1 to ${MAX_REPEAT}`, 400);
136
+ const maxUsd = b.maxUsd === undefined ? DEFAULT_MAX_USD : Number(b.maxUsd);
137
+ if (!Number.isFinite(maxUsd) || maxUsd <= 0 || maxUsd > MAX_MAX_USD) throw refuse(`maxUsd is a spend limit in USD, above 0 and at most ${MAX_MAX_USD}`, 400);
138
+ const trials = info.cases.length * configs.length * repeat;
139
+ if (trials > MAX_TRIALS) {
140
+ throw refuse(`${info.cases.length} cases × ${configs.length} settings × ${repeat} = ${trials} runs — at most ${MAX_TRIALS}; fewer settings or repeats`, 413);
141
+ }
142
+ return { configs, repeat, maxUsd, trials };
143
+ }
144
+
145
+ /**
146
+ * Run the comparison. `onProgress` hears each finished pass of one
147
+ * configuration (done of total case runs). `run` is the eval runner —
148
+ * injectable, so a test needs no model.
149
+ */
150
+ /** A flow eval's `agent` must have steps in the flow, or the comparison
151
+ * would run the original models under the requested labels. */
152
+ function assertAgentsInFlow(tenant: string, workspace: string, info: EvalInfo, plan: ComparePlan) {
153
+ const named = plan.configs.map((c) => c.agent).filter((a): a is string => !!a);
154
+ if (!info.flow || !named.length) return;
155
+ const flow = loadFlow(tenant, workspace, info.flow);
156
+ const agents = new Set((flow?.steps ?? []).map((s) => s.agent));
157
+ const missing = named.find((a) => !agents.has(a));
158
+ if (missing) throw refuse(`flow ${info.flow} has no step by ${missing} — pick an agent it runs`, 400);
159
+ }
160
+
161
+ export async function compareEval(
162
+ tenant: string,
163
+ workspace: string,
164
+ info: EvalInfo,
165
+ plan: ComparePlan,
166
+ opts: { onProgress?: (done: number, total: number) => void | Promise<void>; run?: typeof runEval } = {},
167
+ ): Promise<EvalComparison> {
168
+ if (!opts.run) assertAgentsInFlow(tenant, workspace, info, plan);
169
+ const run = opts.run ?? runEval;
170
+ const startedAt = new Date().toISOString();
171
+ const results: EvalResult[][] = plan.configs.map(() => []);
172
+ let spent = 0;
173
+ let stopped: string | null = null;
174
+ let done = 0;
175
+ for (let r = 0; r < plan.repeat; r++) {
176
+ for (let i = 0; i < plan.configs.length; i++) {
177
+ const cfg = plan.configs[i];
178
+ const result = await run(tenant, workspace, info, {
179
+ model: cfg.model,
180
+ effort: cfg.effort ?? null,
181
+ agent: cfg.agent ?? null,
182
+ forceTest: true,
183
+ beforeCase: () => {
184
+ if (spent >= plan.maxUsd) {
185
+ stopped ??= `the spend limit of $${plan.maxUsd.toFixed(2)} was reached`;
186
+ return stopped;
187
+ }
188
+ return null;
189
+ },
190
+ // Counted as each case ends, so the limit holds inside a setting too.
191
+ afterCase: (c) => {
192
+ spent += c.costUsd + (c.judgeCostUsd ?? 0);
193
+ },
194
+ });
195
+ results[i].push(result);
196
+ done += info.cases.length;
197
+ await opts.onProgress?.(done, plan.trials);
198
+ }
199
+ }
200
+
201
+ const round = (n: number, d = 4) => Math.round(n * 10 ** d) / 10 ** d;
202
+ const configs: CompareConfigResult[] = plan.configs.map((cfg, i) => {
203
+ const cases = results[i].flatMap((r) => r.cases);
204
+ const ran = cases.filter((c) => c.runId);
205
+ const notRun = cases.filter((c) => !c.passed && c.error && !c.assertions.length).length;
206
+ const passed = cases.filter((c) => c.passed).length;
207
+ const decided = cases.length - notRun;
208
+ const cost = cases.reduce((n, c) => n + c.costUsd, 0);
209
+ const judge = cases.reduce((n, c) => n + (c.judgeCostUsd ?? 0), 0);
210
+ const timed = ran.filter((c) => typeof c.secs === "number");
211
+ return {
212
+ ...cfg,
213
+ label: configLabel(cfg),
214
+ trials: cases.length,
215
+ passed,
216
+ notRun,
217
+ rate: decided ? round(passed / decided, 3) : null,
218
+ costUsd: round(cost + judge),
219
+ judgeCostUsd: round(judge),
220
+ avgCostUsd: ran.length ? round((cost + judge) / ran.length) : null,
221
+ avgSecs: timed.length ? Math.round((timed.reduce((n, c) => n + (c.secs ?? 0), 0) / timed.length) * 10) / 10 : null,
222
+ runIds: ran.map((c) => c.runId!),
223
+ };
224
+ });
225
+ const cases: CompareCaseRow[] = info.cases.map((c) => ({
226
+ case: c.name,
227
+ passed: results.map((rs) => rs.flatMap((r) => r.cases).filter((x) => x.name === c.name && x.passed).length),
228
+ trials: results.map((rs) => rs.flatMap((r) => r.cases).filter((x) => x.name === c.name).length),
229
+ }));
230
+ return {
231
+ eval: info.name,
232
+ target: { flow: info.flow, agent: info.agent },
233
+ judge: { model: info.model, effort: info.effort },
234
+ startedAt,
235
+ finishedAt: new Date().toISOString(),
236
+ repeat: plan.repeat,
237
+ maxUsd: plan.maxUsd,
238
+ spentUsd: round(spent),
239
+ stopped,
240
+ configs,
241
+ cases,
242
+ };
243
+ }