@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.
Files changed (55) 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/grades.d.ts +118 -0
  13. package/dist/src/grades.js +481 -0
  14. package/dist/src/judge-verdict.d.ts +1 -0
  15. package/dist/src/judge-verdict.js +3 -1
  16. package/dist/src/online-checks.d.ts +4 -0
  17. package/dist/src/online-checks.js +5 -1
  18. package/dist/src/openapi.d.ts +4 -0
  19. package/dist/src/openapi.js +5 -1
  20. package/dist/src/platform.d.ts +12 -3
  21. package/dist/src/platform.js +9 -1
  22. package/dist/src/starter.js +5 -0
  23. package/dist/src/store.d.ts +8 -0
  24. package/dist/src/store.js +5 -1
  25. package/dist/src/tool-test.js +19 -7
  26. package/dist/src/translator.d.ts +4 -0
  27. package/dist/src/translator.js +22 -3
  28. package/dist/src/trigger-filter.d.ts +28 -0
  29. package/dist/src/trigger-filter.js +272 -0
  30. package/dist/src/trigger-log.d.ts +1 -1
  31. package/dist/src/triggers.d.ts +2 -0
  32. package/dist/src/triggers.js +13 -2
  33. package/dist/src/untrusted-fetch.d.ts +16 -0
  34. package/dist/src/untrusted-fetch.js +60 -2
  35. package/index.ts +3 -0
  36. package/package.json +3 -3
  37. package/src/catalog.ts +5 -1
  38. package/src/deploy.ts +10 -0
  39. package/src/eval-compare.ts +243 -0
  40. package/src/evals.ts +195 -38
  41. package/src/flow-lint.ts +4 -0
  42. package/src/frontmatter-fields.ts +4 -1
  43. package/src/grades.ts +532 -0
  44. package/src/judge-verdict.ts +4 -2
  45. package/src/online-checks.ts +5 -1
  46. package/src/openapi.ts +6 -1
  47. package/src/platform.ts +16 -4
  48. package/src/starter.ts +5 -0
  49. package/src/store.ts +11 -1
  50. package/src/tool-test.ts +21 -7
  51. package/src/translator.ts +24 -3
  52. package/src/trigger-filter.ts +264 -0
  53. package/src/trigger-log.ts +1 -0
  54. package/src/triggers.ts +14 -2
  55. package/src/untrusted-fetch.ts +65 -2
