@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
@@ -0,0 +1,498 @@
1
+ // The model loop, extracted to run in two places: the server process (the
2
+ // classic path) and the runner container's driver (the isolated path). One
3
+ // implementation, because the moment these fork, the sandboxed path becomes
4
+ // the less-tested one — backwards from its whole purpose.
5
+ //
6
+ // Everything here takes values, not stores: the caller resolves secrets,
7
+ // assembles the system prompt and builds MCP servers, then hands this the
8
+ // results. That is what lets the same function run somewhere the vault, the
9
+ // library and the account do not exist.
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { query } from "@anthropic-ai/claude-agent-sdk";
14
+ import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
15
+ import type { Effort } from "./store.ts";
16
+ import { spawn } from "node:child_process";
17
+ import { checkPaths, checkBash, isFilesystemTool } from "./confine.ts";
18
+
19
+ export interface ExecOutcome {
20
+ status: "completed" | "failed";
21
+ result: string | null;
22
+ /** The LAST text block, which is the model's answer. `result` is every
23
+ * block joined, and the first of those is usually narration before a tool
24
+ * call — so anything wanting "what did this conclude" must read this. */
25
+ conclusion: string | null;
26
+ /** The JSON an `output: json` step returned — parsed, so the next step
27
+ * gets the value and not a re-extraction of it from prose. Undefined for
28
+ * a step that declared no output shape. */
29
+ data?: unknown;
30
+ costUsd: number | null;
31
+ /** Token counts off the SDK's result message. costUsd is priced from
32
+ * Anthropic's table, which is wrong for a routed model — these are the
33
+ * raw numbers a caller with a gateway's own prices can reprice from. */
34
+ usage: { inputTokens: number; outputTokens: number } | null;
35
+ }
36
+
37
+ export interface ExecOptions {
38
+ agentDir: string;
39
+ workspaceRoot: string;
40
+ libraryRoot: string;
41
+ prompt: string;
42
+ model: string;
43
+ /** How hard the model thinks before answering — orthogonal to which model
44
+ * it is. Null leaves it to the SDK's own default rather than guessing a
45
+ * level on the author's behalf. */
46
+ effort?: Effort | null;
47
+ systemPrompt: string;
48
+ /** Exact SDK tool names the agent may use. */
49
+ allowed: string[];
50
+ /** MCP server names the agent was granted — tools from these pass. */
51
+ mcpNames: string[];
52
+ mcpServers: Record<string, McpServerConfig>;
53
+ /** The child environment: process env + secrets + provider. */
54
+ env: Record<string, string | undefined>;
55
+ timeoutSec?: number;
56
+ /** The most this step may spend, in USD, before it is stopped mid-turn.
57
+ * The runner derives it from the flow's `budget:` and what the run has
58
+ * already spent; absent means no ceiling on this step. */
59
+ budgetUsd?: number | null;
60
+ /** Price per token for the model, when the catalogue knows it, so spend
61
+ * can be counted turn by turn instead of learned at the end. Without it
62
+ * the step prices its turns from the SDK's own running total. */
63
+ price?: { input: number; output: number } | null;
64
+ /** A check the step must pass to count as done: a shell command that must
65
+ * exit 0, or an eval-style assertion (`contains: x`, `not-contains: x`,
66
+ * `matches: re`, `file: path`, `judge: sentence`) — see checkVerify. */
67
+ verify?: string;
68
+ verifyEnv?: Record<string, string>;
69
+ /** `output: json` — the reply must carry one JSON value; extracting it is
70
+ * part of finishing the step, and failing to is failing the step. */
71
+ output?: "json";
72
+ /** false when the caller is already an isolation boundary (a run
73
+ * container): the SDK's bash sandbox is then redundant and would block
74
+ * declared network use. Default (undefined/true) keeps it on. */
75
+ sandboxBash?: boolean;
76
+ emit: (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
77
+ }
78
+
79
+ /** The pairing fields on a tool event — see RunEvent in store.ts. */
80
+ export type EventExtra = { call?: string; ms?: number; err?: boolean };
81
+
82
+ /** Conservative per-token rates for a model the catalogue cannot price —
83
+ * Opus-class — so a ceiling still means something on an unknown gateway. */
84
+ export const FALLBACK_PRICE = { input: 15e-6, output: 75e-6 };
85
+
86
+ /**
87
+ * What one assistant turn cost. Cache traffic is counted as input at the
88
+ * input rate, which over-approximates on gateways with cheaper cache reads
89
+ * — on the money side, the honest direction to be wrong in. Pure.
90
+ */
91
+ export function priceTurn(u: Record<string, number | undefined>, price: { input: number; output: number } | null): number {
92
+ const inTok = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
93
+ const outTok = u.output_tokens ?? 0;
94
+ const p = price ?? FALLBACK_PRICE;
95
+ return inTok * p.input + outTok * p.output;
96
+ }
97
+
98
+ /**
99
+ * Each step launched together gets an equal share of what is left of the
100
+ * run's budget: the shares sum to the remainder, so a group cannot end
101
+ * over the cap however many steps it fans out to. No budget, no ceiling.
102
+ */
103
+ export function stepCeiling(budgetUsd: number | null | undefined, spentUsd: number, launching: number): number | null {
104
+ if (!budgetUsd || budgetUsd <= 0) return null;
105
+ return Math.max(0, budgetUsd - spentUsd) / Math.max(1, launching);
106
+ }
107
+
108
+ export async function executeStep(opts: ExecOptions): Promise<ExecOutcome> {
109
+ const { agentDir, workspaceRoot, libraryRoot, emit } = opts;
110
+ let status: "running" | "completed" | "failed" = "running";
111
+ let costUsd: number | null = null;
112
+ let usage: ExecOutcome["usage"] = null;
113
+ const texts: string[] = [];
114
+
115
+ fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
116
+ fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
117
+
118
+ const q = query({
119
+ prompt: opts.prompt,
120
+ options: {
121
+ cwd: agentDir,
122
+ model: opts.model,
123
+ // Omitted, not passed as undefined-with-a-default: an unset effort
124
+ // should mean "whatever this model does normally", which is not a
125
+ // level we can name — it moves as models ship.
126
+ ...(opts.effort ? { effort: opts.effort } : {}),
127
+ systemPrompt: opts.systemPrompt,
128
+ // Restrict the toolset itself, not just approval: an agent that
129
+ // declares no tools gets none, instead of seeing the full Claude
130
+ // Code toolset and burning turns on denied calls.
131
+ tools: opts.allowed,
132
+ // Deliberately NOT allowedTools: listing them there auto-approves and
133
+ // skips canUseTool entirely, which is where confinement happens. The
134
+ // toolset is still restricted by `tools` above.
135
+ settingSources: [],
136
+ // Sandbox the shell — but only when the shell would otherwise run in a
137
+ // process worth protecting. In an isolated run the container IS the
138
+ // boundary (non-root, cap-dropped, host-less, icc-disabled network),
139
+ // so the SDK's own bash sandbox is redundant there and actively harms:
140
+ // it blocks outbound network, which a declared SSH or curl step
141
+ // legitimately needs. So the container relaxes it (opts.sandboxBash =
142
+ // false) and the container's walls do the containing; the in-process
143
+ // path (the CLI on someone's laptop, and the host executor) keeps it,
144
+ // because there a shell escape reaches the real machine.
145
+ sandbox: {
146
+ enabled: opts.sandboxBash !== false,
147
+ // Must stay false. Auto-allowing bash because it's sandboxed skips
148
+ // canUseTool entirely — and the OS sandbox blocks writes outside the
149
+ // tree but still permits reads, so `cat ../../secrets.json` walked
150
+ // straight out. Verified by probe: true leaks, false denies.
151
+ autoAllowBashIfSandboxed: false,
152
+ failIfUnavailable: false,
153
+ },
154
+ env: opts.env,
155
+ mcpServers: opts.mcpServers,
156
+ canUseTool: async (toolName: string, input: Record<string, unknown>) => {
157
+ // The toolset was already narrowed to what the agent declared, so
158
+ // anything outside it is a denial with a reason the model can act on.
159
+ const fromGrantedServer = opts.mcpNames.some((n) => toolName.startsWith(`mcp__${n}__`));
160
+ if (!opts.allowed.includes(toolName) && !fromGrantedServer) {
161
+ return {
162
+ behavior: "deny" as const,
163
+ message: `Tool ${toolName} is not enabled for this agent.`,
164
+ };
165
+ }
166
+ const verdict =
167
+ toolName === "Bash"
168
+ ? checkBash(String(input.command ?? ""))
169
+ : isFilesystemTool(toolName)
170
+ ? checkPaths(toolName, input, { agentDir, workspaceRoot, libraryRoot })
171
+ : { ok: true as const };
172
+ if (!verdict.ok) {
173
+ emit("error", verdict.reason!);
174
+ return { behavior: "deny" as const, message: verdict.reason! };
175
+ }
176
+ return { behavior: "allow" as const, updatedInput: verdict.updatedInput ?? input };
177
+ },
178
+ },
179
+ });
180
+
181
+ const deadline = opts.timeoutSec ? Date.now() + opts.timeoutSec * 1000 : null;
182
+ // The hard cap. Spend is counted as each assistant turn arrives — its
183
+ // usage is in the message — so the step can stop at the ceiling rather
184
+ // than learn afterwards that it crossed it. Cache traffic is counted as
185
+ // input at the input rate, which over-approximates: on the money side
186
+ // that is the honest direction to be wrong in.
187
+ const ceiling = typeof opts.budgetUsd === "number" && opts.budgetUsd > 0 ? opts.budgetUsd : null;
188
+ let spentUsd = 0;
189
+
190
+ // Open tool calls, by the provider's id, so the result can be paired with
191
+ // its call and the trace can say how long each tool ran.
192
+ const openCalls = new Map<string, { name: string; at: number }>();
193
+
194
+ for await (const message of q) {
195
+ if (deadline && Date.now() > deadline) {
196
+ emit("error", `step exceeded its ${opts.timeoutSec}s timeout`);
197
+ status = "failed";
198
+ break;
199
+ }
200
+ if (message.type === "assistant") {
201
+ const u = (message.message as unknown as { usage?: Record<string, number | undefined> }).usage;
202
+ if (ceiling && u) {
203
+ spentUsd += priceTurn(u, opts.price ?? null);
204
+ if (spentUsd >= ceiling) {
205
+ emit("error", `over budget — this step reached $${spentUsd.toFixed(4)} of its $${ceiling.toFixed(4)} ceiling mid-turn and was stopped (budget: in the flow file)`);
206
+ status = "failed";
207
+ break;
208
+ }
209
+ }
210
+ for (const block of message.message.content) {
211
+ if (block.type === "text" && block.text.trim()) {
212
+ texts.push(block.text);
213
+ emit("text", block.text);
214
+ } else if (block.type === "tool_use") {
215
+ openCalls.set(block.id, { name: block.name, at: Date.now() });
216
+ emit("tool", block.name, { call: block.id });
217
+ }
218
+ }
219
+ } else if (message.type === "user") {
220
+ // Tool results ride back as user turns. The completion event closes
221
+ // the span the call opened; the runner's journal keeps both.
222
+ const content = message.message.content;
223
+ if (Array.isArray(content)) {
224
+ for (const block of content) {
225
+ if (block.type !== "tool_result") continue;
226
+ const open = openCalls.get(block.tool_use_id);
227
+ if (!open) continue;
228
+ openCalls.delete(block.tool_use_id);
229
+ emit("tool", open.name, {
230
+ call: block.tool_use_id,
231
+ ms: Date.now() - open.at,
232
+ ...(block.is_error ? { err: true } : {}),
233
+ });
234
+ }
235
+ }
236
+ } else if (message.type === "result") {
237
+ status = message.subtype === "success" ? "completed" : "failed";
238
+ costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
239
+ if ("usage" in message && message.usage) {
240
+ const u = message.usage as unknown as Record<string, number | undefined>;
241
+ usage = {
242
+ // Cache traffic is input the provider still bills (at its own
243
+ // rates); folding it into the input count over-approximates for
244
+ // gateways with cheaper cache reads, which errs on the honest side.
245
+ inputTokens:
246
+ (u.input_tokens ?? 0) +
247
+ (u.cache_creation_input_tokens ?? 0) +
248
+ (u.cache_read_input_tokens ?? 0),
249
+ outputTokens: u.output_tokens ?? 0,
250
+ };
251
+ }
252
+ }
253
+ }
254
+ // The SDK ends a healthy run with a `result` message. A stream that just
255
+ // stops — subprocess OOM-killed, crashed, or torn down — used to fall
256
+ // through as "completed", which reported a run that produced nothing as a
257
+ // success. Silence is not success.
258
+ if (status === "running") {
259
+ status = "failed";
260
+ emit(
261
+ "error",
262
+ "the model stream ended without a result — the model process likely died (out of memory?)",
263
+ );
264
+ }
265
+ const result = texts.join("\n").trim() || null;
266
+ // A step's reply is not one message: the model narrates ("Now let me read
267
+ // the outputs…"), calls tools, and answers last. Joining those and reading
268
+ // the first line — which is what the run summary did — reports the plan
269
+ // and never the outcome. The final block is the answer; keep it so the
270
+ // summary has something true to read.
271
+ const conclusion = texts.at(-1)?.trim() || null;
272
+
273
+ // output: json — the declared shape is a contract, checked here where both
274
+ // executors run. A reply with no JSON in it is not "done with a caveat";
275
+ // it is the failure the next step would otherwise inherit as garbage.
276
+ let data: unknown = undefined;
277
+ if (status === "completed" && opts.output === "json") {
278
+ const extracted = extractJson(result);
279
+ if (extracted.ok) {
280
+ data = extracted.value;
281
+ emit("info", `output: json — ${describeJson(data)}`);
282
+ } else {
283
+ emit("error", `output: json — ${extracted.reason}`);
284
+ status = "failed";
285
+ }
286
+ }
287
+
288
+ // "Done" should mean a check passed, not that the model stopped talking.
289
+ if (status === "completed" && opts.verify) {
290
+ const verdict = await checkVerify(agentDir, opts.verify, {
291
+ env: opts.verifyEnv ?? {},
292
+ result,
293
+ conclusion,
294
+ data,
295
+ modelEnv: opts.env,
296
+ });
297
+ emit(verdict.ok ? "info" : "error", `verify \`${opts.verify}\` → ${verdict.headline}`);
298
+ if (verdict.detail.trim()) emit(verdict.ok ? "info" : "error", verdict.detail.slice(0, 1000));
299
+ if (!verdict.ok) status = "failed";
300
+ }
301
+
302
+ return { status, result, conclusion, ...(opts.output ? { data } : {}), costUsd, usage };
303
+ }
304
+
305
+ // ------------------------------------------------------------ output: json
306
+
307
+ /**
308
+ * The one JSON value a reply carries. A ```json fence wins — it is what the
309
+ * step was asked to write — and the LAST one at that, because a model that
310
+ * shows its working often quotes an earlier draft first. Without a fence,
311
+ * the reply's trailing `{…}` or `[…]` is tried, so a model that answered
312
+ * with bare JSON is not failed for good behaviour.
313
+ */
314
+ export function extractJson(result: string | null): { ok: true; value: unknown } | { ok: false; reason: string } {
315
+ const text = (result ?? "").trim();
316
+ if (!text) return { ok: false, reason: "the reply was empty" };
317
+ const fences = [...text.matchAll(/```(?:json|JSON)?\s*\n([\s\S]*?)\n\s*```/g)].map((m) => m[1].trim());
318
+ const candidates = fences.length ? fences.reverse() : [];
319
+ if (!candidates.length) {
320
+ // The largest trailing bracketed span: walk back from the end to the
321
+ // last closing bracket, then forward to the matching opener.
322
+ const close = Math.max(text.lastIndexOf("}"), text.lastIndexOf("]"));
323
+ if (close !== -1) {
324
+ const opener = text[close] === "}" ? "{" : "[";
325
+ for (let i = text.indexOf(opener); i !== -1 && i < close; i = text.indexOf(opener, i + 1)) {
326
+ candidates.push(text.slice(i, close + 1));
327
+ }
328
+ }
329
+ }
330
+ let lastError = "no JSON value found in the reply";
331
+ for (const c of candidates) {
332
+ try {
333
+ return { ok: true, value: JSON.parse(c) };
334
+ } catch (err) {
335
+ lastError = `could not parse: ${err instanceof Error ? err.message : String(err)}`;
336
+ }
337
+ }
338
+ return { ok: false, reason: lastError };
339
+ }
340
+
341
+ function describeJson(value: unknown): string {
342
+ if (Array.isArray(value)) return `an array of ${value.length}`;
343
+ if (value && typeof value === "object") return `an object with keys ${Object.keys(value).slice(0, 8).join(", ")}`;
344
+ return `a ${typeof value}`;
345
+ }
346
+
347
+ // ----------------------------------------------------------------- verify:
348
+
349
+ /** The eval vocabulary a `verify:` may borrow. Anything else is a shell command. */
350
+ const VERIFY_ASSERTION = /^(contains|not-contains|matches|file|judge):\s*([\s\S]+)$/;
351
+
352
+ export interface VerifyVerdict {
353
+ ok: boolean;
354
+ /** One line for the trace: "exit 0", "found", "PASS"… */
355
+ headline: string;
356
+ detail: string;
357
+ }
358
+
359
+ /**
360
+ * Decide a `verify:`. Two dialects, one key: an eval assertion, when the
361
+ * value starts with one of the eval file's assertion words, or a shell
362
+ * command. The assertion form exists so that a flow and an eval share a
363
+ * vocabulary — "the output must mention the price" is the same sentence in
364
+ * both places — and so that the commonest checks need no shell at all.
365
+ */
366
+ export async function checkVerify(
367
+ agentDir: string,
368
+ verify: string,
369
+ ctx: {
370
+ env: Record<string, string>;
371
+ result: string | null;
372
+ /** The final turn — what the step concluded. `contains:`, `not-contains:`
373
+ * and `matches:` test this, because it is also what the run's headline
374
+ * is read from: a reporter that narrated between tool calls ("Now let
375
+ * me write the report…") failed `matches: ^BAD\b` with a correct
376
+ * headline while the check read every turn joined. `judge:` still grades
377
+ * the whole result. */
378
+ conclusion?: string | null;
379
+ data?: unknown;
380
+ /** The step's own model environment, for `judge:` — it grades on the
381
+ * fast tier through the same credential the step rode. */
382
+ modelEnv?: Record<string, string | undefined>;
383
+ },
384
+ ): Promise<VerifyVerdict> {
385
+ const m = verify.trim().match(VERIFY_ASSERTION);
386
+ if (!m) {
387
+ const { code, out } = await runVerify(agentDir, verify, ctx.env, ctx.data);
388
+ return { ok: code === 0, headline: `exit ${code ?? "error"}`, detail: out };
389
+ }
390
+ const [, kind, rawValue] = m;
391
+ const value = rawValue.trim().replace(/^["']|["']$/g, "");
392
+ const output = (kind === "judge" ? ctx.result : (ctx.conclusion ?? ctx.result)) ?? "";
393
+ const hay = output.toLowerCase();
394
+ switch (kind) {
395
+ case "contains": {
396
+ const ok = hay.includes(value.toLowerCase());
397
+ return { ok, headline: ok ? "found" : `"${value}" not in the reply`, detail: "" };
398
+ }
399
+ case "not-contains": {
400
+ const ok = !hay.includes(value.toLowerCase());
401
+ return { ok, headline: ok ? "absent" : `"${value}" appeared in the reply`, detail: "" };
402
+ }
403
+ case "matches": {
404
+ try {
405
+ const ok = new RegExp(value, "i").test(output);
406
+ return { ok, headline: ok ? "matched" : "no match", detail: "" };
407
+ } catch {
408
+ return { ok: false, headline: "invalid regular expression", detail: "" };
409
+ }
410
+ }
411
+ case "file": {
412
+ const target = path.resolve(agentDir, value);
413
+ if (!target.startsWith(path.resolve(agentDir) + path.sep)) {
414
+ return { ok: false, headline: "path escapes the agent directory", detail: "" };
415
+ }
416
+ const ok = fs.existsSync(target) && fs.statSync(target).size > 0;
417
+ return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
418
+ }
419
+ case "judge": {
420
+ const verdict = await judgeReply(value, output, ctx.modelEnv ?? {});
421
+ const ok = /^\s*PASS\b/i.test(verdict);
422
+ return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
423
+ }
424
+ }
425
+ return { ok: false, headline: `unknown check "${kind}"`, detail: "" };
426
+ }
427
+
428
+ /**
429
+ * A toolless, fast-tier grading call: does the reply satisfy the sentence?
430
+ * Same shape as the eval judge, and answered with one word first so the
431
+ * verdict is a prefix test rather than a reading.
432
+ */
433
+ async function judgeReply(
434
+ rubric: string,
435
+ output: string,
436
+ env: Record<string, string | undefined>,
437
+ ): Promise<string> {
438
+ const texts: string[] = [];
439
+ try {
440
+ const q = query({
441
+ prompt:
442
+ `You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
443
+ `first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
444
+ `<reply>\n${output.slice(0, 40_000)}\n</reply>`,
445
+ options: {
446
+ model: "haiku",
447
+ systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
448
+ tools: [],
449
+ settingSources: [],
450
+ env,
451
+ maxTurns: 1,
452
+ },
453
+ });
454
+ for await (const message of q) {
455
+ if (message.type === "assistant") {
456
+ for (const block of message.message.content) {
457
+ if (block.type === "text") texts.push(block.text);
458
+ }
459
+ }
460
+ }
461
+ } catch (err) {
462
+ return `FAIL — the judge could not run: ${err instanceof Error ? err.message : String(err)}`;
463
+ }
464
+ return texts.join("\n").trim();
465
+ }
466
+
467
+ // Verification runs in the agent's directory with its secrets available, so
468
+ // a check can be as simple as `npm run build` or `test -s outputs/report.md`.
469
+ function runVerify(
470
+ agentDir: string,
471
+ command: string,
472
+ env: Record<string, string>,
473
+ data?: unknown,
474
+ ): Promise<{ code: number | null; out: string }> {
475
+ return new Promise((resolve) => {
476
+ // No clock of the platform's: a verify like `npm run build` takes what
477
+ // it takes, and the step's own `timeout:` is the bound if the flow set one.
478
+ const child = spawn("bash", ["-lc", command], {
479
+ cwd: agentDir,
480
+ env: { ...process.env, ...env },
481
+ stdio: ["pipe", "pipe", "pipe"],
482
+ });
483
+ // An `output: json` step's data arrives on stdin, so a check can be
484
+ // `jq -e '.total > 0'` — arithmetic in a real tool, reading the value
485
+ // the step actually returned rather than re-parsing its prose.
486
+ child.stdin.on("error", () => {});
487
+ if (data !== undefined) child.stdin.end(JSON.stringify(data));
488
+ else child.stdin.end();
489
+ let out = "";
490
+ const append = (c: Buffer) => {
491
+ if (out.length < 4000) out += c.toString();
492
+ };
493
+ child.stdout.on("data", append);
494
+ child.stderr.on("data", append);
495
+ child.on("error", (e) => resolve({ code: null, out: e.message }));
496
+ child.on("close", (code) => resolve({ code, out }));
497
+ });
498
+ }