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