@foldrun/core 2.10.1 → 2.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.js +3 -0
  3. package/dist/src/catalog.d.ts +3 -0
  4. package/dist/src/catalog.js +4 -1
  5. package/dist/src/deploy.js +16 -0
  6. package/dist/src/eval-compare.d.ts +74 -0
  7. package/dist/src/eval-compare.js +187 -0
  8. package/dist/src/evals.d.ts +62 -3
  9. package/dist/src/evals.js +161 -36
  10. package/dist/src/flow-lint.js +4 -0
  11. package/dist/src/frontmatter-fields.js +4 -1
  12. package/dist/src/grades.d.ts +118 -0
  13. package/dist/src/grades.js +481 -0
  14. package/dist/src/judge-verdict.d.ts +1 -0
  15. package/dist/src/judge-verdict.js +3 -1
  16. package/dist/src/online-checks.d.ts +4 -0
  17. package/dist/src/online-checks.js +5 -1
  18. package/dist/src/openapi.d.ts +4 -0
  19. package/dist/src/openapi.js +5 -1
  20. package/dist/src/platform.d.ts +12 -3
  21. package/dist/src/platform.js +9 -1
  22. package/dist/src/starter.js +5 -0
  23. package/dist/src/store.d.ts +8 -0
  24. package/dist/src/store.js +5 -1
  25. package/dist/src/tool-test.js +19 -7
  26. package/dist/src/translator.d.ts +4 -0
  27. package/dist/src/translator.js +22 -3
  28. package/dist/src/trigger-filter.d.ts +28 -0
  29. package/dist/src/trigger-filter.js +272 -0
  30. package/dist/src/trigger-log.d.ts +1 -1
  31. package/dist/src/triggers.d.ts +2 -0
  32. package/dist/src/triggers.js +13 -2
  33. package/dist/src/untrusted-fetch.d.ts +16 -0
  34. package/dist/src/untrusted-fetch.js +60 -2
  35. package/index.ts +3 -0
  36. package/package.json +3 -3
  37. package/src/catalog.ts +5 -1
  38. package/src/deploy.ts +10 -0
  39. package/src/eval-compare.ts +243 -0
  40. package/src/evals.ts +195 -38
  41. package/src/flow-lint.ts +4 -0
  42. package/src/frontmatter-fields.ts +4 -1
  43. package/src/grades.ts +532 -0
  44. package/src/judge-verdict.ts +4 -2
  45. package/src/online-checks.ts +5 -1
  46. package/src/openapi.ts +6 -1
  47. package/src/platform.ts +16 -4
  48. package/src/starter.ts +5 -0
  49. package/src/store.ts +11 -1
  50. package/src/tool-test.ts +21 -7
  51. package/src/translator.ts +24 -3
  52. package/src/trigger-filter.ts +264 -0
  53. package/src/trigger-log.ts +1 -0
  54. package/src/triggers.ts +14 -2
  55. package/src/untrusted-fetch.ts +65 -2
package/src/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
- trigger: "deploy" | "manual";
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: data.trigger === "manual" ? "manual" : "deploy",
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) => parseEval(f, fs.readFileSync(path.join(dir, f), "utf8"), judgeDefaults(tenant, workspace)));
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
- async function judge(
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
- ): Promise<AssertionResult["detail"] & string> {
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<output>\n${output.slice(0, 20000)}\n</output>`;
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
- const q = query({
523
- prompt,
524
- options: {
525
- model,
526
- ...(effort ? { effort } : {}),
527
- tools: [],
528
- allowedTools: [],
529
- settingSources: [],
530
- strictMcpConfig: true,
531
- env,
532
- maxTurns: 1,
533
- },
534
- });
535
- for await (const message of q) {
536
- if (message.type === "assistant") {
537
- for (const block of message.message.content) {
538
- if (block.type === "text") text += block.text;
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 text = await withModelEnv(m, (env) => judge(assertion.value, output, model, effort, env));
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
- run = await beginEvalRun(tenant, workspace, steps, `eval:${info.name}`, flow.model, flow.effort, !(info.live || flow.live));
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
- costUsd = finished.steps.reduce((sum, s) => sum + (s.costUsd ?? 0), 0);
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
- const evals = listEvals(tenant, workspace).filter((e) => e.trigger !== "manual" && !e.inputs);
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; anything else is deploy" },
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
  ];