@@ -0,0 +1,481 @@
1
+ // Grading live runs — an eval with `trigger: runs` (FOL-feature 1, 24 Oct 2026).
2
+ //
3
+ // An eval tests an agent on cases someone wrote; it says nothing about the
4
+ // runs that actually happened. A desk that runs at 3am every night is only
5
+ // ever checked by its own `verify:` lines, and a reply that passes them but
6
+ // is quietly worse than last month's goes unseen. A grader is an eval file
7
+ // that, instead of running cases, reads a sample of the real finished runs
8
+ // of its agent or flow and holds each to its checks:
9
+ //
10
+ // evals/digest-quality.md
11
+ // ---
12
+ // flow: weekly-digest # or agent: writer — that agent's steps
13
+ // trigger: runs
14
+ // sample: 25 # percent of finished runs graded (default 25)
15
+ // model: fast # the judge
16
+ // ---
17
+ //
18
+ // ## cites its sources
19
+ // expect:
20
+ // - judge: every claim names the source it came from
21
+ // ## no placeholders
22
+ // expect:
23
+ // - not-contains: lorem ipsum
24
+ //
25
+ // The same assertions as an eval case, cheap ones first; the judge runs only
26
+ // when they pass, and is given the run's task so it grades the reply against
27
+ // what was asked. `file:` and `run:` are refused — they would read or execute
28
+ // on the platform's host, not in the run's sandbox — and so is `matches:`: a
29
+ // regular expression over any run's output, on the scheduler, could stall it.
30
+ //
31
+ // Which runs: completed ones (a crash is already an issue), not test runs,
32
+ // not eval runs, finished after the grader was first seen — a new grader
33
+ // does not back-fill a week of runs in one burst — and inside the last
34
+ // three days. Sampled by a hash of the run id and the eval's name, so the
35
+ // same run is in or out however many times the pass looks at it.
36
+ //
37
+ // Where: one line per graded run in evals/.results/grades/<eval>.jsonl
38
+ // (beside the eval history the deploy never touches). A run graded again —
39
+ // on demand, from the dashboard or the CLI — appends a newer line, and the
40
+ // newest line per run is its grade. Kept 90 days, at most 5,000 lines; the
41
+ // pass only looks three days back, so a run is never graded twice by it.
42
+ //
43
+ // Who: the platform's grade pass (platform grade-pass.ts) every few minutes,
44
+ // a few judges at a time; or a person, for one run, at once.
45
+ import fs from "node:fs";
46
+ import path from "node:path";
47
+ import crypto from "node:crypto";
48
+ import { workspaceDir, listRunSummaries, readRun, assertSafeName } from "./store.js";
49
+ import { listEvals, checkAssertion, judgeCall, judgeProcessEnv } from "./evals.js";
50
+ import { accountModelEnv, withModelEnv } from "./model-env.js";
51
+ import { judgeVerdict } from "./judge-verdict.js";
52
+ /** How far back the pass looks for runs to grade. */
53
+ export const GRADE_WINDOW_MS = 3 * 86400_000;
54
+ /** How long grades are kept, and how many per eval. */
55
+ export const GRADE_KEEP_MS = 90 * 86400_000;
56
+ export const GRADE_KEEP_LINES = 5000;
57
+ function refuse(message, status) {
58
+ return Object.assign(new Error(message), { status });
59
+ }
60
+ const gradesDir = (tenant, workspace) => path.join(workspaceDir(tenant, workspace), "evals", ".results", "grades");
61
+ function gradesFile(tenant, workspace, name) {
62
+ assertSafeName(name, "eval");
63
+ return path.join(gradesDir(tenant, workspace), `${name}.jsonl`);
64
+ }
65
+ function stateFile(tenant, workspace, name) {
66
+ assertSafeName(name, "eval");
67
+ return path.join(gradesDir(tenant, workspace), `${name}.state.json`);
68
+ }
69
+ /** The graders of a workspace: its `trigger: runs` evals that can be read. */
70
+ export function listGraders(tenant, workspace) {
71
+ return listEvals(tenant, workspace).filter((e) => e.trigger === "runs");
72
+ }
73
+ /** The version of a grader, as its file reads now. */
74
+ export function evalRevision(tenant, workspace, info) {
75
+ try {
76
+ const raw = fs.readFileSync(path.join(workspaceDir(tenant, workspace), "evals", info.file), "utf8");
77
+ return crypto.createHash("sha256").update(raw).digest("hex").slice(0, 12);
78
+ }
79
+ catch {
80
+ return "unknown";
81
+ }
82
+ }
83
+ /** Is this run in this grader's sample? Stable: a hash, not a coin. */
84
+ export function sampled(runId, evalName, percent) {
85
+ if (percent >= 100)
86
+ return true;
87
+ if (percent <= 0)
88
+ return false;
89
+ const h = crypto.createHash("sha256").update(`${evalName}\u0000${runId}`).digest();
90
+ return h.readUInt32BE(0) % 10_000 < Math.round(percent * 100);
91
+ }
92
+ /** What a run's task was — the input a flow run was given, else its first
93
+ * step's instruction. */
94
+ function taskOfRun(run) {
95
+ const first = run.steps[0]?.instruction ?? "";
96
+ const block = first.match(/<(run_task|webhook_payload|email|previous_run|storage_event|watched_content)>\n?([\s\S]*?)\n?<\/\1>/);
97
+ return (block ? block[2] : first).trim().slice(0, 4000);
98
+ }
99
+ /** Is a run one this grader is about? Its flow, or a step by its agent. */
100
+ function about(info, run) {
101
+ if (info.flow)
102
+ return run.flow === info.flow;
103
+ if (info.agent)
104
+ return run.flow === `adhoc:${info.agent}` || run.steps.some((s) => s.agent === info.agent);
105
+ return false;
106
+ }
107
+ /** The text a grader grades: every step's reply for a flow, the agent's
108
+ * replies for an agent. Null when the run has nothing of it. */
109
+ export function gradedOutput(info, run) {
110
+ const steps = info.flow ? run.steps : run.steps.filter((s) => s.agent === info.agent);
111
+ const out = steps.map((s) => (s.result ?? "").trim()).filter(Boolean).join("\n\n");
112
+ return out || null;
113
+ }
114
+ /** Grade one finished run. Writes nothing; `recordGrade` keeps it. */
115
+ export async function gradeRun(tenant, workspace, info, run, opts = {}) {
116
+ if (info.trigger !== "runs")
117
+ throw refuse(`${info.name} is not a grader — it runs cases (set trigger: runs to grade real runs)`, 400);
118
+ if (info.problems.length)
119
+ throw refuse(`${info.name} cannot be read as written: ${info.problems[0]}`, 422);
120
+ if (run.status !== "completed" && run.status !== "failed")
121
+ throw refuse(`run ${run.id} has not finished — it is ${run.status}`, 409);
122
+ if (!about(info, run)) {
123
+ throw refuse(`run ${run.id} is a run of ${run.flow}, not ${info.flow ? `flow ${info.flow}` : `agent ${info.agent}`} — ${info.name} grades only those`, 400);
124
+ }
125
+ const output = gradedOutput(info, run);
126
+ if (!output)
127
+ throw refuse(`run ${run.id} produced no reply ${info.agent ? `from ${info.agent} ` : ""}to grade`, 409);
128
+ // A flow's task is what the run was given; an agent's is what ITS steps
129
+ // were told — an editor late in a flow is graded against its own brief,
130
+ // not the researcher's.
131
+ const task = info.flow
132
+ ? taskOfRun(run)
133
+ : run.steps
134
+ .filter((s) => s.agent === info.agent)
135
+ .map((s) => (s.instruction ?? "").trim())
136
+ .filter(Boolean)
137
+ .join("\n\n")
138
+ .slice(0, 4000) || taskOfRun(run);
139
+ const agentDir = path.join(workspaceDir(tenant, workspace), "agents", info.agent ?? "");
140
+ const ask = opts.judge ?? judgeCall;
141
+ let costUsd = 0;
142
+ const checks = [];
143
+ for (const c of info.cases) {
144
+ const assertions = [];
145
+ // The judges that did not decide — threw, or answered with no verdict —
146
+ // by their place in `assertions`. A check whose only misses are these is
147
+ // undecided, never failed: nobody's work was found wanting.
148
+ const undecided = new Set();
149
+ const errors = [];
150
+ const cheap = c.expect.filter((a) => a.type !== "judge");
151
+ const judges = c.expect.filter((a) => a.type === "judge");
152
+ for (const a of cheap) {
153
+ if (a.type === "file" || a.type === "run" || a.type === "matches") {
154
+ assertions.push({ type: a.type, value: a.value, passed: false, detail: `${a.type}: is not used to grade a finished run` });
155
+ continue;
156
+ }
157
+ const r = await checkAssertion(a, output, agentDir, info.model, info.effort);
158
+ assertions.push({ type: a.type, value: a.value, passed: r.passed, detail: r.detail });
159
+ }
160
+ if (assertions.every((a) => a.passed)) {
161
+ for (const a of judges) {
162
+ try {
163
+ const m = accountModelEnv(tenant, workspace, judgeProcessEnv);
164
+ const said = await withModelEnv(m, (env) => ask(a.value, output, info.model, info.effort, env, task));
165
+ costUsd += said.costUsd;
166
+ const v = judgeVerdict(said.text);
167
+ if (v.decided === false) {
168
+ undecided.add(assertions.length);
169
+ errors.push(v.detail);
170
+ }
171
+ assertions.push({ type: "judge", value: a.value, passed: v.passed, detail: v.detail });
172
+ }
173
+ catch (err) {
174
+ const why = `the judge did not answer — ${err instanceof Error ? err.message : String(err)}`;
175
+ undecided.add(assertions.length);
176
+ errors.push(why);
177
+ assertions.push({ type: "judge", value: a.value, passed: false, detail: why });
178
+ }
179
+ }
180
+ }
181
+ else {
182
+ for (const a of judges)
183
+ assertions.push({ type: "judge", value: a.value, passed: false, detail: "not run — an earlier check failed" });
184
+ }
185
+ const decidedFail = assertions.some((a, i) => !a.passed && !undecided.has(i));
186
+ const error = errors.length ? errors.join("; ").slice(0, 600) : undefined;
187
+ checks.push({
188
+ name: c.name,
189
+ passed: assertions.length > 0 && assertions.every((a) => a.passed),
190
+ ...(error && !decidedFail ? { error } : {}),
191
+ assertions,
192
+ });
193
+ }
194
+ const failed = checks.some((c) => !c.passed && !c.error);
195
+ const undecided = checks.some((c) => c.error);
196
+ return {
197
+ eval: info.name,
198
+ runId: run.id,
199
+ flow: run.flow,
200
+ at: new Date(opts.now ?? Date.now()).toISOString(),
201
+ status: failed ? "fail" : undecided ? "error" : "pass",
202
+ checks,
203
+ costUsd: Math.round(costUsd * 1e6) / 1e6,
204
+ model: info.model,
205
+ evalRevision: info.revision ?? evalRevision(tenant, workspace, info),
206
+ runRevision: run.revision ?? null,
207
+ runFinishedAt: run.finishedAt,
208
+ by: opts.by ?? "sample",
209
+ };
210
+ }
211
+ /** Keep a grade: one line, appended. */
212
+ export function recordGrade(tenant, workspace, grade) {
213
+ const file = gradesFile(tenant, workspace, grade.eval);
214
+ fs.mkdirSync(path.dirname(file), { recursive: true });
215
+ fs.appendFileSync(file, `${JSON.stringify(grade)}\n`);
216
+ }
217
+ /** Every line of a grader's file, oldest first; a torn line is skipped. */
218
+ function readLines(tenant, workspace, name) {
219
+ let raw;
220
+ try {
221
+ raw = fs.readFileSync(gradesFile(tenant, workspace, name), "utf8");
222
+ }
223
+ catch {
224
+ return [];
225
+ }
226
+ const out = [];
227
+ for (const line of raw.split("\n")) {
228
+ if (!line.trim())
229
+ continue;
230
+ try {
231
+ const g = JSON.parse(line);
232
+ if (g && typeof g.runId === "string" && typeof g.at === "string")
233
+ out.push(g);
234
+ }
235
+ catch {
236
+ /* a line torn by a crash mid-append costs that line, not the file */
237
+ }
238
+ }
239
+ return out;
240
+ }
241
+ /** A grader's grades, newest first — one per run, the newest line for it. */
242
+ export function readGrades(tenant, workspace, name, opts = {}) {
243
+ const byRun = new Map();
244
+ for (const g of readLines(tenant, workspace, name)) {
245
+ const prev = byRun.get(g.runId);
246
+ if (!prev || g.at >= prev.at)
247
+ byRun.set(g.runId, g);
248
+ }
249
+ return [...byRun.values()]
250
+ .filter((g) => (opts.sinceMs === undefined ? true : Date.parse(g.at) >= opts.sinceMs))
251
+ .sort((a, b) => b.at.localeCompare(a.at));
252
+ }
253
+ /** The grades of one run, across every grader of the workspace. */
254
+ export function gradesOfRun(tenant, workspace, runId) {
255
+ const out = [];
256
+ for (const g of listGraders(tenant, workspace)) {
257
+ const mine = readGrades(tenant, workspace, g.name).find((x) => x.runId === runId);
258
+ if (mine)
259
+ out.push(mine);
260
+ }
261
+ return out;
262
+ }
263
+ export function summariseGrades(grades) {
264
+ const days = new Map();
265
+ let passed = 0;
266
+ let failed = 0;
267
+ let errors = 0;
268
+ let costUsd = 0;
269
+ for (const g of grades) {
270
+ if (g.status === "pass")
271
+ passed++;
272
+ else if (g.status === "fail")
273
+ failed++;
274
+ else
275
+ errors++;
276
+ costUsd += g.costUsd ?? 0;
277
+ const day = g.at.slice(0, 10);
278
+ const d = days.get(day) ?? { graded: 0, passed: 0, failed: 0 };
279
+ d.graded++;
280
+ if (g.status === "pass")
281
+ d.passed++;
282
+ if (g.status === "fail")
283
+ d.failed++;
284
+ days.set(day, d);
285
+ }
286
+ return {
287
+ graded: grades.length,
288
+ passed,
289
+ failed,
290
+ errors,
291
+ rate: passed + failed ? Math.round((passed / (passed + failed)) * 1000) / 1000 : null,
292
+ costUsd: Math.round(costUsd * 1e4) / 1e4,
293
+ byDay: [...days.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, d]) => ({ day, ...d })),
294
+ };
295
+ }
296
+ /** When the pass first saw this grader — runs that finished before it are
297
+ * never sampled, so a new grader starts from now rather than back-filling. */
298
+ export function graderSince(tenant, workspace, name, now = Date.now()) {
299
+ const file = stateFile(tenant, workspace, name);
300
+ try {
301
+ const s = JSON.parse(fs.readFileSync(file, "utf8"));
302
+ const t = s.since ? Date.parse(s.since) : NaN;
303
+ if (Number.isFinite(t))
304
+ return t;
305
+ }
306
+ catch {
307
+ /* first sight */
308
+ }
309
+ fs.mkdirSync(path.dirname(file), { recursive: true });
310
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify({ since: new Date(now).toISOString() }));
311
+ fs.renameSync(`${file}.tmp`, file);
312
+ return now;
313
+ }
314
+ /** When a grader last had a run graded by the pass (0: never) — the pass
315
+ * serves the longest-waiting grader first, so fairness holds across passes. */
316
+ export function graderLastServed(tenant, workspace, name) {
317
+ try {
318
+ const s = JSON.parse(fs.readFileSync(stateFile(tenant, workspace, name), "utf8"));
319
+ const t = s.lastServed ? Date.parse(s.lastServed) : NaN;
320
+ return Number.isFinite(t) ? t : 0;
321
+ }
322
+ catch {
323
+ return 0;
324
+ }
325
+ }
326
+ function markServed(tenant, workspace, name, now) {
327
+ const file = stateFile(tenant, workspace, name);
328
+ let state = {};
329
+ try {
330
+ state = JSON.parse(fs.readFileSync(file, "utf8"));
331
+ }
332
+ catch {
333
+ /* graderSince writes it first; a missing one is rebuilt */
334
+ }
335
+ fs.mkdirSync(path.dirname(file), { recursive: true });
336
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify({ ...state, lastServed: new Date(now).toISOString() }));
337
+ fs.renameSync(`${file}.tmp`, file);
338
+ }
339
+ /** How long an undecided grade (the judge did not answer — a spent usage
340
+ * cap, a provider outage) waits before the pass tries the run again, and
341
+ * how many tries it gets. */
342
+ export const GRADE_RETRY_MS = 3600_000;
343
+ export const GRADE_TRIES = 3;
344
+ /** The runs the pass is done with: decided (pass or fail), or undecided
345
+ * too often, or too recently to try again. */
346
+ function settledRuns(lines, now) {
347
+ const errors = new Map();
348
+ const done = new Set();
349
+ for (const g of lines) {
350
+ if (g.status !== "error") {
351
+ done.add(g.runId);
352
+ continue;
353
+ }
354
+ const e = errors.get(g.runId) ?? { n: 0, last: 0 };
355
+ errors.set(g.runId, { n: e.n + 1, last: Math.max(e.last, Date.parse(g.at) || 0) });
356
+ }
357
+ for (const [id, e] of errors)
358
+ if (e.n >= GRADE_TRIES || now - e.last < GRADE_RETRY_MS)
359
+ done.add(id);
360
+ return done;
361
+ }
362
+ /** The runs the pass should grade for one grader, oldest first. */
363
+ export function gradeCandidates(tenant, workspace, info, now = Date.now()) {
364
+ const since = Math.max(graderSince(tenant, workspace, info.name, now), now - GRADE_WINDOW_MS);
365
+ const graded = settledRuns(readLines(tenant, workspace, info.name), now);
366
+ return listRunSummaries(tenant, workspace)
367
+ .filter((r) => r.status === "completed" &&
368
+ !r.test &&
369
+ !r.flow.startsWith("eval:") &&
370
+ !!r.finishedAt &&
371
+ Date.parse(r.finishedAt) >= since &&
372
+ about(info, r) &&
373
+ !graded.has(r.id) &&
374
+ sampled(r.id, info.name, info.sample))
375
+ .sort((a, b) => (a.finishedAt ?? "").localeCompare(b.finishedAt ?? ""))
376
+ .map((r) => r.id);
377
+ }
378
+ /** Drop grades past the keeping window or the line cap. Rewritten whole
379
+ * (temp, then rename); only the pass prunes. */
380
+ export function pruneGrades(tenant, workspace, name, now = Date.now()) {
381
+ const file = gradesFile(tenant, workspace, name);
382
+ if (!fs.existsSync(file))
383
+ return 0;
384
+ // Read whatever its size: the 90 days hold for a quiet grader too.
385
+ const lines = readLines(tenant, workspace, name);
386
+ // The line cap never takes a grade from the last GRADE_WINDOW_MS (and a
387
+ // day's margin): those are what keeps the pass from grading a run twice.
388
+ const recent = (g) => now - Date.parse(g.at) < GRADE_WINDOW_MS + 86400_000;
389
+ const old = lines.filter((g) => !recent(g) && now - Date.parse(g.at) < GRADE_KEEP_MS);
390
+ const room = GRADE_KEEP_LINES - lines.filter(recent).length;
391
+ const kept = [...(room > 0 ? old.slice(-room) : []), ...lines.filter(recent)]
392
+ .sort((a, b) => a.at.localeCompare(b.at));
393
+ if (kept.length === lines.length)
394
+ return 0;
395
+ const tmp = `${file}.${process.pid}.tmp`;
396
+ fs.writeFileSync(tmp, kept.map((g) => JSON.stringify(g)).join("\n") + (kept.length ? "\n" : ""));
397
+ fs.renameSync(tmp, file);
398
+ return lines.length - kept.length;
399
+ }
400
+ /**
401
+ * Grade what is due in one workspace: for each grader, the sampled runs that
402
+ * finished since it was first seen and are not graded yet. At most `limit`
403
+ * judges' worth of runs; `keepGoing` is asked before each run, so a pass
404
+ * that lost its lease stops between runs, never mid-grade.
405
+ */
406
+ export async function gradeWorkspace(tenant, workspace, opts = {}) {
407
+ const now = opts.now ?? Date.now();
408
+ let left = opts.limit ?? 5;
409
+ const errors = [];
410
+ let graded = 0;
411
+ let failed = 0;
412
+ // Every grader's candidates first — which also stamps a new grader's start
413
+ // whatever the budget — then taken in turns, one run from each grader in
414
+ // round, so one busy grader cannot have the whole pass every time.
415
+ const queues = [];
416
+ for (const info of listGraders(tenant, workspace)) {
417
+ if (info.problems.length || info.sample <= 0)
418
+ continue;
419
+ pruneGrades(tenant, workspace, info.name, now);
420
+ queues.push({ info, runs: gradeCandidates(tenant, workspace, info, now) });
421
+ }
422
+ // The grader that has waited longest goes first: within a pass they take
423
+ // turns, and across passes the ones left out last time lead the next.
424
+ queues.sort((a, b) => graderLastServed(tenant, workspace, a.info.name) - graderLastServed(tenant, workspace, b.info.name) || a.info.name.localeCompare(b.info.name));
425
+ const turns = [];
426
+ for (let i = 0; queues.some((q) => i < q.runs.length); i++) {
427
+ for (const q of queues)
428
+ if (i < q.runs.length)
429
+ turns.push({ info: q.info, runId: q.runs[i] });
430
+ }
431
+ for (const { info, runId } of turns) {
432
+ if (left <= 0)
433
+ break;
434
+ if (opts.keepGoing && !(await opts.keepGoing()))
435
+ return { graded, failed, errors };
436
+ const run = readRun(tenant, workspace, runId);
437
+ if (!run)
438
+ continue;
439
+ left--;
440
+ markServed(tenant, workspace, info.name, Date.now());
441
+ let g;
442
+ try {
443
+ g = await gradeRun(tenant, workspace, info, run, { now: Date.now(), judge: opts.judge });
444
+ recordGrade(tenant, workspace, g);
445
+ graded++;
446
+ if (g.status === "fail")
447
+ failed++;
448
+ }
449
+ catch (err) {
450
+ // A run that cannot be graded (it said nothing, it is no longer the
451
+ // grader's) is written down as an error so the pass does not pick it
452
+ // again every few minutes.
453
+ const message = err instanceof Error ? err.message : String(err);
454
+ errors.push(`${info.name} ${runId}: ${message}`);
455
+ recordGrade(tenant, workspace, {
456
+ eval: info.name,
457
+ runId,
458
+ flow: run.flow,
459
+ at: new Date().toISOString(),
460
+ status: "error",
461
+ checks: [{ name: "grade", passed: false, error: message, assertions: [] }],
462
+ costUsd: 0,
463
+ model: info.model,
464
+ evalRevision: evalRevision(tenant, workspace, info),
465
+ runRevision: run.revision ?? null,
466
+ runFinishedAt: run.finishedAt,
467
+ by: "sample",
468
+ });
469
+ continue;
470
+ }
471
+ // What follows a grade is not the grade: a hook that fails is said,
472
+ // and the recorded verdict stands.
473
+ try {
474
+ await opts.onGrade?.(g, info);
475
+ }
476
+ catch (err) {
477
+ errors.push(`${info.name} ${runId}: after grading — ${err instanceof Error ? err.message : String(err)}`);
478
+ }
479
+ }
480
+ return { graded, failed, errors };
481
+ }
@@ -8,6 +8,7 @@ export declare const JUDGE_VERDICT_INSTRUCTION = "Check each part against the te
8
8
  export declare function judgeVerdict(text: string): {
9
9
  passed: boolean;
10
10
  detail: string;
11
+ decided?: boolean;
11
12
  };
