@foldrun/core 2.10.1 → 2.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/catalog.d.ts +3 -0
- package/dist/src/catalog.js +4 -1
- package/dist/src/deploy.js +16 -0
- package/dist/src/eval-compare.d.ts +74 -0
- package/dist/src/eval-compare.js +187 -0
- package/dist/src/evals.d.ts +62 -3
- package/dist/src/evals.js +161 -36
- package/dist/src/flow-lint.js +4 -0
- package/dist/src/frontmatter-fields.js +4 -1
- package/dist/src/grades.d.ts +118 -0
- package/dist/src/grades.js +481 -0
- package/dist/src/judge-verdict.d.ts +1 -0
- package/dist/src/judge-verdict.js +3 -1
- package/dist/src/online-checks.d.ts +4 -0
- package/dist/src/online-checks.js +5 -1
- package/dist/src/openapi.d.ts +4 -0
- package/dist/src/openapi.js +5 -1
- package/dist/src/platform.d.ts +12 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/starter.js +5 -0
- package/dist/src/store.d.ts +8 -0
- package/dist/src/store.js +5 -1
- package/dist/src/tool-test.js +19 -7
- package/dist/src/translator.d.ts +4 -0
- package/dist/src/translator.js +22 -3
- package/dist/src/trigger-filter.d.ts +28 -0
- package/dist/src/trigger-filter.js +272 -0
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +2 -0
- package/dist/src/triggers.js +13 -2
- package/dist/src/untrusted-fetch.d.ts +16 -0
- package/dist/src/untrusted-fetch.js +60 -2
- package/index.ts +3 -0
- package/package.json +3 -3
- package/src/catalog.ts +5 -1
- package/src/deploy.ts +10 -0
- package/src/eval-compare.ts +243 -0
- package/src/evals.ts +195 -38
- package/src/flow-lint.ts +4 -0
- package/src/frontmatter-fields.ts +4 -1
- package/src/grades.ts +532 -0
- package/src/judge-verdict.ts +4 -2
- package/src/online-checks.ts +5 -1
- package/src/openapi.ts +6 -1
- package/src/platform.ts +16 -4
- package/src/starter.ts +5 -0
- package/src/store.ts +11 -1
- package/src/tool-test.ts +21 -7
- package/src/translator.ts +24 -3
- package/src/trigger-filter.ts +264 -0
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +14 -2
- package/src/untrusted-fetch.ts +65 -2
package/dist/src/evals.js
CHANGED
|
@@ -34,6 +34,7 @@ import { accountModelEnv, withModelEnv } from "./model-env.js";
|
|
|
34
34
|
import { sdkEnv } from "./step-exec.js";
|
|
35
35
|
import { platformModelEnv } from "./runner.js";
|
|
36
36
|
import fs from "node:fs";
|
|
37
|
+
import crypto from "node:crypto";
|
|
37
38
|
import path from "node:path";
|
|
38
39
|
import { deployedCommit } from "./deploy.js";
|
|
39
40
|
import { latestRevisionId } from "./history.js";
|
|
@@ -137,6 +138,45 @@ export function parseEval(file, raw, defaults = {}) {
|
|
|
137
138
|
}
|
|
138
139
|
if (current)
|
|
139
140
|
cases.push(current);
|
|
141
|
+
const trigger = data.trigger === "manual" ? "manual" : data.trigger === "runs" ? "runs" : "deploy";
|
|
142
|
+
const problems = [];
|
|
143
|
+
const sample = parseSample(data.sample, problems);
|
|
144
|
+
const improve = parseImprove(data.improve, problems);
|
|
145
|
+
if (trigger !== "runs") {
|
|
146
|
+
if (data.sample !== undefined)
|
|
147
|
+
problems.push("sample: only applies to an eval with trigger: runs — this one runs its cases");
|
|
148
|
+
if (data.improve !== undefined)
|
|
149
|
+
problems.push("improve: only applies to an eval with trigger: runs");
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
// A grader reads what a run said; it never starts one, so a task has
|
|
153
|
+
// nothing to go to, and file:/run: would execute or read on the
|
|
154
|
+
// platform's host rather than in the run's sandbox.
|
|
155
|
+
for (const c of cases) {
|
|
156
|
+
if (c.task)
|
|
157
|
+
problems.push(`## ${c.name}: task: does nothing in a trigger: runs eval — it grades real runs, so each check is only an expect: list`);
|
|
158
|
+
for (const a of c.expect) {
|
|
159
|
+
if (a.type === "file" || a.type === "run")
|
|
160
|
+
problems.push(`## ${c.name}: ${a.type}: cannot grade a finished run — use contains, not-contains or judge`);
|
|
161
|
+
// Graded on the platform's scheduler, over whatever a run said: a
|
|
162
|
+
// regular expression that backtracks would hold the scheduler with it.
|
|
163
|
+
if (a.type === "matches")
|
|
164
|
+
problems.push(`## ${c.name}: matches: is not used to grade live runs — a regular expression over any run's output can stall the platform; use contains, not-contains or judge`);
|
|
165
|
+
}
|
|
166
|
+
if (c.expect.length === 0)
|
|
167
|
+
problems.push(`## ${c.name}: has no expect: lines, so it grades nothing`);
|
|
168
|
+
}
|
|
169
|
+
if (cases.length === 0)
|
|
170
|
+
problems.push("a grader needs at least one check — a `## <name>` section with an expect: list; with none it would pass every run");
|
|
171
|
+
// The pass budgets graded runs; this bounds the judges each one costs.
|
|
172
|
+
const judges = cases.reduce((n, c) => n + c.expect.filter((x) => x.type === "judge").length, 0);
|
|
173
|
+
if (judges > MAX_GRADER_JUDGES)
|
|
174
|
+
problems.push(`${judges} judge: lines — a grader asks at most ${MAX_GRADER_JUDGES} judges per run; fold related claims into one`);
|
|
175
|
+
if (data.live === true)
|
|
176
|
+
problems.push("live: does nothing in a trigger: runs eval — it never starts a run");
|
|
177
|
+
if (data.inputs === true)
|
|
178
|
+
problems.push("inputs: and trigger: runs cannot go together");
|
|
179
|
+
}
|
|
140
180
|
return {
|
|
141
181
|
name: data.name ?? file.replace(/\.md$/, ""),
|
|
142
182
|
file,
|
|
@@ -144,12 +184,41 @@ export function parseEval(file, raw, defaults = {}) {
|
|
|
144
184
|
flow: typeof data.flow === "string" ? data.flow : null,
|
|
145
185
|
model: resolveModel(data.model ?? defaults.model ?? "fast"),
|
|
146
186
|
effort: resolveEffort(data.effort ?? defaults.effort ?? "low"),
|
|
147
|
-
trigger
|
|
187
|
+
trigger,
|
|
188
|
+
sample,
|
|
189
|
+
improve: trigger === "runs" ? improve : null,
|
|
190
|
+
problems,
|
|
148
191
|
live: data.live === true,
|
|
149
192
|
inputs: data.inputs === true,
|
|
150
193
|
cases,
|
|
151
194
|
};
|
|
152
195
|
}
|
|
196
|
+
/** Judges one graded run may cost — the grade pass counts runs, not calls. */
|
|
197
|
+
export const MAX_GRADER_JUDGES = 5;
|
|
198
|
+
/** `sample: 25`, `sample: "25%"` — percent of finished runs graded. */
|
|
199
|
+
function parseSample(v, problems) {
|
|
200
|
+
if (v === undefined || v === null)
|
|
201
|
+
return 25;
|
|
202
|
+
const n = typeof v === "number" ? v : typeof v === "string" ? Number(v.trim().replace(/%$/, "")) : NaN;
|
|
203
|
+
if (!Number.isFinite(n) || n < 0 || n > 100) {
|
|
204
|
+
problems.push(`sample: ${String(v)} — a percent from 0 to 100 (25 grades about one run in four; 0 grades none on its own)`);
|
|
205
|
+
return 25;
|
|
206
|
+
}
|
|
207
|
+
return Math.round(n * 100) / 100;
|
|
208
|
+
}
|
|
209
|
+
/** `improve: true` (5), `improve: 3`, `improve: false`. */
|
|
210
|
+
function parseImprove(v, problems) {
|
|
211
|
+
if (v === undefined || v === null || v === false)
|
|
212
|
+
return null;
|
|
213
|
+
if (v === true)
|
|
214
|
+
return 5;
|
|
215
|
+
const n = typeof v === "number" ? v : NaN;
|
|
216
|
+
if (!Number.isInteger(n) || n < 1 || n > 50) {
|
|
217
|
+
problems.push(`improve: ${String(v)} — true, or how many failed grades open a proposal (1 to 50)`);
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
return n;
|
|
221
|
+
}
|
|
153
222
|
export function listEvals(tenant, workspace) {
|
|
154
223
|
const dir = path.join(workspaceDir(tenant, workspace), "evals");
|
|
155
224
|
if (!fs.existsSync(dir))
|
|
@@ -158,7 +227,13 @@ export function listEvals(tenant, workspace) {
|
|
|
158
227
|
.readdirSync(dir)
|
|
159
228
|
.filter((f) => f.endsWith(".md"))
|
|
160
229
|
.sort()
|
|
161
|
-
.map((f) =>
|
|
230
|
+
.map((f) => {
|
|
231
|
+
const raw = fs.readFileSync(path.join(dir, f), "utf8");
|
|
232
|
+
// The version is taken from the same read the eval is parsed from, so
|
|
233
|
+
// a grade names the rubric that produced it even if the file changes
|
|
234
|
+
// while its judges answer.
|
|
235
|
+
return { ...parseEval(f, raw, judgeDefaults(tenant, workspace)), revision: crypto.createHash("sha256").update(raw).digest("hex").slice(0, 12) };
|
|
236
|
+
});
|
|
162
237
|
}
|
|
163
238
|
/** `judge:` from AGENTS.md, field by field: the workspace's model (or
|
|
164
239
|
* effort), else the account's — so a workspace that sets only an effort
|
|
@@ -386,32 +461,59 @@ function shell(command, cwd) {
|
|
|
386
461
|
// LAST, after the reasoning: asked for it first, the judge committed before
|
|
387
462
|
// it had looked, then argued the other way — "FAIL … which meets the
|
|
388
463
|
// criterion" (vaza evals, 21 Oct 2026).
|
|
389
|
-
|
|
464
|
+
/** How long one judge may take before it is cut off: a verdict on one
|
|
465
|
+
* sentence is seconds; a stalled model must not hold a grade pass. */
|
|
466
|
+
export const JUDGE_TIMEOUT_MS = 120_000;
|
|
467
|
+
/** One judge call: its words and what it cost (the SDK's result line). */
|
|
468
|
+
export async function judgeCall(rubric, output, model, effort, env,
|
|
469
|
+
/** What the output was an answer to — a live run's task, so the judge
|
|
470
|
+
* grades the reply against what was asked rather than in a vacuum. */
|
|
471
|
+
context) {
|
|
390
472
|
const prompt = `You are grading one piece of output against one criterion. ${JUDGE_VERDICT_INSTRUCTION}\n\n` +
|
|
391
|
-
`Criterion: ${rubric}\n\n
|
|
473
|
+
`Criterion: ${rubric}\n\n` +
|
|
474
|
+
(context ? `<task>\n${context.slice(0, 4000)}\n</task>\n\n` : "") +
|
|
475
|
+
`<output>\n${output.slice(0, 20000)}\n</output>`;
|
|
392
476
|
let text = "";
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
477
|
+
let costUsd = 0;
|
|
478
|
+
const abort = new AbortController();
|
|
479
|
+
const timer = setTimeout(() => abort.abort(), JUDGE_TIMEOUT_MS);
|
|
480
|
+
timer.unref?.();
|
|
481
|
+
try {
|
|
482
|
+
const q = query({
|
|
483
|
+
prompt,
|
|
484
|
+
options: {
|
|
485
|
+
model,
|
|
486
|
+
...(effort ? { effort } : {}),
|
|
487
|
+
tools: [],
|
|
488
|
+
allowedTools: [],
|
|
489
|
+
settingSources: [],
|
|
490
|
+
strictMcpConfig: true,
|
|
491
|
+
env,
|
|
492
|
+
maxTurns: 1,
|
|
493
|
+
abortController: abort,
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
for await (const message of q) {
|
|
497
|
+
if (message.type === "assistant") {
|
|
498
|
+
for (const block of message.message.content) {
|
|
499
|
+
if (block.type === "text")
|
|
500
|
+
text += block.text;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
else if (message.type === "result") {
|
|
504
|
+
costUsd = Number(message.total_cost_usd) || 0;
|
|
411
505
|
}
|
|
412
506
|
}
|
|
413
507
|
}
|
|
414
|
-
|
|
508
|
+
catch (err) {
|
|
509
|
+
if (abort.signal.aborted)
|
|
510
|
+
throw new Error(`the judge took longer than ${JUDGE_TIMEOUT_MS / 1000}s and was stopped`);
|
|
511
|
+
throw err;
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
clearTimeout(timer);
|
|
515
|
+
}
|
|
516
|
+
return { text: text.trim(), costUsd };
|
|
415
517
|
}
|
|
416
518
|
/**
|
|
417
519
|
* The judge's environment: this process's, with the platform's model
|
|
@@ -425,7 +527,7 @@ export function judgeProcessEnv() {
|
|
|
425
527
|
const live = Object.fromEntries(Object.entries(platformModelEnv()).filter(([, v]) => v !== undefined));
|
|
426
528
|
return sdkEnv({ ...process.env, ...live });
|
|
427
529
|
}
|
|
428
|
-
async function checkAssertion(assertion, output, agentDir, model, effort, judgeEnv = () => ({ env: judgeProcessEnv() })) {
|
|
530
|
+
export async function checkAssertion(assertion, output, agentDir, model, effort, judgeEnv = () => ({ env: judgeProcessEnv() })) {
|
|
429
531
|
const hay = output.toLowerCase();
|
|
430
532
|
switch (assertion.type) {
|
|
431
533
|
case "contains": {
|
|
@@ -467,13 +569,16 @@ async function checkAssertion(assertion, output, agentDir, model, effort, judgeE
|
|
|
467
569
|
catch (err) {
|
|
468
570
|
return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
|
|
469
571
|
}
|
|
470
|
-
const
|
|
471
|
-
const { passed, detail } = judgeVerdict(text);
|
|
472
|
-
return { assertion, passed, detail };
|
|
572
|
+
const said = await withModelEnv(m, (env) => judgeCall(assertion.value, output, model, effort, env));
|
|
573
|
+
const { passed, detail } = judgeVerdict(said.text);
|
|
574
|
+
return { assertion, passed, detail, costUsd: said.costUsd };
|
|
473
575
|
}
|
|
474
576
|
}
|
|
475
577
|
}
|
|
476
|
-
export async function runEval(tenant, workspace, info) {
|
|
578
|
+
export async function runEval(tenant, workspace, info, opts = {}) {
|
|
579
|
+
if (info.trigger === "runs") {
|
|
580
|
+
throw refuse(`${info.name} grades real runs (trigger: runs) — it has no cases to run; grade a run with POST …/evals/${info.name}/grade { run }`, 400);
|
|
581
|
+
}
|
|
477
582
|
const startedAt = new Date().toISOString();
|
|
478
583
|
const results = [];
|
|
479
584
|
// Saved inputs carry no assertions: running them would start and bill a
|
|
@@ -485,6 +590,16 @@ export async function runEval(tenant, workspace, info) {
|
|
|
485
590
|
let output = "";
|
|
486
591
|
let error = null;
|
|
487
592
|
let costUsd = 0;
|
|
593
|
+
let secs = null;
|
|
594
|
+
const stop = opts.beforeCase?.() ?? null;
|
|
595
|
+
if (stop) {
|
|
596
|
+
results.push({ name: testCase.name, task: testCase.task, runId: null, error: `not run — ${stop}`, passed: false, assertions: [], costUsd: 0, judgeCostUsd: 0, secs: null });
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
// A step forced onto the comparison's model: step-level, so it wins.
|
|
600
|
+
const force = (st) => opts.model && (!opts.agent || st.agent === opts.agent)
|
|
601
|
+
? { ...st, model: opts.model, ...(opts.effort ? { effort: opts.effort } : {}) }
|
|
602
|
+
: st;
|
|
488
603
|
try {
|
|
489
604
|
let run;
|
|
490
605
|
if (info.flow) {
|
|
@@ -492,27 +607,33 @@ export async function runEval(tenant, workspace, info) {
|
|
|
492
607
|
if (!flow || flow.steps.length === 0)
|
|
493
608
|
throw new Error(`flow ${info.flow} not found or empty`);
|
|
494
609
|
// The case's task reaches the first step; output passing carries it on.
|
|
495
|
-
const steps = flow.steps.map((s, i) => i === 0
|
|
610
|
+
const steps = flow.steps.map((s, i) => force(i === 0
|
|
496
611
|
? { ...s, instruction: `${s.instruction}\n\n<run_task>\n${testCase.task}\n</run_task>` }
|
|
497
|
-
: s);
|
|
612
|
+
: s));
|
|
498
613
|
// Test unless the eval OR the flow says live: an eval that runs a
|
|
499
|
-
// flow written `live: true` was written by someone who knew.
|
|
500
|
-
|
|
614
|
+
// flow written `live: true` was written by someone who knew. A
|
|
615
|
+
// comparison is a test whatever they say.
|
|
616
|
+
run = await beginEvalRun(tenant, workspace, steps, `eval:${info.name}`, flow.model, flow.effort, opts.forceTest ? true : !(info.live || flow.live));
|
|
501
617
|
}
|
|
502
618
|
else {
|
|
503
619
|
if (!target)
|
|
504
620
|
throw new Error("this eval names neither an agent nor a flow");
|
|
505
|
-
run = await beginEvalRun(tenant, workspace, [{ agent: target, instruction: testCase.task, group: 1, optional: false }], `eval:${info.name}`, undefined, undefined, !info.live);
|
|
621
|
+
run = await beginEvalRun(tenant, workspace, [force({ agent: target, instruction: testCase.task, group: 1, optional: false })], `eval:${info.name}`, undefined, undefined, opts.forceTest ? true : !info.live);
|
|
506
622
|
}
|
|
507
623
|
runId = run.id;
|
|
508
624
|
const finished = await waitForRun(tenant, workspace, run.id);
|
|
509
625
|
if (!finished)
|
|
510
626
|
throw new Error("run disappeared");
|
|
627
|
+
// What the steps before a gate spent is spent: counted before the
|
|
628
|
+
// gate is refused, so a comparison's limit sees it.
|
|
629
|
+
costUsd = finished.steps.reduce((sum, s) => sum + (s.costUsd ?? 0), 0);
|
|
511
630
|
if (finished.status === "awaiting-approval") {
|
|
512
631
|
throw new Error("this run pauses for approval — an eval cannot answer a human gate");
|
|
513
632
|
}
|
|
514
633
|
output = finished.steps.map((s) => s.result ?? "").join("\n\n");
|
|
515
|
-
|
|
634
|
+
const from = Date.parse(finished.drivenAt ?? finished.startedAt);
|
|
635
|
+
const to = finished.finishedAt ? Date.parse(finished.finishedAt) : NaN;
|
|
636
|
+
secs = Number.isFinite(from) && Number.isFinite(to) ? Math.max(0, Math.round((to - from) / 100) / 10) : null;
|
|
516
637
|
if (finished.status === "failed" && !output.trim()) {
|
|
517
638
|
// Quote the run's own last error. "Produced nothing" on a fresh
|
|
518
639
|
// workspace is almost always "secret X is not set" or a tool that
|
|
@@ -556,7 +677,10 @@ export async function runEval(tenant, workspace, info) {
|
|
|
556
677
|
passed: !error && assertions.length > 0 && assertions.every((r) => r.passed),
|
|
557
678
|
assertions,
|
|
558
679
|
costUsd,
|
|
680
|
+
judgeCostUsd: Math.round(assertions.reduce((n, a) => n + (a.costUsd ?? 0), 0) * 1e6) / 1e6,
|
|
681
|
+
secs,
|
|
559
682
|
});
|
|
683
|
+
opts.afterCase?.(results[results.length - 1]);
|
|
560
684
|
}
|
|
561
685
|
return {
|
|
562
686
|
eval: info.name,
|
|
@@ -661,8 +785,9 @@ commit = null) {
|
|
|
661
785
|
if (!stamp)
|
|
662
786
|
return { ran: 0, skipped: "no revision to stamp the result with" };
|
|
663
787
|
// Only the evals that asked to run on deploy — a `trigger: manual` eval is
|
|
664
|
-
// one a person runs on purpose, because it costs a whole flow run
|
|
665
|
-
|
|
788
|
+
// one a person runs on purpose, because it costs a whole flow run, and a
|
|
789
|
+
// `trigger: runs` one grades real runs and has no cases to run.
|
|
790
|
+
const evals = listEvals(tenant, workspace).filter((e) => e.trigger === "deploy" && !e.inputs);
|
|
666
791
|
if (evals.length === 0)
|
|
667
792
|
return { ran: 0, skipped: "no evals" };
|
|
668
793
|
const dir = path.join(workspaceDir(tenant, workspace), "evals", ".results");
|
package/dist/src/flow-lint.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// blocked — flows still run, warnings are advisory.
|
|
13
13
|
import { whenRowsPath } from "./store.js";
|
|
14
14
|
import { timezoneProblem } from "./clock.js";
|
|
15
|
+
import { FILTERABLE_TRIGGERS } from "./trigger-filter.js";
|
|
15
16
|
/** What a `[[flow:x]]` step's options are called in a flow file, for the
|
|
16
17
|
* ones expandSubflows does not carry into the nested steps. */
|
|
17
18
|
const SUBFLOW_DROPPED = [
|
|
@@ -209,6 +210,9 @@ export function lintFlow(flow, known) {
|
|
|
209
210
|
if (flow.signature && !flow.signingSecret) {
|
|
210
211
|
warnings.push({ step: null, message: `signature: ${flow.signature} needs \`signing_secret: \${NAME}\``, detail: "Every delivery will be refused until the secret is named and set in Settings → Secrets." });
|
|
211
212
|
}
|
|
213
|
+
if (flow.filter != null && !FILTERABLE_TRIGGERS.includes(flow.trigger)) {
|
|
214
|
+
warnings.push({ step: null, message: `filter: only applies to a webhook, email, flow or storage trigger`, detail: `trigger: ${flow.trigger} delivers no payload to match, so the filter does nothing here.` });
|
|
215
|
+
}
|
|
212
216
|
if (flow.signature && flow.trigger !== "webhook") {
|
|
213
217
|
warnings.push({ step: null, message: "signature: only applies to trigger: webhook", detail: "Inbox and other triggers do not carry a provider signature; the line does nothing here." });
|
|
214
218
|
}
|
|
@@ -113,6 +113,7 @@ const FLOW = [
|
|
|
113
113
|
{ key: "url", type: "string", help: "trigger: watch — the page watched" },
|
|
114
114
|
{ key: "every", type: ["string", "number"], help: "trigger: watch — how often" },
|
|
115
115
|
{ key: "path", type: "string", help: "trigger: storage — the prefix under storage/" },
|
|
116
|
+
{ key: "filter", type: "object", validated: true, help: "webhook, email, flow or storage trigger: the fields a delivery must have to start a run — e.g. {action: [opened], sender: {type: {not_in: [Bot]}}}" },
|
|
116
117
|
{ key: "idempotency", type: "string", help: "a header name, or body:<field>, that marks a repeat delivery" },
|
|
117
118
|
{ key: "debounce", type: ["string", "number"], help: "wait for a burst to end" },
|
|
118
119
|
{ key: "throttle", type: ["string", "number"], help: "at most one run per window" },
|
|
@@ -166,7 +167,9 @@ const EVAL = [
|
|
|
166
167
|
{ key: "flow", type: "string", help: "or the flow" },
|
|
167
168
|
{ key: "model", type: "string", help: "the judge's model" },
|
|
168
169
|
{ key: "effort", type: "string", values: EFFORT_LEVELS, help: "the judge's effort" },
|
|
169
|
-
{ key: "trigger", type: "string", values: ["deploy", "manual"], help: "deploy (default) | manual
|
|
170
|
+
{ key: "trigger", type: "string", values: ["deploy", "manual", "runs"], help: "deploy (default) | manual | runs — runs: grade real finished runs of the agent or flow instead of running cases" },
|
|
171
|
+
{ key: "sample", type: ["number", "string"], validated: true, help: "trigger: runs — the share of finished runs graded, 0 to 100 percent (default 25)" },
|
|
172
|
+
{ key: "improve", type: ["boolean", "number"], validated: true, help: "trigger: runs — true (or a count, default 5): propose a fix once that many graded runs failed" },
|
|
170
173
|
{ key: "live", type: "boolean", help: "true: real calls" },
|
|
171
174
|
{ key: "inputs", type: "boolean", help: "true: cases carry inputs" },
|
|
172
175
|
];
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { type RunRecord } from "./store.ts";
|
|
2
|
+
import { judgeCall, type EvalInfo, type Assertion } from "./evals.ts";
|
|
3
|
+
/** How far back the pass looks for runs to grade. */
|
|
4
|
+
export declare const GRADE_WINDOW_MS: number;
|
|
5
|
+
/** How long grades are kept, and how many per eval. */
|
|
6
|
+
export declare const GRADE_KEEP_MS: number;
|
|
7
|
+
export declare const GRADE_KEEP_LINES = 5000;
|
|
8
|
+
export interface GradeAssertion {
|
|
9
|
+
type: Assertion["type"];
|
|
10
|
+
value: string;
|
|
11
|
+
passed: boolean;
|
|
12
|
+
detail: string;
|
|
13
|
+
}
|
|
14
|
+
export interface GradeCheck {
|
|
15
|
+
/** The `## heading` of the check. */
|
|
16
|
+
name: string;
|
|
17
|
+
passed: boolean;
|
|
18
|
+
/** Set when the check could not be decided — the judge failed to answer.
|
|
19
|
+
* An error is not a failure: nobody's work was found wanting. */
|
|
20
|
+
error?: string;
|
|
21
|
+
assertions: GradeAssertion[];
|
|
22
|
+
}
|
|
23
|
+
export interface Grade {
|
|
24
|
+
eval: string;
|
|
25
|
+
runId: string;
|
|
26
|
+
/** The run's flow (`adhoc:<agent>` for a direct agent run). */
|
|
27
|
+
flow: string;
|
|
28
|
+
at: string;
|
|
29
|
+
/** pass: every check passed. fail: at least one failed. error: none
|
|
30
|
+
* failed but at least one could not be decided. */
|
|
31
|
+
status: "pass" | "fail" | "error";
|
|
32
|
+
checks: GradeCheck[];
|
|
33
|
+
/** What the judges cost, USD — the run's own cost is on the run. */
|
|
34
|
+
costUsd: number;
|
|
35
|
+
/** The judge's model. */
|
|
36
|
+
model: string;
|
|
37
|
+
/** Which version of the grader graded it (a hash of the eval file), and
|
|
38
|
+
* which version of the workspace the run ran (RunRecord.revision). */
|
|
39
|
+
evalRevision: string;
|
|
40
|
+
runRevision: string | null;
|
|
41
|
+
runFinishedAt: string | null;
|
|
42
|
+
/** `sample` when the pass picked it; a person's email (or key label)
|
|
43
|
+
* when someone asked for it. */
|
|
44
|
+
by: string;
|
|
45
|
+
}
|
|
46
|
+
/** The graders of a workspace: its `trigger: runs` evals that can be read. */
|
|
47
|
+
export declare function listGraders(tenant: string, workspace: string): EvalInfo[];
|
|
48
|
+
/** The version of a grader, as its file reads now. */
|
|
49
|
+
export declare function evalRevision(tenant: string, workspace: string, info: EvalInfo): string;
|
|
50
|
+
/** Is this run in this grader's sample? Stable: a hash, not a coin. */
|
|
51
|
+
export declare function sampled(runId: string, evalName: string, percent: number): boolean;
|
|
52
|
+
/** The text a grader grades: every step's reply for a flow, the agent's
|
|
53
|
+
* replies for an agent. Null when the run has nothing of it. */
|
|
54
|
+
export declare function gradedOutput(info: EvalInfo, run: RunRecord): string | null;
|
|
55
|
+
/** Grade one finished run. Writes nothing; `recordGrade` keeps it. */
|
|
56
|
+
export declare function gradeRun(tenant: string, workspace: string, info: EvalInfo, run: RunRecord, opts?: {
|
|
57
|
+
by?: string;
|
|
58
|
+
now?: number;
|
|
59
|
+
judge?: typeof judgeCall;
|
|
60
|
+
}): Promise<Grade>;
|
|
61
|
+
/** Keep a grade: one line, appended. */
|
|
62
|
+
export declare function recordGrade(tenant: string, workspace: string, grade: Grade): void;
|
|
63
|
+
/** A grader's grades, newest first — one per run, the newest line for it. */
|
|
64
|
+
export declare function readGrades(tenant: string, workspace: string, name: string, opts?: {
|
|
65
|
+
sinceMs?: number;
|
|
66
|
+
}): Grade[];
|
|
67
|
+
/** The grades of one run, across every grader of the workspace. */
|
|
68
|
+
export declare function gradesOfRun(tenant: string, workspace: string, runId: string): Grade[];
|
|
69
|
+
export interface GradeSummary {
|
|
70
|
+
graded: number;
|
|
71
|
+
passed: number;
|
|
72
|
+
failed: number;
|
|
73
|
+
errors: number;
|
|
74
|
+
/** passed / (passed + failed); null when nothing was decided. */
|
|
75
|
+
rate: number | null;
|
|
76
|
+
costUsd: number;
|
|
77
|
+
/** One row per UTC day that has grades, oldest first. */
|
|
78
|
+
byDay: {
|
|
79
|
+
day: string;
|
|
80
|
+
graded: number;
|
|
81
|
+
passed: number;
|
|
82
|
+
failed: number;
|
|
83
|
+
}[];
|
|
84
|
+
}
|
|
85
|
+
export declare function summariseGrades(grades: Grade[]): GradeSummary;
|
|
86
|
+
/** When the pass first saw this grader — runs that finished before it are
|
|
87
|
+
* never sampled, so a new grader starts from now rather than back-filling. */
|
|
88
|
+
export declare function graderSince(tenant: string, workspace: string, name: string, now?: number): number;
|
|
89
|
+
/** When a grader last had a run graded by the pass (0: never) — the pass
|
|
90
|
+
* serves the longest-waiting grader first, so fairness holds across passes. */
|
|
91
|
+
export declare function graderLastServed(tenant: string, workspace: string, name: string): number;
|
|
92
|
+
/** How long an undecided grade (the judge did not answer — a spent usage
|
|
93
|
+
* cap, a provider outage) waits before the pass tries the run again, and
|
|
94
|
+
* how many tries it gets. */
|
|
95
|
+
export declare const GRADE_RETRY_MS = 3600000;
|
|
96
|
+
export declare const GRADE_TRIES = 3;
|
|
97
|
+
/** The runs the pass should grade for one grader, oldest first. */
|
|
98
|
+
export declare function gradeCandidates(tenant: string, workspace: string, info: EvalInfo, now?: number): string[];
|
|
99
|
+
/** Drop grades past the keeping window or the line cap. Rewritten whole
|
|
100
|
+
* (temp, then rename); only the pass prunes. */
|
|
101
|
+
export declare function pruneGrades(tenant: string, workspace: string, name: string, now?: number): number;
|
|
102
|
+
/**
|
|
103
|
+
* Grade what is due in one workspace: for each grader, the sampled runs that
|
|
104
|
+
* finished since it was first seen and are not graded yet. At most `limit`
|
|
105
|
+
* judges' worth of runs; `keepGoing` is asked before each run, so a pass
|
|
106
|
+
* that lost its lease stops between runs, never mid-grade.
|
|
107
|
+
*/
|
|
108
|
+
export declare function gradeWorkspace(tenant: string, workspace: string, opts?: {
|
|
109
|
+
now?: number;
|
|
110
|
+
limit?: number;
|
|
111
|
+
keepGoing?: () => Promise<boolean> | boolean;
|
|
112
|
+
onGrade?: (g: Grade, info: EvalInfo) => void | Promise<void>;
|
|
113
|
+
judge?: typeof judgeCall;
|
|
114
|
+
}): Promise<{
|
|
115
|
+
graded: number;
|
|
116
|
+
failed: number;
|
|
117
|
+
errors: string[];
|
|
118
|
+
}>;
|