@foldrun/core 2.10.1 → 2.11.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/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/catalog.d.ts +3 -0
- package/dist/src/catalog.js +4 -1
- package/dist/src/deploy.js +16 -0
- package/dist/src/eval-compare.d.ts +74 -0
- package/dist/src/eval-compare.js +187 -0
- package/dist/src/evals.d.ts +62 -3
- package/dist/src/evals.js +161 -36
- package/dist/src/flow-lint.js +4 -0
- package/dist/src/frontmatter-fields.js +4 -1
- package/dist/src/grades.d.ts +118 -0
- package/dist/src/grades.js +481 -0
- package/dist/src/judge-verdict.d.ts +1 -0
- package/dist/src/judge-verdict.js +3 -1
- package/dist/src/online-checks.d.ts +4 -0
- package/dist/src/online-checks.js +5 -1
- package/dist/src/openapi.d.ts +4 -0
- package/dist/src/openapi.js +5 -1
- package/dist/src/platform.d.ts +12 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/starter.js +5 -0
- package/dist/src/store.d.ts +8 -0
- package/dist/src/store.js +5 -1
- package/dist/src/tool-test.js +19 -7
- package/dist/src/translator.d.ts +4 -0
- package/dist/src/translator.js +22 -3
- package/dist/src/trigger-filter.d.ts +28 -0
- package/dist/src/trigger-filter.js +272 -0
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +2 -0
- package/dist/src/triggers.js +13 -2
- package/dist/src/untrusted-fetch.d.ts +16 -0
- package/dist/src/untrusted-fetch.js +60 -2
- package/index.ts +3 -0
- package/package.json +3 -3
- package/src/catalog.ts +5 -1
- package/src/deploy.ts +10 -0
- package/src/eval-compare.ts +243 -0
- package/src/evals.ts +195 -38
- package/src/flow-lint.ts +4 -0
- package/src/frontmatter-fields.ts +4 -1
- package/src/grades.ts +532 -0
- package/src/judge-verdict.ts +4 -2
- package/src/online-checks.ts +5 -1
- package/src/openapi.ts +6 -1
- package/src/platform.ts +16 -4
- package/src/starter.ts +5 -0
- package/src/store.ts +11 -1
- package/src/tool-test.ts +21 -7
- package/src/translator.ts +24 -3
- package/src/trigger-filter.ts +264 -0
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +14 -2
- package/src/untrusted-fetch.ts +65 -2
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.
|
|
3
|
+
"version": "2.11.1",
|
|
4
4
|
"description": "Agents are just folders. The format, the runner, the checks.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"README.md"
|
|
43
43
|
],
|
|
44
44
|
"scripts": {
|
|
45
|
-
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json",
|
|
46
46
|
"prepare": "npm run build && (test ! -f .githooks/install.sh || sh .githooks/install.sh)",
|
|
47
47
|
"prepublishOnly": "npm run build",
|
|
48
48
|
"test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test *.test.ts $(ls tests/*.test.ts | grep -v e2e)",
|
|
@@ -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.
|
|
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
|
+
}
|