12
13
  /** `judge:` in AGENTS.md — the model and effort every judge in the workspace
13
14
  * grades with, unless an eval names its own. `model` is a tier or alias as
@@ -25,8 +25,10 @@ export function judgeVerdict(text) {
25
25
  reason = [first[2], ...lines.slice(1)].join(" ").trim();
26
26
  }
27
27
  }
28
+ // `decided: false` lets a grader tell "no verdict" from "FAIL": an eval
29
+ // case still fails on it, a live run's grade is left undecided.
28
30
  if (!verdict)
29
- return { passed: false, detail: `judge gave no PASS or FAIL: ${text.trim().slice(0, 300) || "nothing"}` };
31
+ return { passed: false, decided: false, detail: `judge gave no PASS or FAIL: ${text.trim().slice(0, 300) || "nothing"}` };
30
32
  // The end of the reasoning is the part that decided it.
31
33
  const why = reason.length > 400 ? `…${reason.slice(-400)}` : reason;
32
34
  return { passed: verdict === "PASS", detail: `${verdict}${why ? ` — ${why}` : ""}` };
@@ -1,5 +1,9 @@
1
1
  import { type ScanFile, type ScanFinding } from "./scan.ts";
2
2
  export declare const OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch";
3
+ /** What one online-check answer may be, read, through the platform's guard:
4
+ * an MCP server's own byte budget, so the guard's 1 MiB default never cuts
5
+ * an answer the budget allows. Pass it when wrapping platform.fetchUntrusted. */
6
+ export declare const ONLINE_MAX_BYTES: number;
3
7
  export interface Pin {
4
8
  ecosystem: "PyPI" | "npm";
5
9
  name: string;
@@ -25,6 +25,10 @@ const MAX_PINS = 500;
25
25
  const MCP_LIMITS = { pages: 10, tools: 300, text: 200_000, bytes: 2_000_000 };
26
26
  /** The MCP protocol version this client asks for (2025-06-18 spec). */
27
27
  const MCP_VERSION = "2025-06-18";
28
+ /** What one online-check answer may be, read, through the platform's guard:
29
+ * an MCP server's own byte budget, so the guard's 1 MiB default never cuts
30
+ * an answer the budget allows. Pass it when wrapping platform.fetchUntrusted. */
31
+ export const ONLINE_MAX_BYTES = MCP_LIMITS.bytes;
28
32
  const front = (content) => {
29
33
  try {
30
34
  return matter(content).data;
@@ -377,7 +381,7 @@ export async function mcpDescriptionFindings(files, opts = {}) {
377
381
  const i = opts.order?.indexOf(`mcp:${s.file}#${s.name}`) ?? -1;
378
382
  return i < 0 ? -1 : i;
379
383
  };
380
- const fetchImpl = opts.fetch ?? ((url, init) => platform.fetchUntrusted(url, init));
384
+ const fetchImpl = opts.fetch ?? ((url, init) => platform.fetchUntrusted(url, init, { maxBytes: ONLINE_MAX_BYTES }));
381
385
  const findings = [];
382
386
  const problems = [];
383
387
  const sources = [];
@@ -46,6 +46,10 @@ export declare function loadOpenApiDocument(tenant: string, workspaceRoot: strin
46
46
  doc: unknown | null;
47
47
  error: string | null;
48
48
  };
49
+ /** The most one `openapi:` document may be, read. Public specs run large
50
+ * (Stripe's is about 8 MB, GitHub's about 13 MB), far past
51
+ * fetchUntrusted's 1 MiB default. */
52
+ export declare const OPENAPI_MAX_BYTES: number;
49
53
  /**
50
54
  * Fill the cache for every https `openapi:` URL that is missing or older
51
55
  * than a day. Ten seconds per document; a failure is a warning line, and a
@@ -272,6 +272,10 @@ export function loadOpenApiDocument(tenant, workspaceRoot, source) {
272
272
  return { doc: null, error: `${source}: not JSON or YAML (${err.message})` };
273
273
  }
274
274
  }
275
+ /** The most one `openapi:` document may be, read. Public specs run large
276
+ * (Stripe's is about 8 MB, GitHub's about 13 MB), far past
277
+ * fetchUntrusted's 1 MiB default. */
278
+ export const OPENAPI_MAX_BYTES = 32 * 1024 * 1024;
275
279
  /**
276
280
  * Fill the cache for every https `openapi:` URL that is missing or older
277
281
  * than a day. Ten seconds per document; a failure is a warning line, and a
@@ -293,7 +297,7 @@ export async function prefetchOpenApi(tenant, sources) {
293
297
  const res = await fetchUntrusted(source, {
294
298
  signal: AbortSignal.timeout(10_000),
295
299
  headers: { accept: "application/json, application/yaml, text/yaml, */*" },
296
- });
300
+ }, { maxBytes: OPENAPI_MAX_BYTES });
297
301
  if (!res.ok)
