@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/src/evals.ts
CHANGED
|
@@ -36,6 +36,7 @@ import type { TranslatorSpec } from "./translator.ts";
|
|
|
36
36
|
import { sdkEnv } from "./step-exec.ts";
|
|
37
37
|
import { platformModelEnv } from "./runner.ts";
|
|
38
38
|
import fs from "node:fs";
|
|
39
|
+
import crypto from "node:crypto";
|
|
39
40
|
import path from "node:path";
|
|
40
41
|
import { deployedCommit } from "./deploy.ts";
|
|
41
42
|
import { latestRevisionId } from "./history.ts";
|
|
@@ -86,8 +87,20 @@ export interface EvalInfo {
|
|
|
86
87
|
effort: Effort | null;
|
|
87
88
|
/** When it runs. `deploy` (default): after every push, with the rest.
|
|
88
89
|
* `manual`: only when a person presses Run or the CLI asks — for a flow
|
|
89
|
-
* eval that costs a whole run and touches real systems every time.
|
|
90
|
-
|
|
90
|
+
* eval that costs a whole run and touches real systems every time.
|
|
91
|
+
* `runs`: never run as cases — it grades real finished runs of its agent
|
|
92
|
+
* or flow (grades.ts), a `sample` of them, as they finish. */
|
|
93
|
+
trigger: "deploy" | "manual" | "runs";
|
|
94
|
+
/** trigger: runs — the percent of finished runs graded, 0–100. */
|
|
95
|
+
sample: number;
|
|
96
|
+
/** trigger: runs — propose a fix (improve.ts) once this many graded runs
|
|
97
|
+
* failed and no person has looked yet. Null: never on its own. */
|
|
98
|
+
improve: number | null;
|
|
99
|
+
/** What in the file cannot be read as written, one sentence each — the
|
|
100
|
+
* deploy's checks and `foldrun check` report them. */
|
|
101
|
+
problems: string[];
|
|
102
|
+
/** A hash of the file as it was read (listEvals), when it was. */
|
|
103
|
+
revision?: string;
|
|
91
104
|
/** An eval's runs are test runs — nothing outward, state untouched —
|
|
92
105
|
* unless the file says `live: true`. A flow eval that must really send
|
|
93
106
|
* to prove itself opts in; nothing opts in by accident. */
|
|
@@ -198,6 +211,35 @@ export function parseEval(file: string, raw: string, defaults: { model?: string;
|
|
|
198
211
|
}
|
|
199
212
|
if (current) cases.push(current);
|
|
200
213
|
|
|
214
|
+
const trigger: EvalInfo["trigger"] = data.trigger === "manual" ? "manual" : data.trigger === "runs" ? "runs" : "deploy";
|
|
215
|
+
const problems: string[] = [];
|
|
216
|
+
const sample = parseSample(data.sample, problems);
|
|
217
|
+
const improve = parseImprove(data.improve, problems);
|
|
218
|
+
if (trigger !== "runs") {
|
|
219
|
+
if (data.sample !== undefined) problems.push("sample: only applies to an eval with trigger: runs — this one runs its cases");
|
|
220
|
+
if (data.improve !== undefined) problems.push("improve: only applies to an eval with trigger: runs");
|
|
221
|
+
} else {
|
|
222
|
+
// A grader reads what a run said; it never starts one, so a task has
|
|
223
|
+
// nothing to go to, and file:/run: would execute or read on the
|
|
224
|
+
// platform's host rather than in the run's sandbox.
|
|
225
|
+
for (const c of cases) {
|
|
226
|
+
if (c.task) problems.push(`## ${c.name}: task: does nothing in a trigger: runs eval — it grades real runs, so each check is only an expect: list`);
|
|
227
|
+
for (const a of c.expect) {
|
|
228
|
+
if (a.type === "file" || a.type === "run") problems.push(`## ${c.name}: ${a.type}: cannot grade a finished run — use contains, not-contains or judge`);
|
|
229
|
+
// Graded on the platform's scheduler, over whatever a run said: a
|
|
230
|
+
// regular expression that backtracks would hold the scheduler with it.
|
|
231
|
+
if (a.type === "matches") 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`);
|
|
232
|
+
}
|
|
233
|
+
if (c.expect.length === 0) problems.push(`## ${c.name}: has no expect: lines, so it grades nothing`);
|
|
234
|
+
}
|
|
235
|
+
if (cases.length === 0) problems.push("a grader needs at least one check — a `## <name>` section with an expect: list; with none it would pass every run");
|
|
236
|
+
// The pass budgets graded runs; this bounds the judges each one costs.
|
|
237
|
+
const judges = cases.reduce((n, c) => n + c.expect.filter((x) => x.type === "judge").length, 0);
|
|
238
|
+
if (judges > MAX_GRADER_JUDGES) problems.push(`${judges} judge: lines — a grader asks at most ${MAX_GRADER_JUDGES} judges per run; fold related claims into one`);
|
|
239
|
+
if (data.live === true) problems.push("live: does nothing in a trigger: runs eval — it never starts a run");
|
|
240
|
+
if (data.inputs === true) problems.push("inputs: and trigger: runs cannot go together");
|
|
241
|
+
}
|
|
242
|
+
|
|
201
243
|
return {
|
|
202
244
|
name: data.name ?? file.replace(/\.md$/, ""),
|
|
203
245
|
file,
|
|
@@ -205,13 +247,42 @@ export function parseEval(file: string, raw: string, defaults: { model?: string;
|
|
|
205
247
|
flow: typeof data.flow === "string" ? data.flow : null,
|
|
206
248
|
model: resolveModel(data.model ?? defaults.model ?? "fast"),
|
|
207
249
|
effort: resolveEffort(data.effort ?? defaults.effort ?? "low"),
|
|
208
|
-
trigger
|
|
250
|
+
trigger,
|
|
251
|
+
sample,
|
|
252
|
+
improve: trigger === "runs" ? improve : null,
|
|
253
|
+
problems,
|
|
209
254
|
live: data.live === true,
|
|
210
255
|
inputs: data.inputs === true,
|
|
211
256
|
cases,
|
|
212
257
|
};
|
|
213
258
|
}
|
|
214
259
|
|
|
260
|
+
/** Judges one graded run may cost — the grade pass counts runs, not calls. */
|
|
261
|
+
export const MAX_GRADER_JUDGES = 5;
|
|
262
|
+
|
|
263
|
+
/** `sample: 25`, `sample: "25%"` — percent of finished runs graded. */
|
|
264
|
+
function parseSample(v: unknown, problems: string[]): number {
|
|
265
|
+
if (v === undefined || v === null) return 25;
|
|
266
|
+
const n = typeof v === "number" ? v : typeof v === "string" ? Number(v.trim().replace(/%$/, "")) : NaN;
|
|
267
|
+
if (!Number.isFinite(n) || n < 0 || n > 100) {
|
|
268
|
+
problems.push(`sample: ${String(v)} — a percent from 0 to 100 (25 grades about one run in four; 0 grades none on its own)`);
|
|
269
|
+
return 25;
|
|
270
|
+
}
|
|
271
|
+
return Math.round(n * 100) / 100;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** `improve: true` (5), `improve: 3`, `improve: false`. */
|
|
275
|
+
function parseImprove(v: unknown, problems: string[]): number | null {
|
|
276
|
+
if (v === undefined || v === null || v === false) return null;
|
|
277
|
+
if (v === true) return 5;
|
|
278
|
+
const n = typeof v === "number" ? v : NaN;
|
|
279
|
+
if (!Number.isInteger(n) || n < 1 || n > 50) {
|
|
280
|
+
problems.push(`improve: ${String(v)} — true, or how many failed grades open a proposal (1 to 50)`);
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
return n;
|
|
284
|
+
}
|
|
285
|
+
|
|
215
286
|
export function listEvals(tenant: string, workspace: string): EvalInfo[] {
|
|
216
287
|
const dir = path.join(workspaceDir(tenant, workspace), "evals");
|
|
217
288
|
if (!fs.existsSync(dir)) return [];
|
|
@@ -219,7 +290,13 @@ export function listEvals(tenant: string, workspace: string): EvalInfo[] {
|
|
|
219
290
|
.readdirSync(dir)
|
|
220
291
|
.filter((f) => f.endsWith(".md"))
|
|
221
292
|
.sort()
|
|
222
|
-
.map((f) =>
|
|
293
|
+
.map((f) => {
|
|
294
|
+
const raw = fs.readFileSync(path.join(dir, f), "utf8");
|
|
295
|
+
// The version is taken from the same read the eval is parsed from, so
|
|
296
|
+
// a grade names the rubric that produced it even if the file changes
|
|
297
|
+
// while its judges answer.
|
|
298
|
+
return { ...parseEval(f, raw, judgeDefaults(tenant, workspace)), revision: crypto.createHash("sha256").update(raw).digest("hex").slice(0, 12) };
|
|
299
|
+
});
|
|
223
300
|
}
|
|
224
301
|
|
|
225
302
|
/** `judge:` from AGENTS.md, field by field: the workspace's model (or
|
|
@@ -406,6 +483,8 @@ export interface AssertionResult {
|
|
|
406
483
|
assertion: Assertion;
|
|
407
484
|
passed: boolean;
|
|
408
485
|
detail: string;
|
|
486
|
+
/** What a judge cost, USD; absent for the free checks. */
|
|
487
|
+
costUsd?: number;
|
|
409
488
|
}
|
|
410
489
|
|
|
411
490
|
export interface CaseResult {
|
|
@@ -416,7 +495,35 @@ export interface CaseResult {
|
|
|
416
495
|
/** Set when the agent itself failed, before any assertion could run. */
|
|
417
496
|
error: string | null;
|
|
418
497
|
assertions: AssertionResult[];
|
|
498
|
+
/** What the case's run cost — the subject, not the judge. */
|
|
419
499
|
costUsd: number;
|
|
500
|
+
/** What its judges cost, USD. Absent on results from before 24 Oct 2026. */
|
|
501
|
+
judgeCostUsd?: number;
|
|
502
|
+
/** How long the run took from a worker picking it up to its end — its
|
|
503
|
+
* time in the queue left out, so a busy account does not read as a slow
|
|
504
|
+
* model. Null when it never ran. */
|
|
505
|
+
secs?: number | null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** How a model comparison (eval-compare.ts) runs one eval: every step it
|
|
509
|
+
* forces onto a model, always as a test run, and whether to go on. */
|
|
510
|
+
export interface EvalRunOptions {
|
|
511
|
+
/** Forced onto the subject's steps — every step of a flow eval, or only
|
|
512
|
+
* `agent`'s when it is set — as a step-level setting, so it wins over
|
|
513
|
+
* the agent's own `model:` and the flow's defaults. `effort` likewise
|
|
514
|
+
* when given; none keeps each step's own effort, so a model-only
|
|
515
|
+
* comparison changes the model and nothing else. */
|
|
516
|
+
model?: string;
|
|
517
|
+
effort?: Effort | null;
|
|
518
|
+
agent?: string | null;
|
|
519
|
+
/** A test run whatever the eval or flow says about `live:`. */
|
|
520
|
+
forceTest?: boolean;
|
|
521
|
+
/** Asked before each case; a reason stops the eval there, the rest of its
|
|
522
|
+
* cases recorded as not run with it. */
|
|
523
|
+
beforeCase?: () => string | null;
|
|
524
|
+
/** Told each case as it ends — what a comparison counts its spend by, so
|
|
525
|
+
* its limit holds between the cases of one setting, not only after. */
|
|
526
|
+
afterCase?: (c: CaseResult) => void;
|
|
420
527
|
}
|
|
421
528
|
|
|
422
529
|
export interface EvalResult {
|
|
@@ -507,41 +614,66 @@ function shell(command: string, cwd: string): Promise<{ code: number | null; out
|
|
|
507
614
|
// LAST, after the reasoning: asked for it first, the judge committed before
|
|
508
615
|
// it had looked, then argued the other way — "FAIL … which meets the
|
|
509
616
|
// criterion" (vaza evals, 21 Oct 2026).
|
|
510
|
-
|
|
617
|
+
/** How long one judge may take before it is cut off: a verdict on one
|
|
618
|
+
* sentence is seconds; a stalled model must not hold a grade pass. */
|
|
619
|
+
export const JUDGE_TIMEOUT_MS = 120_000;
|
|
620
|
+
|
|
621
|
+
/** One judge call: its words and what it cost (the SDK's result line). */
|
|
622
|
+
export async function judgeCall(
|
|
511
623
|
rubric: string,
|
|
512
624
|
output: string,
|
|
513
625
|
model: string,
|
|
514
626
|
effort: Effort | null,
|
|
515
627
|
env: Record<string, string | undefined>,
|
|
516
|
-
|
|
628
|
+
/** What the output was an answer to — a live run's task, so the judge
|
|
629
|
+
* grades the reply against what was asked rather than in a vacuum. */
|
|
630
|
+
context?: string,
|
|
631
|
+
): Promise<{ text: string; costUsd: number }> {
|
|
517
632
|
const prompt =
|
|
518
633
|
`You are grading one piece of output against one criterion. ${JUDGE_VERDICT_INSTRUCTION}\n\n` +
|
|
519
|
-
`Criterion: ${rubric}\n\n
|
|
634
|
+
`Criterion: ${rubric}\n\n` +
|
|
635
|
+
(context ? `<task>\n${context.slice(0, 4000)}\n</task>\n\n` : "") +
|
|
636
|
+
`<output>\n${output.slice(0, 20000)}\n</output>`;
|
|
520
637
|
|
|
521
638
|
let text = "";
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
639
|
+
let costUsd = 0;
|
|
640
|
+
const abort = new AbortController();
|
|
641
|
+
const timer = setTimeout(() => abort.abort(), JUDGE_TIMEOUT_MS);
|
|
642
|
+
timer.unref?.();
|
|
643
|
+
try {
|
|
644
|
+
const q = query({
|
|
645
|
+
prompt,
|
|
646
|
+
options: {
|
|
647
|
+
model,
|
|
648
|
+
...(effort ? { effort } : {}),
|
|
649
|
+
tools: [],
|
|
650
|
+
allowedTools: [],
|
|
651
|
+
settingSources: [],
|
|
652
|
+
strictMcpConfig: true,
|
|
653
|
+
env,
|
|
654
|
+
maxTurns: 1,
|
|
655
|
+
abortController: abort,
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
for await (const message of q) {
|
|
659
|
+
if (message.type === "assistant") {
|
|
660
|
+
for (const block of message.message.content) {
|
|
661
|
+
if (block.type === "text") text += block.text;
|
|
662
|
+
}
|
|
663
|
+
} else if (message.type === "result") {
|
|
664
|
+
costUsd = Number((message as { total_cost_usd?: number }).total_cost_usd) || 0;
|
|
539
665
|
}
|
|
540
666
|
}
|
|
667
|
+
} catch (err) {
|
|
668
|
+
if (abort.signal.aborted) throw new Error(`the judge took longer than ${JUDGE_TIMEOUT_MS / 1000}s and was stopped`);
|
|
669
|
+
throw err;
|
|
670
|
+
} finally {
|
|
671
|
+
clearTimeout(timer);
|
|
541
672
|
}
|
|
542
|
-
return text.trim();
|
|
673
|
+
return { text: text.trim(), costUsd };
|
|
543
674
|
}
|
|
544
675
|
|
|
676
|
+
|
|
545
677
|
/**
|
|
546
678
|
* The judge's environment: this process's, with the platform's model
|
|
547
679
|
* credential read live. The process's own CLAUDE_CODE_OAUTH_TOKEN is the copy
|
|
@@ -555,7 +687,7 @@ export function judgeProcessEnv(): Record<string, string | undefined> {
|
|
|
555
687
|
return sdkEnv({ ...process.env, ...live });
|
|
556
688
|
}
|
|
557
689
|
|
|
558
|
-
async function checkAssertion(
|
|
690
|
+
export async function checkAssertion(
|
|
559
691
|
assertion: Assertion,
|
|
560
692
|
output: string,
|
|
561
693
|
agentDir: string,
|
|
@@ -602,9 +734,9 @@ async function checkAssertion(
|
|
|
602
734
|
} catch (err) {
|
|
603
735
|
return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
|
|
604
736
|
}
|
|
605
|
-
const
|
|
606
|
-
const { passed, detail } = judgeVerdict(text);
|
|
607
|
-
return { assertion, passed, detail };
|
|
737
|
+
const said = await withModelEnv(m, (env) => judgeCall(assertion.value, output, model, effort, env));
|
|
738
|
+
const { passed, detail } = judgeVerdict(said.text);
|
|
739
|
+
return { assertion, passed, detail, costUsd: said.costUsd };
|
|
608
740
|
}
|
|
609
741
|
}
|
|
610
742
|
}
|
|
@@ -613,7 +745,11 @@ export async function runEval(
|
|
|
613
745
|
tenant: string,
|
|
614
746
|
workspace: string,
|
|
615
747
|
info: EvalInfo,
|
|
748
|
+
opts: EvalRunOptions = {},
|
|
616
749
|
): Promise<EvalResult> {
|
|
750
|
+
if (info.trigger === "runs") {
|
|
751
|
+
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);
|
|
752
|
+
}
|
|
617
753
|
const startedAt = new Date().toISOString();
|
|
618
754
|
const results: CaseResult[] = [];
|
|
619
755
|
// Saved inputs carry no assertions: running them would start and bill a
|
|
@@ -626,6 +762,17 @@ export async function runEval(
|
|
|
626
762
|
let output = "";
|
|
627
763
|
let error: string | null = null;
|
|
628
764
|
let costUsd = 0;
|
|
765
|
+
let secs: number | null = null;
|
|
766
|
+
const stop = opts.beforeCase?.() ?? null;
|
|
767
|
+
if (stop) {
|
|
768
|
+
results.push({ name: testCase.name, task: testCase.task, runId: null, error: `not run — ${stop}`, passed: false, assertions: [], costUsd: 0, judgeCostUsd: 0, secs: null });
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
// A step forced onto the comparison's model: step-level, so it wins.
|
|
772
|
+
const force = <S extends FlowStep>(st: S): S =>
|
|
773
|
+
opts.model && (!opts.agent || st.agent === opts.agent)
|
|
774
|
+
? { ...st, model: opts.model, ...(opts.effort ? { effort: opts.effort } : {}) }
|
|
775
|
+
: st;
|
|
629
776
|
|
|
630
777
|
try {
|
|
631
778
|
let run;
|
|
@@ -634,33 +781,39 @@ export async function runEval(
|
|
|
634
781
|
if (!flow || flow.steps.length === 0) throw new Error(`flow ${info.flow} not found or empty`);
|
|
635
782
|
// The case's task reaches the first step; output passing carries it on.
|
|
636
783
|
const steps = flow.steps.map((s, i) =>
|
|
637
|
-
i === 0
|
|
784
|
+
force(i === 0
|
|
638
785
|
? { ...s, instruction: `${s.instruction}\n\n<run_task>\n${testCase.task}\n</run_task>` }
|
|
639
|
-
: s,
|
|
786
|
+
: s),
|
|
640
787
|
);
|
|
641
788
|
// Test unless the eval OR the flow says live: an eval that runs a
|
|
642
|
-
// flow written `live: true` was written by someone who knew.
|
|
643
|
-
|
|
789
|
+
// flow written `live: true` was written by someone who knew. A
|
|
790
|
+
// comparison is a test whatever they say.
|
|
791
|
+
run = await beginEvalRun(tenant, workspace, steps, `eval:${info.name}`, flow.model, flow.effort, opts.forceTest ? true : !(info.live || flow.live));
|
|
644
792
|
} else {
|
|
645
793
|
if (!target) throw new Error("this eval names neither an agent nor a flow");
|
|
646
794
|
run = await beginEvalRun(
|
|
647
795
|
tenant,
|
|
648
796
|
workspace,
|
|
649
|
-
[{ agent: target, instruction: testCase.task, group: 1, optional: false }],
|
|
797
|
+
[force({ agent: target, instruction: testCase.task, group: 1, optional: false } as FlowStep)],
|
|
650
798
|
`eval:${info.name}`,
|
|
651
799
|
undefined,
|
|
652
800
|
undefined,
|
|
653
|
-
!info.live,
|
|
801
|
+
opts.forceTest ? true : !info.live,
|
|
654
802
|
);
|
|
655
803
|
}
|
|
656
804
|
runId = run.id;
|
|
657
805
|
const finished = await waitForRun(tenant, workspace, run.id);
|
|
658
806
|
if (!finished) throw new Error("run disappeared");
|
|
807
|
+
// What the steps before a gate spent is spent: counted before the
|
|
808
|
+
// gate is refused, so a comparison's limit sees it.
|
|
809
|
+
costUsd = finished.steps.reduce((sum, s) => sum + (s.costUsd ?? 0), 0);
|
|
659
810
|
if (finished.status === "awaiting-approval") {
|
|
660
811
|
throw new Error("this run pauses for approval — an eval cannot answer a human gate");
|
|
661
812
|
}
|
|
662
813
|
output = finished.steps.map((s) => s.result ?? "").join("\n\n");
|
|
663
|
-
|
|
814
|
+
const from = Date.parse(finished.drivenAt ?? finished.startedAt);
|
|
815
|
+
const to = finished.finishedAt ? Date.parse(finished.finishedAt) : NaN;
|
|
816
|
+
secs = Number.isFinite(from) && Number.isFinite(to) ? Math.max(0, Math.round((to - from) / 100) / 10) : null;
|
|
664
817
|
if (finished.status === "failed" && !output.trim()) {
|
|
665
818
|
// Quote the run's own last error. "Produced nothing" on a fresh
|
|
666
819
|
// workspace is almost always "secret X is not set" or a tool that
|
|
@@ -702,7 +855,10 @@ export async function runEval(
|
|
|
702
855
|
passed: !error && assertions.length > 0 && assertions.every((r) => r.passed),
|
|
703
856
|
assertions,
|
|
704
857
|
costUsd,
|
|
858
|
+
judgeCostUsd: Math.round(assertions.reduce((n, a) => n + (a.costUsd ?? 0), 0) * 1e6) / 1e6,
|
|
859
|
+
secs,
|
|
705
860
|
});
|
|
861
|
+
opts.afterCase?.(results[results.length - 1]);
|
|
706
862
|
}
|
|
707
863
|
|
|
708
864
|
return {
|
|
@@ -837,8 +993,9 @@ export async function evaluateDeployed(
|
|
|
837
993
|
const stamp = commit ?? deployedCommit(tenant, workspace)?.commit ?? latestRevisionId(tenant, workspace);
|
|
838
994
|
if (!stamp) return { ran: 0, skipped: "no revision to stamp the result with" };
|
|
839
995
|
// Only the evals that asked to run on deploy — a `trigger: manual` eval is
|
|
840
|
-
// one a person runs on purpose, because it costs a whole flow run
|
|
841
|
-
|
|
996
|
+
// one a person runs on purpose, because it costs a whole flow run, and a
|
|
997
|
+
// `trigger: runs` one grades real runs and has no cases to run.
|
|
998
|
+
const evals = listEvals(tenant, workspace).filter((e) => e.trigger === "deploy" && !e.inputs);
|
|
842
999
|
if (evals.length === 0) return { ran: 0, skipped: "no evals" };
|
|
843
1000
|
const dir = path.join(workspaceDir(tenant, workspace), "evals", ".results");
|
|
844
1001
|
fs.mkdirSync(dir, { recursive: true });
|
package/src/flow-lint.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import type { FlowInfo, FlowStep } from "./store.ts";
|
|
15
15
|
import { whenRowsPath } from "./store.ts";
|
|
16
16
|
import { timezoneProblem } from "./clock.ts";
|
|
17
|
+
import { FILTERABLE_TRIGGERS } from "./trigger-filter.ts";
|
|
17
18
|
|
|
18
19
|
/** What a `[[flow:x]]` step's options are called in a flow file, for the
|
|
19
20
|
* ones expandSubflows does not carry into the nested steps. */
|
|
@@ -251,6 +252,9 @@ export function lintFlow(flow: FlowInfo, known?: KnownNames): FlowWarning[] {
|
|
|
251
252
|
if (flow.signature && !flow.signingSecret) {
|
|
252
253
|
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." });
|
|
253
254
|
}
|
|
255
|
+
if (flow.filter != null && !(FILTERABLE_TRIGGERS as readonly string[]).includes(flow.trigger)) {
|
|
256
|
+
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.` });
|
|
257
|
+
}
|
|
254
258
|
if (flow.signature && flow.trigger !== "webhook") {
|
|
255
259
|
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." });
|
|
256
260
|
}
|
|
@@ -160,6 +160,7 @@ const FLOW: FieldSpec[] = [
|
|
|
160
160
|
{ key: "url", type: "string", help: "trigger: watch — the page watched" },
|
|
161
161
|
{ key: "every", type: ["string", "number"], help: "trigger: watch — how often" },
|
|
162
162
|
{ key: "path", type: "string", help: "trigger: storage — the prefix under storage/" },
|
|
163
|
+
{ 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]}}}" },
|
|
163
164
|
{ key: "idempotency", type: "string", help: "a header name, or body:<field>, that marks a repeat delivery" },
|
|
164
165
|
{ key: "debounce", type: ["string", "number"], help: "wait for a burst to end" },
|
|
165
166
|
{ key: "throttle", type: ["string", "number"], help: "at most one run per window" },
|
|
@@ -216,7 +217,9 @@ const EVAL: FieldSpec[] = [
|
|
|
216
217
|
{ key: "flow", type: "string", help: "or the flow" },
|
|
217
218
|
{ key: "model", type: "string", help: "the judge's model" },
|
|
218
219
|
{ key: "effort", type: "string", values: EFFORT_LEVELS, help: "the judge's effort" },
|
|
219
|
-
{ key: "trigger", type: "string", values: ["deploy", "manual"], help: "deploy (default) | manual
|
|
220
|
+
{ 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" },
|
|
221
|
+
{ key: "sample", type: ["number", "string"], validated: true, help: "trigger: runs — the share of finished runs graded, 0 to 100 percent (default 25)" },
|
|
222
|
+
{ 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" },
|
|
220
223
|
{ key: "live", type: "boolean", help: "true: real calls" },
|
|
221
224
|
{ key: "inputs", type: "boolean", help: "true: cases carry inputs" },
|
|
222
225
|
];
|