@foldrun/core 2.10.0 → 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.
- 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/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +113 -0
- package/dist/src/gitrepo.js +404 -0
- 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/providers.js +8 -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/tar.d.ts +30 -0
- package/dist/src/tar.js +112 -0
- 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 +2 -2
- 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/providers.ts +8 -1
- 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/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
|
+
}
|
package/src/judge-verdict.ts
CHANGED
|
@@ -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
|
-
|
|
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}` : ""}` };
|
package/src/online-checks.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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/providers.ts
CHANGED
|
@@ -1302,7 +1302,14 @@ export function resolveSearch(name: unknown, kind: "search" | "fetch" | "browse"
|
|
|
1302
1302
|
if (other) {
|
|
1303
1303
|
return { provider: null, error: `web.fetch: ${key} — ${other.title} searches but has no fetch here. The fetch APIs: ${FETCH_APIS.map((a) => a.name).join(", ")}; or anthropic.` };
|
|
1304
1304
|
}
|
|
1305
|
-
|
|
1305
|
+
// `anthropic` handed fetch to Claude Code's WebFetch, which is not
|
|
1306
|
+
// Anthropic's server-side tool: it fetches from the sandbox after a domain
|
|
1307
|
+
// check that fails behind the egress proxy ("Unable to verify if domain …
|
|
1308
|
+
// is safe to fetch"), so every fetch by a step in a run pod was refused
|
|
1309
|
+
// (validators on four desks, Oct 2026). The name is still accepted, and
|
|
1310
|
+
// our own fetch answers: through the proxy, on the run record, like every
|
|
1311
|
+
// other read (owner, 23 Oct 2026).
|
|
1312
|
+
if (key === "anthropic") return { provider: null, index: "foldrun's own fetch (web.fetch: anthropic is answered by it)" };
|
|
1306
1313
|
const preset = PROVIDERS.find((p) => p.name === key);
|
|
1307
1314
|
return {
|
|
1308
1315
|
provider: null,
|