298
302
  throw new Error(`HTTP ${res.status}`);
299
303
  const doc = parseDocumentText(await res.text());
@@ -14,7 +14,7 @@ export type TriggerAdmission = {
14
14
  admit: true;
15
15
  } | {
16
16
  admit: false;
17
- reason: "duplicate" | "throttled" | "quarantined" | "paused" | "halted" | "debounced";
17
+ reason: "duplicate" | "throttled" | "quarantined" | "paused" | "halted" | "debounced" | "filtered";
18
18
  detail: string;
19
19
  firesAt?: number;
20
20
  };
@@ -68,10 +68,15 @@ export interface PlatformHooks {
68
68
  * deliveries to remember; the local runner is the one asking, and it asked
69
69
  * because a person did.
70
70
  */
71
- admitTrigger(tenant: string, workspace: string, flow: FlowInfo, opts?: {
71
+ admitTrigger(tenant: string, workspace: string, flow: FlowInfo,
72
+ /** `fields`: what the flow's `filter:` is matched against — a webhook's
73
+ * JSON, an email, a chained run, a storage event. Absent: no filter
74
+ * applies (a schedule has nothing to match). */
75
+ opts?: {
72
76
  deliveryKey?: string | null;
73
77
  body?: string;
74
78
  tag?: string;
79
+ fields?: unknown;
75
80
  }): Promise<TriggerAdmission>;
76
81
  /** An admitted trigger whose start then threw. Undoes what admitTrigger
77
82
  * recorded — the delivery as seen, the throttle window — so the sender's
@@ -145,8 +150,12 @@ export interface PlatformHooks {
145
150
  * which is a laptop calling its own LAN on purpose. Core calls this only
146
151
  * through untrusted-fetch.ts, once per hop with `redirect: "manual"`, so
147
152
  * every redirect target is checked here like the first URL was.
153
+ * `maxBytes` is the most the answer may be, read (untrusted-fetch.ts
154
+ * caps it again, so a laptop's plain fetch may ignore it).
148
155
  */
149
- fetchUntrusted(url: string, init?: RequestInit): Promise<Response>;
156
+ fetchUntrusted(url: string, init?: RequestInit, opts?: {
157
+ maxBytes?: number;
158
+ }): Promise<Response>;
150
159
  /**
151
160
  * Hand a notify: webhook to whatever delivers it. The platform records
152
161
  * the delivery, makes the first attempt now and retries on a backoff from
@@ -11,6 +11,7 @@
11
11
  // install key. Core never imports the platform; the platform imports core
12
12
  // and calls registerPlatform() once at boot. A process that forgets to is a
13
13
  // local install — which is exactly the failure that is safe.
14
+ import { matchFilter } from "./trigger-filter.js";
14
15
  const local = {
15
16
  edition: () => "self-hosted",
16
17
  async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], startedBy = null, opts = {}) {
@@ -19,7 +20,14 @@ const local = {
19
20
  return startFlowRun(tenant, workspace, steps, flowName, modelOverride ?? null, tags, null, startedBy, opts);
20
21
  },
21
22
  async enqueueResume() { },
22
- admitTrigger: async () => ({ admit: true }),
23
+ // The flow's own `filter:` holds here too: a laptop has no fleet, but a
24
+ // delivery the flow says is not for it is still not for it.
25
+ admitTrigger: async (_tenant, _workspace, flow, opts) => {
26
+ if (flow.filter == null || opts?.fields === undefined)
27
+ return { admit: true };
28
+ const v = matchFilter(flow.filter, opts.fields);
29
+ return v.match ? { admit: true } : { admit: false, reason: "filtered", detail: `filter: ${v.reason} — no run started` };
30
+ },
23
31
  async rollbackTrigger() { },
24
32
  runSpend: (run) => run.steps.reduce((sum, s) => sum + (s.costUsd ?? 0), 0),
25
33
  isolation: {},
@@ -359,8 +359,12 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
359
359
  | \`/runs/<id>/message\` | POST | tell a running step something; \`step\` picks one of a parallel group |
360
360
  | \`/flows\` · \`/flows/<f>\` | GET POST · POST DELETE PATCH | list, create, edit |
361
361
  | \`/flows/<f>/run\` | POST | start a flow |
362
+ | \`/flows/<f>/filter\` | POST | would this delivery start it? its \`filter:\` against a payload — nothing starts |
362
363
  | \`/agents\` · \`/agents/<a>/run\` · \`/agents/import\` | GET POST · POST · GET POST | list agents, run one, copy one in from another workspace |
363
364
  | \`/evals\` · \`/evals/<e>/run\` | GET POST · POST | list evals, run one |
365
+ | \`/evals/<e>/compare\` | GET POST | its cases under two to four models, side by side — test runs, a spend limit |
366
+ | \`/evals/<e>/grades\` · \`/evals/<e>/grade\` | GET · POST | a grader's grades (\`trigger: runs\`); grade one finished run now |
367
+ | \`/evals/<e>/improve\` · \`/proposals\` · \`/proposals/<id>\` | POST · GET · GET POST | ask for a proposed fix; list them; apply or dismiss one |
364
368
  | \`/tools/<t>/test\` | POST | exercise one tool alone |
365
369
  | \`/storage\` · \`/storage/download\` · \`/storage/upload-url\` · \`/storage/preview\` | GET POST PUT DELETE · GET · POST · GET | workspace files, any type; \`preview\` returns one parsed for reading |
366
370
  | \`/assets\` | POST | upload an asset |
@@ -375,6 +379,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
375
379
  Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
376
380
  \`/api/approvals\`, \`/api/issues\` (what went wrong, open until resolved), \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
377
381
  \`/api/me/notifications\` (what mail you get; a key acts for whoever minted it),
382
+ \`/api/me/inbox\` (what waits on you: gates, questions, issues, proposed fixes, your failed runs),
378
383
  \`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
379
384
  \`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
380
385
  and \`/api/metrics\` (Prometheus).