@foldrun/core 0.2.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.
Files changed (166) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -0
  3. package/dist/index.d.ts +32 -0
  4. package/dist/index.js +43 -0
  5. package/dist/src/agent-tools.d.ts +31 -0
  6. package/dist/src/agent-tools.js +99 -0
  7. package/dist/src/agents-md.d.ts +9 -0
  8. package/dist/src/agents-md.js +83 -0
  9. package/dist/src/api-tools.d.ts +38 -0
  10. package/dist/src/api-tools.js +313 -0
  11. package/dist/src/approvals.d.ts +39 -0
  12. package/dist/src/approvals.js +103 -0
  13. package/dist/src/arrange.d.ts +8 -0
  14. package/dist/src/arrange.js +40 -0
  15. package/dist/src/catalog.d.ts +108 -0
  16. package/dist/src/catalog.js +255 -0
  17. package/dist/src/completions.d.ts +61 -0
  18. package/dist/src/completions.js +578 -0
  19. package/dist/src/confine.d.ts +31 -0
  20. package/dist/src/confine.js +298 -0
  21. package/dist/src/container.d.ts +30 -0
  22. package/dist/src/container.js +172 -0
  23. package/dist/src/context-tools.d.ts +81 -0
  24. package/dist/src/context-tools.js +347 -0
  25. package/dist/src/deploy.d.ts +100 -0
  26. package/dist/src/deploy.js +377 -0
  27. package/dist/src/egress.d.ts +75 -0
  28. package/dist/src/egress.js +133 -0
  29. package/dist/src/evals.d.ts +150 -0
  30. package/dist/src/evals.js +651 -0
  31. package/dist/src/flow-lint.d.ts +16 -0
  32. package/dist/src/flow-lint.js +177 -0
  33. package/dist/src/git-hook.d.ts +1 -0
  34. package/dist/src/git-hook.js +70 -0
  35. package/dist/src/git.d.ts +37 -0
  36. package/dist/src/git.js +101 -0
  37. package/dist/src/gitrepo.d.ts +95 -0
  38. package/dist/src/gitrepo.js +355 -0
  39. package/dist/src/history.d.ts +82 -0
  40. package/dist/src/history.js +259 -0
  41. package/dist/src/kinds.d.ts +95 -0
  42. package/dist/src/kinds.js +421 -0
  43. package/dist/src/library.d.ts +94 -0
  44. package/dist/src/library.js +341 -0
  45. package/dist/src/linkable.d.ts +20 -0
  46. package/dist/src/linkable.js +113 -0
  47. package/dist/src/model-probe.d.ts +21 -0
  48. package/dist/src/model-probe.js +81 -0
  49. package/dist/src/notify.d.ts +61 -0
  50. package/dist/src/notify.js +252 -0
  51. package/dist/src/oauth-presets.d.ts +13 -0
  52. package/dist/src/oauth-presets.js +39 -0
  53. package/dist/src/okf.d.ts +220 -0
  54. package/dist/src/okf.js +656 -0
  55. package/dist/src/openapi.d.ts +66 -0
  56. package/dist/src/openapi.js +342 -0
  57. package/dist/src/paths.d.ts +33 -0
  58. package/dist/src/paths.js +78 -0
  59. package/dist/src/platform.d.ts +32 -0
  60. package/dist/src/platform.js +48 -0
  61. package/dist/src/providers.d.ts +47 -0
  62. package/dist/src/providers.js +114 -0
  63. package/dist/src/refs.d.ts +20 -0
  64. package/dist/src/refs.js +78 -0
  65. package/dist/src/run-container.d.ts +227 -0
  66. package/dist/src/run-container.js +760 -0
  67. package/dist/src/runner.d.ts +241 -0
  68. package/dist/src/runner.js +3215 -0
  69. package/dist/src/runtime.d.ts +41 -0
  70. package/dist/src/runtime.js +357 -0
  71. package/dist/src/script-tools.d.ts +62 -0
  72. package/dist/src/script-tools.js +296 -0
  73. package/dist/src/secret-files.d.ts +12 -0
  74. package/dist/src/secret-files.js +92 -0
  75. package/dist/src/secrets.d.ts +147 -0
  76. package/dist/src/secrets.js +626 -0
  77. package/dist/src/starter.d.ts +21 -0
  78. package/dist/src/starter.js +399 -0
  79. package/dist/src/step-exec.d.ts +136 -0
  80. package/dist/src/step-exec.js +391 -0
  81. package/dist/src/storage.d.ts +123 -0
  82. package/dist/src/storage.js +640 -0
  83. package/dist/src/store.d.ts +1052 -0
  84. package/dist/src/store.js +2378 -0
  85. package/dist/src/tar.d.ts +30 -0
  86. package/dist/src/tar.js +111 -0
  87. package/dist/src/tool-names.d.ts +39 -0
  88. package/dist/src/tool-names.js +79 -0
  89. package/dist/src/tool-programs.d.ts +18 -0
  90. package/dist/src/tool-programs.js +52 -0
  91. package/dist/src/tool-test.d.ts +19 -0
  92. package/dist/src/tool-test.js +335 -0
  93. package/dist/src/translator.d.ts +159 -0
  94. package/dist/src/translator.js +986 -0
  95. package/dist/src/triggers.d.ts +60 -0
  96. package/dist/src/triggers.js +270 -0
  97. package/dist/src/webhook.d.ts +47 -0
  98. package/dist/src/webhook.js +148 -0
  99. package/index.ts +44 -0
  100. package/package.json +64 -0
  101. package/src/agent-tools.ts +138 -0
  102. package/src/agents-md.ts +81 -0
  103. package/src/api-tools.ts +389 -0
  104. package/src/approvals.ts +129 -0
  105. package/src/arrange.ts +42 -0
  106. package/src/catalog.ts +321 -0
  107. package/src/completions.ts +652 -0
  108. package/src/confine.ts +350 -0
  109. package/src/container.ts +220 -0
  110. package/src/context-tools.ts +435 -0
  111. package/src/deploy.ts +458 -0
  112. package/src/egress.ts +176 -0
  113. package/src/evals.ts +812 -0
  114. package/src/flow-lint.ts +215 -0
  115. package/src/git-hook.ts +73 -0
  116. package/src/git.ts +117 -0
  117. package/src/gitrepo.ts +391 -0
  118. package/src/history.ts +329 -0
  119. package/src/kinds.ts +507 -0
  120. package/src/library.ts +419 -0
  121. package/src/linkable.ts +123 -0
  122. package/src/model-probe.ts +112 -0
  123. package/src/notify.ts +281 -0
  124. package/src/oauth-presets.ts +54 -0
  125. package/src/okf.ts +768 -0
  126. package/src/openapi.ts +383 -0
  127. package/src/paths.ts +79 -0
  128. package/src/platform.ts +87 -0
  129. package/src/providers.ts +147 -0
  130. package/src/refs.ts +83 -0
  131. package/src/run-container.ts +883 -0
  132. package/src/runner.ts +3446 -0
  133. package/src/runtime.ts +404 -0
  134. package/src/script-tools.ts +383 -0
  135. package/src/secret-files.ts +101 -0
  136. package/src/secrets.ts +799 -0
  137. package/src/starter.ts +415 -0
  138. package/src/step-exec.ts +498 -0
  139. package/src/storage.ts +771 -0
  140. package/src/store.ts +2985 -0
  141. package/src/tar.ts +124 -0
  142. package/src/tool-names.ts +91 -0
  143. package/src/tool-programs.ts +65 -0
  144. package/src/tool-test.ts +401 -0
  145. package/src/translator.ts +1029 -0
  146. package/src/triggers.ts +287 -0
  147. package/src/webhook.ts +183 -0
  148. package/templates/blog-desk/AGENTS.md +13 -0
  149. package/templates/blog-desk/agents/researcher/agent.md +11 -0
  150. package/templates/blog-desk/agents/writer/agent.md +8 -0
  151. package/templates/blog-desk/evals/writer-quality.md +10 -0
  152. package/templates/blog-desk/flows/publish.md +10 -0
  153. package/templates/blog-desk/knowledge/house-style.md +9 -0
  154. package/templates/blog-desk/knowledge/index.md +9 -0
  155. package/templates/hello/AGENTS.md +18 -0
  156. package/templates/hello/agents/curator/agent.md +32 -0
  157. package/templates/hello/agents/notetaker/agent.md +24 -0
  158. package/templates/hello/evals/house-style.md +16 -0
  159. package/templates/hello/flows/memory-review.md +7 -0
  160. package/templates/hello/flows/note.md +7 -0
  161. package/templates/hello/knowledge/house-style.md +15 -0
  162. package/templates/hello/knowledge/index.md +9 -0
  163. package/templates/hello/skills/plain-english/SKILL.md +11 -0
  164. package/templates/hello/state/publishing.md +7 -0
  165. package/templates/hello/tools/wordcount/run.py +13 -0
  166. package/templates/hello/tools/wordcount/tool.md +22 -0
package/src/evals.ts ADDED
@@ -0,0 +1,812 @@
1
+ // Evals — tests for agents.
2
+ //
3
+ // An agent's output varies between runs, so an eval can't assert equality.
4
+ // It asserts *properties*: this text appears, that file exists, this command
5
+ // exits 0, a judge agrees with a rubric. That's the same shape promptfoo and
6
+ // OpenAI evals settled on, and it's the only shape that survives a model that
7
+ // phrases things differently each time.
8
+ //
9
+ // The file is markdown, like everything else here:
10
+ //
11
+ // evals/writer-quality.md
12
+ // ---
13
+ // name: writer-quality
14
+ // agent: writer # or `flow: publish`
15
+ // model: fast # the judge's model, not the agent's
16
+ // ---
17
+ //
18
+ // ## mentions the audience
19
+ // task: Write a short draft about cleaning a rain gauge.
20
+ // expect:
21
+ // - contains: farmer
22
+ // - not-contains: leverage
23
+ // - judge: speaks to working farmers, not to gardeners
24
+ //
25
+ // ## quotes the real price
26
+ // task: Write one line quoting the price of the RG-40.
27
+ // expect:
28
+ // - contains: $34
29
+ //
30
+ // Deterministic checks run first and cost nothing. The judge only runs if
31
+ // they pass — no point paying a model to grade output already known to be
32
+ // wrong.
33
+
34
+ import fs from "node:fs";
35
+ import path from "node:path";
36
+ import { deployedCommit } from "./deploy.ts";
37
+ import { latestRevisionId } from "./history.ts";
38
+ import matter from "gray-matter";
39
+ import { spawn } from "node:child_process";
40
+ import { query } from "@anthropic-ai/claude-agent-sdk";
41
+ import {
42
+ workspaceDir,
43
+ readRun,
44
+ writeWorkspaceFile,
45
+ resolveModel,
46
+ resolveEffort,
47
+ assertSafeName,
48
+ type Effort,
49
+ } from "./store.ts";
50
+ import { startFlowRun, loadFlow } from "./runner.ts";
51
+ import { platform } from "./platform.ts";
52
+ import type { FlowStep } from "./store.ts";
53
+
54
+ export type Assertion =
55
+ | { type: "contains"; value: string }
56
+ | { type: "not-contains"; value: string }
57
+ | { type: "matches"; value: string }
58
+ | { type: "file"; value: string }
59
+ | { type: "run"; value: string }
60
+ | { type: "judge"; value: string };
61
+
62
+ export interface EvalCase {
63
+ name: string;
64
+ task: string;
65
+ expect: Assertion[];
66
+ }
67
+
68
+ export interface EvalInfo {
69
+ name: string;
70
+ file: string;
71
+ /** The agent under test, or null when a flow is. */
72
+ agent: string | null;
73
+ flow: string | null;
74
+ /** Model for the judge. Cases run the agent on its own model. */
75
+ model: string;
76
+ /** Effort for the judge. PASS/FAIL against one sentence is not deep work,
77
+ * so this is one of the few places a low default is the right one. */
78
+ effort: Effort | null;
79
+ /** When it runs. `deploy` (default): after every push, with the rest.
80
+ * `manual`: only when a person presses Run or the CLI asks — for a flow
81
+ * eval that costs a whole run and touches real systems every time. */
82
+ trigger: "deploy" | "manual";
83
+ cases: EvalCase[];
84
+ }
85
+
86
+ const ASSERTION_TYPES = ["contains", "not-contains", "matches", "file", "run", "judge"] as const;
87
+
88
+ /**
89
+ * A scalar that may continue onto following lines.
90
+ *
91
+ * YAML block scalars (`judge: >` / `judge: |`) and plain multi-line values
92
+ * both used to be read as whatever sat on the first line — which for `>` is
93
+ * the single character `>`. The judge then received ">" as its whole rubric,
94
+ * decided it could not grade against that, and returned FAIL. A four-case
95
+ * eval read 2/4 with two false failures, and nothing anywhere said the file
96
+ * had not been understood.
97
+ *
98
+ * Returns the value and the index of the last line consumed.
99
+ */
100
+ function readScalar(
101
+ lines: string[],
102
+ start: number,
103
+ inline: string,
104
+ indent: number,
105
+ ): { value: string; end: number } {
106
+ const block = inline.trim().match(/^([>|])[-+]?$/);
107
+ const parts: string[] = [];
108
+ let i = start;
109
+
110
+ while (i + 1 < lines.length) {
111
+ const next = lines[i + 1];
112
+ if (!next.trim()) {
113
+ // A blank line ends a plain scalar; inside a block it is a paragraph
114
+ // break, which folds to nothing more than the space we already add.
115
+ if (!block) break;
116
+ i++;
117
+ continue;
118
+ }
119
+ const nextIndent = next.length - next.trimStart().length;
120
+ // A sibling list item or anything dedented back to the key's own column
121
+ // belongs to the parent, not to this value.
122
+ if (nextIndent <= indent || /^\s*-\s/.test(next)) break;
123
+ parts.push(next.trim());
124
+ i++;
125
+ }
126
+
127
+ if (block) {
128
+ // `|` keeps the line breaks it was written with; `>` folds to one line.
129
+ const value = block[1] === "|" ? parts.join("\n") : parts.join(" ");
130
+ return { value: value.trim(), end: i };
131
+ }
132
+ const first = inline.trim().replace(/^["']|["']$/g, "");
133
+ return { value: [first, ...parts].join(" ").trim(), end: i };
134
+ }
135
+
136
+ export function parseEval(file: string, raw: string): EvalInfo {
137
+ const { data, content } = matter(raw);
138
+ const cases: EvalCase[] = [];
139
+ let current: EvalCase | null = null;
140
+ let inExpect = false;
141
+
142
+ const lines = content.split("\n");
143
+ for (let i = 0; i < lines.length; i++) {
144
+ const line = lines[i];
145
+ const heading = line.match(/^##\s+(?:case:\s*)?(.+?)\s*$/);
146
+ if (heading) {
147
+ if (current) cases.push(current);
148
+ current = { name: heading[1], task: "", expect: [] };
149
+ inExpect = false;
150
+ continue;
151
+ }
152
+ if (!current) continue;
153
+
154
+ const task = line.match(/^(\s*)task:\s*(.*)$/);
155
+ if (task) {
156
+ const read = readScalar(lines, i, task[2], task[1].length);
157
+ current.task = read.value;
158
+ i = read.end;
159
+ inExpect = false;
160
+ continue;
161
+ }
162
+ if (/^\s*expect:\s*$/.test(line)) {
163
+ inExpect = true;
164
+ continue;
165
+ }
166
+ if (inExpect) {
167
+ const item = line.match(/^(\s*)-\s*([a-z-]+):\s*(.*)$/);
168
+ if (item && (ASSERTION_TYPES as readonly string[]).includes(item[2])) {
169
+ const read = readScalar(lines, i, item[3], item[1].length);
170
+ current.expect.push({
171
+ type: item[2] as Assertion["type"],
172
+ value: read.value,
173
+ } as Assertion);
174
+ i = read.end;
175
+ } else if (line.trim() && !line.startsWith(" ")) {
176
+ inExpect = false;
177
+ }
178
+ }
179
+ }
180
+ if (current) cases.push(current);
181
+
182
+ return {
183
+ name: data.name ?? file.replace(/\.md$/, ""),
184
+ file,
185
+ agent: typeof data.agent === "string" ? data.agent : null,
186
+ flow: typeof data.flow === "string" ? data.flow : null,
187
+ model: resolveModel(data.model ?? "fast"),
188
+ effort: resolveEffort(data.effort ?? "low"),
189
+ trigger: data.trigger === "manual" ? "manual" : "deploy",
190
+ cases,
191
+ };
192
+ }
193
+
194
+ export function listEvals(tenant: string, workspace: string): EvalInfo[] {
195
+ const dir = path.join(workspaceDir(tenant, workspace), "evals");
196
+ if (!fs.existsSync(dir)) return [];
197
+ return fs
198
+ .readdirSync(dir)
199
+ .filter((f) => f.endsWith(".md"))
200
+ .sort()
201
+ .map((f) => parseEval(f, fs.readFileSync(path.join(dir, f), "utf8")));
202
+ }
203
+
204
+ // ---------- promoting a run ----------
205
+
206
+ /** An error a route can turn into the right status without knowing HTTP. */
207
+ function refuse(message: string, status: number): Error {
208
+ return Object.assign(new Error(message), { status });
209
+ }
210
+
211
+ /** Whatever the run was actually asked to do. A flow run carries its input
212
+ * inside `<run_task>` (the Run button, the API) or `<webhook_payload>` (a
213
+ * hook) appended to the first step's instruction; an ad-hoc agent run's
214
+ * instruction IS the task. */
215
+ function taskOf(instruction: string): string {
216
+ const block = instruction.match(/<(run_task|webhook_payload)>\n?([\s\S]*?)\n?<\/\1>/);
217
+ return (block ? block[2] : instruction).trim();
218
+ }
219
+
220
+ /** One line for the eval file: the parser reads a scalar to the end of its
221
+ * line, and a stray newline would become a new key. */
222
+ const oneLine = (s: string) => s.replace(/\s+/g, " ").trim();
223
+
224
+ /**
225
+ * Lay a task out as the eval parser will read it back.
226
+ *
227
+ * A single line sits after `task:`. Anything longer goes in a `|` block,
228
+ * indented — which also keeps a line beginning `## ` from opening a new
229
+ * case. A `- ` at the start of a line ends a block scalar even indented, so
230
+ * a markdown bullet is written as `* `: the same bullet to a reader and to
231
+ * a model, and the case survives.
232
+ */
233
+ function layoutTask(task: string): string {
234
+ // A quote at either end of a one-liner would be stripped on read.
235
+ if (!task.includes("\n") && !/^["']|["']$/.test(task)) return `task: ${task}`;
236
+ const lines = task.split("\n").map((l) => ` ${l.replace(/^(\s*)-\s/, "$1* ")}`);
237
+ return `task: |\n${lines.join("\n")}`;
238
+ }
239
+
240
+ /**
241
+ * Turn a finished run into a regression case.
242
+ *
243
+ * A run that worked is the best test of the agent that did it: the task is
244
+ * on record, and so is the answer. This writes both into `evals/` — the task
245
+ * verbatim, the answer as a rubric the judge holds the next run to — so a
246
+ * later edit to the markdown that changes the conclusion shows up as a red
247
+ * case rather than as a customer noticing. Appends to the target's
248
+ * regressions file when there is one, so a flow collects its cases in one
249
+ * place instead of one file per press.
250
+ */
251
+ export function promoteRunToEval(
252
+ tenant: string,
253
+ workspace: string,
254
+ runId: string,
255
+ opts: { evalName?: string; caseName?: string; expect?: string[]; by?: string } = {},
256
+ ): { file: string; created: boolean; caseName: string } {
257
+ const run = readRun(tenant, workspace, runId);
258
+ if (!run) throw refuse(`run ${runId} not found`, 404);
259
+ if (run.status !== "completed") {
260
+ throw refuse(`only a completed run can become an eval — this one is ${run.status}`, 409);
261
+ }
262
+ const produced = run.steps.filter((s) => (s.result ?? "").trim());
263
+ const last = produced[produced.length - 1];
264
+ if (!last) throw refuse("this run produced no result to hold the next one to", 409);
265
+ if (run.steps.length === 0 || !run.steps[0].instruction?.trim()) {
266
+ throw refuse("this run has no task on record", 409);
267
+ }
268
+ if (run.flow.startsWith("eval:")) {
269
+ throw refuse("this run was itself an eval case — promote the run it was checking", 409);
270
+ }
271
+
272
+ // The target: the agent for a direct run, the flow otherwise.
273
+ const target = run.flow.startsWith("adhoc:")
274
+ ? { kind: "agent" as const, name: run.flow.slice("adhoc:".length) }
275
+ : { kind: "flow" as const, name: run.flow };
276
+ assertSafeName(target.name, target.kind);
277
+
278
+ const evalName = opts.evalName ?? `${target.name}-regressions`;
279
+ assertSafeName(evalName, "eval name");
280
+ const rel = `evals/${evalName}.md`;
281
+ const abs = path.join(workspaceDir(tenant, workspace), rel);
282
+ const existing = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : null;
283
+
284
+ // The expectations, checked before anything is written: a line the
285
+ // parser would skip is a case that passes on nothing.
286
+ let expect: string[];
287
+ if (opts.expect && opts.expect.length > 0) {
288
+ expect = opts.expect.map((line) => {
289
+ const m = oneLine(line).match(/^([a-z-]+):\s*(.*)$/);
290
+ if (!m || !(ASSERTION_TYPES as readonly string[]).includes(m[1]) || !m[2]) {
291
+ throw refuse(
292
+ `"${line}" is not an assertion — expected one of ${ASSERTION_TYPES.map((t) => `${t}:`).join(", ")} followed by a value`,
293
+ 400,
294
+ );
295
+ }
296
+ return `${m[1]}: ${m[2]}`;
297
+ });
298
+ } else {
299
+ const conclusion = oneLine(run.summary?.trim() || (last.result ?? "")).slice(0, 200);
300
+ expect = [`judge: reaches the same conclusion as the recorded run: ${conclusion}`];
301
+ }
302
+
303
+ // The case name: what the caller said, else the run and the day it ran —
304
+ // made unique in the file, so promoting the same run twice reads as two
305
+ // cases rather than one case declared twice.
306
+ const taken = new Set(existing ? parseEval(rel, existing).cases.map((c) => c.name) : []);
307
+ const base = oneLine(opts.caseName ?? "") || `run ${run.id} (${run.startedAt.slice(0, 10)})`;
308
+ let caseName = base;
309
+ for (let n = 2; taken.has(caseName); n++) caseName = `${base} (${n})`;
310
+
311
+ const task = taskOf(run.steps[0].instruction);
312
+ if (!task) throw refuse("this run has no task on record", 409);
313
+ const caseText =
314
+ `## ${caseName}\n${layoutTask(task)}\nexpect:\n${expect.map((e) => ` - ${e}`).join("\n")}\n`;
315
+
316
+ let content: string;
317
+ if (existing) {
318
+ // Appending to a file that tests something else would run this case
319
+ // against the wrong target and call the result a regression.
320
+ const info = parseEval(rel, existing);
321
+ const points = info.flow ? { kind: "flow", name: info.flow } : { kind: "agent", name: info.agent };
322
+ if (points.kind !== target.kind || points.name !== target.name) {
323
+ throw refuse(
324
+ `${rel} tests ${points.kind} ${points.name ?? "(nothing)"}, not ${target.kind} ${target.name} — pick another evalName`,
325
+ 409,
326
+ );
327
+ }
328
+ content = `${existing.replace(/\s+$/, "")}\n\n${caseText}`;
329
+ } else {
330
+ // A flow eval costs a whole run and touches real systems, so it waits
331
+ // for a person to press Run rather than following every push.
332
+ const front =
333
+ `---\nname: ${evalName}\n${target.kind}: ${target.name}\nmodel: fast\n` +
334
+ (target.kind === "flow" ? "trigger: manual\n" : "") +
335
+ `---\n`;
336
+ content = `${front}\n${caseText}`;
337
+ }
338
+
339
+ // Read it back the way the runner will before it is written: a case the
340
+ // parser holds differently from what was meant is not a test of anything.
341
+ const written = parseEval(rel, content).cases.find((c) => c.name === caseName);
342
+ const wanted = task
343
+ .split("\n")
344
+ .map((l) => l.replace(/^(\s*)-\s/, "$1* ").trim())
345
+ .filter(Boolean)
346
+ .join("\n");
347
+ if (!written || written.task !== wanted || written.expect.length !== expect.length) {
348
+ throw refuse("the task on this run cannot be written in the eval format as it stands", 400);
349
+ }
350
+
351
+ writeWorkspaceFile(tenant, workspace, rel, content, {
352
+ by: opts.by,
353
+ message: `${existing ? "added a case to" : "created"} ${rel} from run ${run.id}`,
354
+ });
355
+ return { file: rel, created: !existing, caseName };
356
+ }
357
+
358
+ // ---------- running ----------
359
+
360
+ export interface AssertionResult {
361
+ assertion: Assertion;
362
+ passed: boolean;
363
+ detail: string;
364
+ }
365
+
366
+ export interface CaseResult {
367
+ name: string;
368
+ task: string;
369
+ runId: string | null;
370
+ passed: boolean;
371
+ /** Set when the agent itself failed, before any assertion could run. */
372
+ error: string | null;
373
+ assertions: AssertionResult[];
374
+ costUsd: number;
375
+ }
376
+
377
+ export interface EvalResult {
378
+ eval: string;
379
+ startedAt: string;
380
+ finishedAt: string;
381
+ passed: number;
382
+ failed: number;
383
+ costUsd: number;
384
+ cases: CaseResult[];
385
+ /** Why the eval could not run at all — a missing secret, an agent that
386
+ * failed to load. Set only when `cases` is empty for that reason, so a
387
+ * 0-of-n result says what went wrong instead of looking like n wrong
388
+ * answers. */
389
+ error?: string;
390
+ }
391
+
392
+ /** Wait for a run to finish, with a ceiling so a hung agent can't hang an eval. */
393
+ /**
394
+ * A web replica never drives a run: it has no route to the cluster API and
395
+ * no right to create a pod, by design — so an eval started there (a push,
396
+ * the Run button) queues its run for the worker like any other. From 29 Aug
397
+ * every eval on the split platform died at "pod create failed: connection
398
+ * refused" for exactly this reason. A single process — the CLI, a laptop,
399
+ * FOLDRUN_ROLE unset — still drives it right here, as it always did.
400
+ */
401
+ const runsViaQueue = () => process.env.FOLDRUN_ROLE === "web";
402
+
403
+ async function beginEvalRun(
404
+ tenant: string,
405
+ workspace: string,
406
+ steps: FlowStep[],
407
+ flowName: string,
408
+ model?: string | null,
409
+ effort?: string | null,
410
+ ) {
411
+ if (!runsViaQueue()) return startFlowRun(tenant, workspace, steps, flowName, model, [], effort);
412
+ // The job carries a model override but no effort one, and driveRun cannot
413
+ // re-read a flow called `eval:<name>`. Put the flow's defaults onto the
414
+ // steps that have none — which is what nearest-wins resolves to anyway.
415
+ const withDefaults = steps.map((s) => ({
416
+ ...s,
417
+ ...(s.model || !model ? {} : { model }),
418
+ ...(s.effort || !effort ? {} : { effort }),
419
+ }));
420
+ return platform.enqueueFlowRun(tenant, workspace, withDefaults, flowName, model ?? null, []);
421
+ }
422
+
423
+ // Waits for as long as the run runs. There was a cap here (5 minutes, then
424
+ // 30) and it marked a flow eval "run disappeared" at the cap while the flow
425
+ // carried on for another half hour — the eval failed for a reason that had
426
+ // nothing to do with the flow. The run is the thing that is bounded, by the
427
+ // flow's own step timeouts; the eval just watches it.
428
+ async function waitForRun(tenant: string, workspace: string, runId: string) {
429
+ for (;;) {
430
+ const run = readRun(tenant, workspace, runId);
431
+ if (!run) return null;
432
+ if (run.finishedAt) return run;
433
+ // An eval never answers an approval gate: a test that needs a human isn't
434
+ // a test. Fail it clearly instead of waiting.
435
+ if (run.status === "awaiting-approval") return run;
436
+ await new Promise((r) => setTimeout(r, 1000));
437
+ }
438
+ }
439
+
440
+ function shell(command: string, cwd: string): Promise<{ code: number | null; out: string }> {
441
+ return new Promise((resolve) => {
442
+ const child = spawn("bash", ["-lc", command], { cwd });
443
+ let out = "";
444
+ const append = (c: Buffer) => {
445
+ if (out.length < 2000) out += c.toString();
446
+ };
447
+ child.stdout.on("data", append);
448
+ child.stderr.on("data", append);
449
+ child.on("close", (code) => resolve({ code, out: out.trim() }));
450
+ child.on("error", (err) => resolve({ code: null, out: String(err) }));
451
+ });
452
+ }
453
+
454
+ // The judge gets the rubric and the output, and must answer with one word.
455
+ // Constrained output beats asking for a score: "PASS" or "FAIL" can't be
456
+ // misread, where "7/10" needs a threshold nobody agreed on.
457
+ async function judge(
458
+ rubric: string,
459
+ output: string,
460
+ model: string,
461
+ effort: Effort | null,
462
+ ): Promise<AssertionResult["detail"] & string> {
463
+ const prompt =
464
+ `You are grading one piece of output against one criterion. Answer with exactly ` +
465
+ `PASS or FAIL on the first line, then one short sentence of reason.\n\n` +
466
+ `Criterion: ${rubric}\n\n<output>\n${output.slice(0, 20000)}\n</output>`;
467
+
468
+ let text = "";
469
+ const q = query({
470
+ prompt,
471
+ options: {
472
+ model,
473
+ ...(effort ? { effort } : {}),
474
+ tools: [],
475
+ allowedTools: [],
476
+ settingSources: [],
477
+ maxTurns: 1,
478
+ },
479
+ });
480
+ for await (const message of q) {
481
+ if (message.type === "assistant") {
482
+ for (const block of message.message.content) {
483
+ if (block.type === "text") text += block.text;
484
+ }
485
+ }
486
+ }
487
+ return text.trim();
488
+ }
489
+
490
+ async function checkAssertion(
491
+ assertion: Assertion,
492
+ output: string,
493
+ agentDir: string,
494
+ model: string,
495
+ effort: Effort | null,
496
+ ): Promise<AssertionResult> {
497
+ const hay = output.toLowerCase();
498
+ switch (assertion.type) {
499
+ case "contains": {
500
+ const passed = hay.includes(assertion.value.toLowerCase());
501
+ return { assertion, passed, detail: passed ? "found" : `"${assertion.value}" not in the output` };
502
+ }
503
+ case "not-contains": {
504
+ const passed = !hay.includes(assertion.value.toLowerCase());
505
+ return { assertion, passed, detail: passed ? "absent" : `"${assertion.value}" appeared` };
506
+ }
507
+ case "matches": {
508
+ try {
509
+ const passed = new RegExp(assertion.value, "i").test(output);
510
+ return { assertion, passed, detail: passed ? "matched" : "no match" };
511
+ } catch {
512
+ return { assertion, passed: false, detail: "invalid regular expression" };
513
+ }
514
+ }
515
+ case "file": {
516
+ const target = path.resolve(agentDir, assertion.value);
517
+ if (!target.startsWith(path.resolve(agentDir))) {
518
+ return { assertion, passed: false, detail: "path escapes the agent directory" };
519
+ }
520
+ const exists = fs.existsSync(target) && fs.statSync(target).size > 0;
521
+ return { assertion, passed: exists, detail: exists ? "present and non-empty" : "missing or empty" };
522
+ }
523
+ case "run": {
524
+ const { code, out } = await shell(assertion.value, agentDir);
525
+ return { assertion, passed: code === 0, detail: `exit ${code ?? "error"}${out ? ` — ${out.slice(0, 200)}` : ""}` };
526
+ }
527
+ case "judge": {
528
+ const verdict = await judge(assertion.value, output, model, effort);
529
+ const passed = /^\s*PASS\b/i.test(verdict);
530
+ return { assertion, passed, detail: verdict.slice(0, 300) || "judge returned nothing" };
531
+ }
532
+ }
533
+ }
534
+
535
+ export async function runEval(
536
+ tenant: string,
537
+ workspace: string,
538
+ info: EvalInfo,
539
+ ): Promise<EvalResult> {
540
+ const startedAt = new Date().toISOString();
541
+ const results: CaseResult[] = [];
542
+
543
+ for (const testCase of info.cases) {
544
+ const target = info.flow ? null : info.agent;
545
+ let runId: string | null = null;
546
+ let output = "";
547
+ let error: string | null = null;
548
+ let costUsd = 0;
549
+
550
+ try {
551
+ let run;
552
+ if (info.flow) {
553
+ const flow = loadFlow(tenant, workspace, info.flow);
554
+ if (!flow || flow.steps.length === 0) throw new Error(`flow ${info.flow} not found or empty`);
555
+ // The case's task reaches the first step; output passing carries it on.
556
+ const steps = flow.steps.map((s, i) =>
557
+ i === 0
558
+ ? { ...s, instruction: `${s.instruction}\n\n<run_task>\n${testCase.task}\n</run_task>` }
559
+ : s,
560
+ );
561
+ run = await beginEvalRun(tenant, workspace, steps, `eval:${info.name}`, flow.model, flow.effort);
562
+ } else {
563
+ if (!target) throw new Error("this eval names neither an agent nor a flow");
564
+ run = await beginEvalRun(
565
+ tenant,
566
+ workspace,
567
+ [{ agent: target, instruction: testCase.task, group: 1, optional: false }],
568
+ `eval:${info.name}`,
569
+ );
570
+ }
571
+ runId = run.id;
572
+ const finished = await waitForRun(tenant, workspace, run.id);
573
+ if (!finished) throw new Error("run disappeared");
574
+ if (finished.status === "awaiting-approval") {
575
+ throw new Error("this run pauses for approval — an eval cannot answer a human gate");
576
+ }
577
+ output = finished.steps.map((s) => s.result ?? "").join("\n\n");
578
+ costUsd = finished.steps.reduce((sum, s) => sum + (s.costUsd ?? 0), 0);
579
+ if (finished.status === "failed" && !output.trim()) {
580
+ // Quote the run's own last error. "Produced nothing" on a fresh
581
+ // workspace is almost always "secret X is not set" or a tool that
582
+ // did not load, and the eval page is where the developer is looking.
583
+ const why = finished.steps
584
+ .flatMap((s) => s.events ?? [])
585
+ .filter((e) => e.type === "error")
586
+ .map((e) => e.text.split("\n")[0].slice(0, 300))
587
+ .pop();
588
+ error = why ? `the run failed: ${why}` : "the run failed and produced nothing";
589
+ }
590
+ } catch (err) {
591
+ error = err instanceof Error ? err.message : String(err);
592
+ }
593
+
594
+ const agentDir = path.join(workspaceDir(tenant, workspace), "agents", target ?? "");
595
+ const assertions: AssertionResult[] = [];
596
+ if (!error) {
597
+ // Cheap checks first; the judge only runs once they've all passed, so a
598
+ // clearly-wrong answer never costs a model call.
599
+ const cheap = testCase.expect.filter((a) => a.type !== "judge");
600
+ const judges = testCase.expect.filter((a) => a.type === "judge");
601
+ for (const a of cheap) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
602
+ if (assertions.every((r) => r.passed)) {
603
+ for (const a of judges) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
604
+ } else {
605
+ for (const a of judges) {
606
+ assertions.push({ assertion: a, passed: false, detail: "not run — an earlier check failed" });
607
+ }
608
+ }
609
+ }
610
+
611
+ results.push({
612
+ name: testCase.name,
613
+ task: testCase.task,
614
+ runId,
615
+ error,
616
+ passed: !error && assertions.length > 0 && assertions.every((r) => r.passed),
617
+ assertions,
618
+ costUsd,
619
+ });
620
+ }
621
+
622
+ return {
623
+ eval: info.name,
624
+ startedAt,
625
+ finishedAt: new Date().toISOString(),
626
+ passed: results.filter((r) => r.passed).length,
627
+ failed: results.filter((r) => !r.passed).length,
628
+ costUsd: results.reduce((sum, r) => sum + r.costUsd, 0),
629
+ cases: results,
630
+ };
631
+ }
632
+
633
+ /**
634
+ * The latest result, and one line of history.
635
+ *
636
+ * `<eval>.json` is the latest run, overwritten each time — what the evals
637
+ * page shows. `history.jsonl` is every run, one line each, stamped with the
638
+ * commit the workspace was running. That stamp is the whole feature: a score
639
+ * with no commit says quality changed; a score with one says WHICH change.
640
+ * Manual runs are stamped too — the commit on record is whatever deploy put
641
+ * there — so a button press and a post-deploy run land in the same series.
642
+ */
643
+ export function writeEvalResult(
644
+ tenant: string,
645
+ workspace: string,
646
+ result: EvalResult,
647
+ // A deployed commit when there is one; otherwise the latest revision —
648
+ // so a workspace that has never seen a git repository still gets a
649
+ // series with ids in it, and the "this change made it worse" table works.
650
+ commit: string | null = deployedCommit(tenant, workspace)?.commit ?? latestRevisionId(tenant, workspace),
651
+ ) {
652
+ const dir = path.join(workspaceDir(tenant, workspace), "evals", ".results");
653
+ fs.mkdirSync(dir, { recursive: true });
654
+ fs.writeFileSync(path.join(dir, `${result.eval}.json`), JSON.stringify(result, null, 2));
655
+ const line: EvalHistoryEntry = {
656
+ eval: result.eval,
657
+ at: result.finishedAt,
658
+ commit,
659
+ passed: result.passed,
660
+ failed: result.failed,
661
+ costUsd: result.costUsd,
662
+ };
663
+ fs.appendFileSync(path.join(dir, "history.jsonl"), `${JSON.stringify(line)}\n`);
664
+ }
665
+
666
+ export interface EvalHistoryEntry {
667
+ eval: string;
668
+ at: string;
669
+ /** The commit the workspace was running. Null on an install that has
670
+ * never deployed from git — the series still exists, just unattributed. */
671
+ commit: string | null;
672
+ passed: number;
673
+ failed: number;
674
+ costUsd: number;
675
+ }
676
+
677
+ export function readEvalHistory(tenant: string, workspace: string): EvalHistoryEntry[] {
678
+ const file = path.join(workspaceDir(tenant, workspace), "evals", ".results", "history.jsonl");
679
+ if (!fs.existsSync(file)) return [];
680
+ return fs
681
+ .readFileSync(file, "utf8")
682
+ .split("\n")
683
+ .filter(Boolean)
684
+ .flatMap((l) => {
685
+ try {
686
+ return [JSON.parse(l) as EvalHistoryEntry];
687
+ } catch {
688
+ return []; // a torn line from a crash mid-append is not a reason to lose the rest
689
+ }
690
+ });
691
+ }
692
+
693
+ export interface EvalTrend {
694
+ eval: string;
695
+ /** Newest first. */
696
+ latest: EvalHistoryEntry;
697
+ /** The most recent run on a DIFFERENT commit — the comparison that names a
698
+ * change. Null when every run so far was on the same commit. */
699
+ previous: EvalHistoryEntry | null;
700
+ /** Pass count now minus pass count on the previous commit. */
701
+ delta: number | null;
702
+ }
703
+
704
+ /**
705
+ * Per eval: where it stands, and against which commit that is a change.
706
+ *
707
+ * Compares to the last run on a different commit rather than the last run,
708
+ * because two runs on one commit differ only by the model's mood — and a
709
+ * "regression" that is really variance would teach people to ignore the
710
+ * real ones.
711
+ */
712
+ export function evalTrends(tenant: string, workspace: string): EvalTrend[] {
713
+ const history = readEvalHistory(tenant, workspace).sort((a, b) => b.at.localeCompare(a.at));
714
+ const byEval = new Map<string, EvalHistoryEntry[]>();
715
+ for (const h of history) {
716
+ if (!byEval.has(h.eval)) byEval.set(h.eval, []);
717
+ byEval.get(h.eval)!.push(h);
718
+ }
719
+ return [...byEval.entries()]
720
+ .map(([name, runs]) => {
721
+ const latest = runs[0];
722
+ const previous = runs.find((r) => r.commit !== latest.commit) ?? null;
723
+ return {
724
+ eval: name,
725
+ latest,
726
+ previous,
727
+ delta: previous ? latest.passed - previous.passed : null,
728
+ };
729
+ })
730
+ .sort((a, b) => (a.delta ?? 0) - (b.delta ?? 0) || a.eval.localeCompare(b.eval));
731
+ }
732
+
733
+ /**
734
+ * Run every eval against what was just deployed, stamped with its commit.
735
+ *
736
+ * Fire-and-forget from the deploy: a deploy answers in milliseconds and an
737
+ * eval takes minutes and money, so the two are never in one request. Runs
738
+ * one at a time — evals are model calls and a deploy should not fan out
739
+ * into all of them at once. A second deploy while one is still evaluating
740
+ * waits its turn behind the lock rather than doubling the spend.
741
+ */
742
+ export async function evaluateDeployed(
743
+ tenant: string,
744
+ workspace: string,
745
+ // The commit when the deploy knows one (a git push, `deploy --commit`);
746
+ // otherwise the revision the save just recorded. A plain `foldrun deploy`
747
+ // passes no commit, and until this defaulted the eval never ran for it —
748
+ // "every deploy is evaluated" was true only of pushes.
749
+ commit: string | null = null,
750
+ ): Promise<{ ran: number; skipped: string | null }> {
751
+ const stamp = commit ?? deployedCommit(tenant, workspace)?.commit ?? latestRevisionId(tenant, workspace);
752
+ if (!stamp) return { ran: 0, skipped: "no revision to stamp the result with" };
753
+ // Only the evals that asked to run on deploy — a `trigger: manual` eval is
754
+ // one a person runs on purpose, because it costs a whole flow run.
755
+ const evals = listEvals(tenant, workspace).filter((e) => e.trigger !== "manual");
756
+ if (evals.length === 0) return { ran: 0, skipped: "no evals" };
757
+ const dir = path.join(workspaceDir(tenant, workspace), "evals", ".results");
758
+ fs.mkdirSync(dir, { recursive: true });
759
+ const lock = path.join(dir, ".evaluating");
760
+ // A lock older than an hour is a crash, not a run.
761
+ try {
762
+ if (Date.now() - fs.statSync(lock).mtimeMs < 60 * 60_000) {
763
+ return { ran: 0, skipped: "evals already running for an earlier deploy" };
764
+ }
765
+ } catch {
766
+ // no lock
767
+ }
768
+ fs.writeFileSync(lock, stamp);
769
+ let ran = 0;
770
+ try {
771
+ for (const info of evals) {
772
+ try {
773
+ const result = await runEval(tenant, workspace, info);
774
+ writeEvalResult(tenant, workspace, result, stamp);
775
+ ran += 1;
776
+ } catch (err) {
777
+ // One eval failing to run must not stop the rest; it is recorded as
778
+ // a run with nothing passing, so the series shows the gap — and with
779
+ // the reason, so "0 of 2" on a fresh workspace reads "secret
780
+ // LINKEDIN_OAUTH is not set" rather than "two wrong answers".
781
+ writeEvalResult(
782
+ tenant,
783
+ workspace,
784
+ {
785
+ eval: info.name,
786
+ startedAt: new Date().toISOString(),
787
+ finishedAt: new Date().toISOString(),
788
+ passed: 0,
789
+ failed: info.cases.length,
790
+ costUsd: 0,
791
+ cases: [],
792
+ error: err instanceof Error ? err.message : String(err),
793
+ } as EvalResult,
794
+ stamp,
795
+ );
796
+ }
797
+ }
798
+ } finally {
799
+ fs.rmSync(lock, { force: true });
800
+ }
801
+ return { ran, skipped: null };
802
+ }
803
+
804
+ export function readEvalResult(tenant: string, workspace: string, name: string): EvalResult | null {
805
+ try {
806
+ assertSafeName(name, "eval");
807
+ const file = path.join(workspaceDir(tenant, workspace), "evals", ".results", `${name}.json`);
808
+ return fs.existsSync(file) ? (JSON.parse(fs.readFileSync(file, "utf8")) as EvalResult) : null;
809
+ } catch {
810
+ return null;
811
+ }
812
+ }