@foldrun/core 0.2.0 → 0.4.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 (85) hide show
  1. package/README.md +31 -10
  2. package/dist/src/approvals.d.ts +104 -1
  3. package/dist/src/approvals.js +192 -2
  4. package/dist/src/budget.d.ts +32 -0
  5. package/dist/src/budget.js +177 -0
  6. package/dist/src/catalog.js +2 -1
  7. package/dist/src/confine.js +6 -1
  8. package/dist/src/deploy.js +4 -2
  9. package/dist/src/egress.d.ts +19 -2
  10. package/dist/src/egress.js +2 -1
  11. package/dist/src/evals.d.ts +4 -0
  12. package/dist/src/evals.js +20 -11
  13. package/dist/src/flow-lint.d.ts +4 -0
  14. package/dist/src/flow-lint.js +108 -0
  15. package/dist/src/host-env.d.ts +8 -0
  16. package/dist/src/host-env.js +48 -0
  17. package/dist/src/json-schema.d.ts +12 -0
  18. package/dist/src/json-schema.js +202 -0
  19. package/dist/src/library.js +13 -1
  20. package/dist/src/notify.d.ts +79 -1
  21. package/dist/src/notify.js +243 -9
  22. package/dist/src/paths.d.ts +5 -0
  23. package/dist/src/paths.js +32 -0
  24. package/dist/src/platform.d.ts +112 -2
  25. package/dist/src/platform.js +20 -2
  26. package/dist/src/provider-health.d.ts +55 -0
  27. package/dist/src/provider-health.js +232 -0
  28. package/dist/src/run-container.d.ts +80 -4
  29. package/dist/src/run-container.js +176 -37
  30. package/dist/src/runner.d.ts +41 -5
  31. package/dist/src/runner.js +513 -81
  32. package/dist/src/script-tools.d.ts +12 -0
  33. package/dist/src/script-tools.js +10 -2
  34. package/dist/src/secret-health.d.ts +59 -0
  35. package/dist/src/secret-health.js +155 -0
  36. package/dist/src/secrets.d.ts +19 -1
  37. package/dist/src/secrets.js +44 -12
  38. package/dist/src/starter.js +4 -0
  39. package/dist/src/step-exec.d.ts +62 -6
  40. package/dist/src/step-exec.js +199 -68
  41. package/dist/src/store.d.ts +300 -3
  42. package/dist/src/store.js +574 -26
  43. package/dist/src/tar.js +2 -1
  44. package/dist/src/test-mode.d.ts +111 -0
  45. package/dist/src/test-mode.js +285 -0
  46. package/dist/src/tool-test.js +48 -21
  47. package/dist/src/translator.js +2 -1
  48. package/dist/src/trigger-log.d.ts +43 -0
  49. package/dist/src/trigger-log.js +122 -0
  50. package/dist/src/triggers.d.ts +20 -0
  51. package/dist/src/triggers.js +91 -10
  52. package/dist/src/webhook.d.ts +15 -3
  53. package/dist/src/webhook.js +11 -11
  54. package/package.json +6 -5
  55. package/src/approvals.ts +253 -2
  56. package/src/budget.ts +178 -0
  57. package/src/catalog.ts +2 -1
  58. package/src/confine.ts +6 -1
  59. package/src/deploy.ts +4 -1
  60. package/src/egress.ts +20 -4
  61. package/src/evals.ts +25 -9
  62. package/src/flow-lint.ts +118 -0
  63. package/src/host-env.ts +51 -0
  64. package/src/json-schema.ts +184 -0
  65. package/src/library.ts +13 -1
  66. package/src/notify.ts +272 -11
  67. package/src/paths.ts +29 -0
  68. package/src/platform.ts +119 -3
  69. package/src/provider-health.ts +274 -0
  70. package/src/run-container.ts +215 -35
  71. package/src/runner.ts +525 -73
  72. package/src/script-tools.ts +18 -2
  73. package/src/secret-health.ts +185 -0
  74. package/src/secrets.ts +44 -10
  75. package/src/starter.ts +4 -0
  76. package/src/step-exec.ts +176 -21
  77. package/src/storage.ts +8 -2
  78. package/src/store.ts +785 -25
  79. package/src/tar.ts +2 -1
  80. package/src/test-mode.ts +311 -0
  81. package/src/tool-test.ts +49 -20
  82. package/src/translator.ts +2 -1
  83. package/src/trigger-log.ts +159 -0
  84. package/src/triggers.ts +92 -10
  85. package/src/webhook.ts +17 -11
package/src/step-exec.ts CHANGED
@@ -11,10 +11,13 @@
11
11
  import fs from "node:fs";
12
12
  import path from "node:path";
13
13
  import { query } from "@anthropic-ai/claude-agent-sdk";
14
- import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
14
+ import type { McpServerConfig, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
15
15
  import type { Effort } from "./store.ts";
16
+ import type { TestEffect } from "./test-mode.ts";
16
17
  import { spawn } from "node:child_process";
17
18
  import { checkPaths, checkBash, isFilesystemTool } from "./confine.ts";
19
+ import { hostSafeEnv } from "./host-env.ts";
20
+ import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
18
21
 
19
22
  export interface ExecOutcome {
20
23
  status: "completed" | "failed";
@@ -57,6 +60,9 @@ export interface ExecOptions {
57
60
  * The runner derives it from the flow's `budget:` and what the run has
58
61
  * already spent; absent means no ceiling on this step. */
59
62
  budgetUsd?: number | null;
63
+ /** Which line set the ceiling, for the error that names it — "budget: in
64
+ * the flow file" or "budget: on the <agent> agent". */
65
+ budgetNote?: string;
60
66
  /** Price per token for the model, when the catalogue knows it, so spend
61
67
  * can be counted turn by turn instead of learned at the end. Without it
62
68
  * the step prices its turns from the SDK's own running total. */
@@ -69,30 +75,93 @@ export interface ExecOptions {
69
75
  /** `output: json` — the reply must carry one JSON value; extracting it is
70
76
  * part of finishing the step, and failing to is failing the step. */
71
77
  output?: "json";
78
+ /** `schema:` — what that value must look like; a value that does not fit
79
+ * fails the step naming the field. See json-schema.ts. */
80
+ schema?: Record<string, unknown> | boolean;
81
+ /** `max_turns:` — the most model turns before the step is stopped. The
82
+ * SDK enforces it; the step reads the result's reason and says so. */
83
+ maxTurns?: number;
72
84
  /** false when the caller is already an isolation boundary (a run
73
85
  * container): the SDK's bash sandbox is then redundant and would block
74
86
  * declared network use. Default (undefined/true) keeps it on. */
75
87
  sandboxBash?: boolean;
88
+ /** Has a person asked for the run to stop? Polled every couple of
89
+ * seconds while the model loop runs; true interrupts it. The in-process
90
+ * runner answers from the run record (stopRun writes there from another
91
+ * process); a container has a sandbox that is destroyed instead. */
92
+ stopRequested?: () => boolean;
76
93
  emit: (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
77
94
  }
78
95
 
96
+ /** How often the stop flag is read while a step runs. */
97
+ export const STOP_POLL_MS = 2000;
98
+ /** After an interrupt is asked for, how long the model loop gets to wind
99
+ * down on its own before the query is aborted outright. */
100
+ export const INTERRUPT_GRACE_MS = 10_000;
101
+
102
+ /** The model loop, as executeStep needs it — the SDK's `query`, or a test's
103
+ * stand-in. Only the shape the loop reads. */
104
+ export type QueryLike = AsyncIterable<unknown> & { interrupt(): Promise<unknown> };
105
+ export type QueryFn = (args: { prompt: string; options: Record<string, unknown> }) => QueryLike;
106
+
79
107
  /** The pairing fields on a tool event — see RunEvent in store.ts. */
80
- export type EventExtra = { call?: string; ms?: number; err?: boolean };
108
+ export type EventExtra = { call?: string; ms?: number; err?: boolean; effect?: TestEffect };
81
109
 
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. */
110
+
111
+ /** Conservative per-token rates for a model nothing else can price — an
112
+ * unknown id on a gateway with no catalogue. Opus-class, so a ceiling
113
+ * still means something. Only ever reached when `knownPrice` below does
114
+ * not recognise the model either. */
84
115
  export const FALLBACK_PRICE = { input: 15e-6, output: 75e-6 };
85
116
 
117
+ /** The rates we know without asking anyone: the tiers this runtime resolves
118
+ * (`fast`/`default`/`max` → haiku/sonnet/opus) and the model ids that carry
119
+ * those words. A gateway with a catalogue still wins — that is the
120
+ * authority — but the platform's own Anthropic key has no catalogue, and
121
+ * pricing its steps at Opus rates made every mid-turn ceiling read up to
122
+ * fifteen times high: an indexing run was stopped at "$1.17" having
123
+ * actually spent $0.15 (2026-09-08). Per token, input/output. */
124
+ const KNOWN_PRICES: [RegExp, { input: number; output: number }][] = [
125
+ [/haiku/i, { input: 1e-6, output: 5e-6 }],
126
+ [/sonnet/i, { input: 2e-6, output: 10e-6 }],
127
+ [/\bopus\b/i, { input: 5e-6, output: 25e-6 }],
128
+ ];
129
+
130
+ /** What a token costs on this model, when we can say without a catalogue. */
131
+ export function knownPrice(model: string | undefined): { input: number; output: number } | null {
132
+ if (!model) return null;
133
+ return KNOWN_PRICES.find(([re]) => re.test(model))?.[1] ?? null;
134
+ }
135
+
136
+ /** Anthropic's cache multipliers, and the shape every gateway that bills
137
+ * for caching at all has copied: a write costs a quarter more than fresh
138
+ * input, a read a tenth of it. Counting both at the full input rate — as
139
+ * this did — makes a long cached prompt read about ten times its price,
140
+ * which on a ceiling is not "the honest direction to be wrong in", it is
141
+ * a step killed for spending money it never spent. */
142
+ const CACHE_WRITE_MULTIPLIER = 1.25;
143
+ const CACHE_READ_MULTIPLIER = 0.1;
144
+
86
145
  /**
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.
146
+ * What one assistant turn cost, from the usage the SDK reports. Pure.
147
+ *
148
+ * A gateway whose cache is priced differently is over- or under-counted
149
+ * here by that difference alone; the run record's own cost is repriced
150
+ * from the catalogue afterwards and stays the authority on the bill. This
151
+ * number exists to stop a runaway step mid-turn, and wants to be close.
90
152
  */
91
153
  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
154
  const p = price ?? FALLBACK_PRICE;
95
- return inTok * p.input + outTok * p.output;
155
+ const fresh = u.input_tokens ?? 0;
156
+ const written = u.cache_creation_input_tokens ?? 0;
157
+ const read = u.cache_read_input_tokens ?? 0;
158
+ const outTok = u.output_tokens ?? 0;
159
+ return (
160
+ fresh * p.input +
161
+ written * p.input * CACHE_WRITE_MULTIPLIER +
162
+ read * p.input * CACHE_READ_MULTIPLIER +
163
+ outTok * p.output
164
+ );
96
165
  }
97
166
 
98
167
  /**
@@ -105,7 +174,34 @@ export function stepCeiling(budgetUsd: number | null | undefined, spentUsd: numb
105
174
  return Math.max(0, budgetUsd - spentUsd) / Math.max(1, launching);
106
175
  }
107
176
 
108
- export async function executeStep(opts: ExecOptions): Promise<ExecOutcome> {
177
+ /**
178
+ * The ceiling a step actually runs under, and which line set it.
179
+ *
180
+ * Two caps can apply to one step: the flow's `budget:` gives it an equal
181
+ * share of what the run has left; the agent's own `budget:` is the most that
182
+ * agent may spend in one run, less what its earlier steps in this run cost.
183
+ * The tighter one wins, and the note says which, because "over budget" with
184
+ * no line to go and raise is a message that sends someone to the wrong file.
185
+ */
186
+ export function stepCeilingFor(
187
+ flowShareUsd: number | null,
188
+ agentBudgetUsd: number | null | undefined,
189
+ agentSpentUsd: number,
190
+ agent: string,
191
+ ): { ceilingUsd: number | null; note: string } {
192
+ const flow = { ceilingUsd: flowShareUsd, note: "budget: in the flow file" };
193
+ if (!agentBudgetUsd || agentBudgetUsd <= 0) return flow;
194
+ const left = Math.max(0, agentBudgetUsd - agentSpentUsd);
195
+ if (flowShareUsd !== null && flowShareUsd <= left) return flow;
196
+ return { ceilingUsd: left, note: `budget: on the ${agent} agent` };
197
+ }
198
+
199
+ export async function executeStep(
200
+ opts: ExecOptions,
201
+ /** The model loop. Injected so the clock and the stop can be tested
202
+ * without a model; production passes nothing and gets the SDK. */
203
+ runQuery: QueryFn = query as unknown as QueryFn,
204
+ ): Promise<ExecOutcome> {
109
205
  const { agentDir, workspaceRoot, libraryRoot, emit } = opts;
110
206
  let status: "running" | "completed" | "failed" = "running";
111
207
  let costUsd: number | null = null;
@@ -115,16 +211,23 @@ export async function executeStep(opts: ExecOptions): Promise<ExecOutcome> {
115
211
  fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
116
212
  fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
117
213
 
118
- const q = query({
214
+ // The one handle that can end the loop from outside: the timeout and a
215
+ // stop both ask the query to interrupt, and abort it outright if it has
216
+ // not wound down within the grace period.
217
+ const abort = new AbortController();
218
+
219
+ const q = runQuery({
119
220
  prompt: opts.prompt,
120
221
  options: {
121
222
  cwd: agentDir,
223
+ abortController: abort,
122
224
  model: opts.model,
123
225
  // Omitted, not passed as undefined-with-a-default: an unset effort
124
226
  // should mean "whatever this model does normally", which is not a
125
227
  // level we can name — it moves as models ship.
126
228
  ...(opts.effort ? { effort: opts.effort } : {}),
127
229
  systemPrompt: opts.systemPrompt,
230
+ ...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}),
128
231
  // Restrict the toolset itself, not just approval: an agent that
129
232
  // declares no tools gets none, instead of seeing the full Claude
130
233
  // Code toolset and burning turns on denied calls.
@@ -178,7 +281,34 @@ export async function executeStep(opts: ExecOptions): Promise<ExecOutcome> {
178
281
  },
179
282
  });
180
283
 
181
- const deadline = opts.timeoutSec ? Date.now() + opts.timeoutSec * 1000 : null;
284
+ // Why the loop was ended from outside, if it was. The deadline used to be
285
+ // checked only when a message arrived, so a step waiting on one long tool
286
+ // call — a crawl, a build — sailed past its timeout: with no message there
287
+ // was nothing to check it against. The backstop timer fires regardless,
288
+ // and a stop is read on a clock rather than between groups.
289
+ let ended: "timeout" | "stopped" | null = null;
290
+ const timers: NodeJS.Timeout[] = [];
291
+ const endWith = (why: "timeout" | "stopped") => {
292
+ if (ended) return;
293
+ ended = why;
294
+ void q.interrupt().catch(() => {});
295
+ timers.push(setTimeout(() => abort.abort(), INTERRUPT_GRACE_MS));
296
+ };
297
+ if (opts.timeoutSec) timers.push(setTimeout(() => endWith("timeout"), opts.timeoutSec * 1000));
298
+ if (opts.stopRequested) {
299
+ timers.push(setInterval(() => {
300
+ try {
301
+ if (opts.stopRequested!()) endWith("stopped");
302
+ } catch {
303
+ // an unreadable record is not a stop
304
+ }
305
+ }, STOP_POLL_MS));
306
+ }
307
+ // The clocks stay referenced on purpose: a step hanging in a tool call may
308
+ // be the only thing keeping the loop alive, and an unreferenced timer
309
+ // would let the process exit around it instead of ending it. They are all
310
+ // cleared when the step ends, so nothing outlives the step.
311
+
182
312
  // The hard cap. Spend is counted as each assistant turn arrives — its
183
313
  // usage is in the message — so the step can stop at the ceiling rather
184
314
  // than learn afterwards that it crossed it. Cache traffic is counted as
@@ -191,18 +321,15 @@ export async function executeStep(opts: ExecOptions): Promise<ExecOutcome> {
191
321
  // its call and the trace can say how long each tool ran.
192
322
  const openCalls = new Map<string, { name: string; at: number }>();
193
323
 
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
- }
324
+ try {
325
+ for await (const message of q as AsyncIterable<SDKMessage>) {
326
+ if (ended) break;
200
327
  if (message.type === "assistant") {
201
328
  const u = (message.message as unknown as { usage?: Record<string, number | undefined> }).usage;
202
329
  if (ceiling && u) {
203
330
  spentUsd += priceTurn(u, opts.price ?? null);
204
331
  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)`);
332
+ emit("error", `over budget — this step reached $${spentUsd.toFixed(4)} of its $${ceiling.toFixed(4)} ceiling mid-turn and was stopped (${opts.budgetNote ?? "budget: in the flow file"})`);
206
333
  status = "failed";
207
334
  break;
208
335
  }
@@ -235,6 +362,9 @@ export async function executeStep(opts: ExecOptions): Promise<ExecOutcome> {
235
362
  }
236
363
  } else if (message.type === "result") {
237
364
  status = message.subtype === "success" ? "completed" : "failed";
365
+ if (message.subtype === "error_max_turns") {
366
+ emit("error", `stopped after ${opts.maxTurns ?? "its"} turns (max_turns: in the flow file) — the step did not finish`);
367
+ }
238
368
  costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
239
369
  if ("usage" in message && message.usage) {
240
370
  const u = message.usage as unknown as Record<string, number | undefined>;
@@ -251,6 +381,19 @@ export async function executeStep(opts: ExecOptions): Promise<ExecOutcome> {
251
381
  }
252
382
  }
253
383
  }
384
+ } catch (err) {
385
+ // An abort we asked for is not an error of the step's; anything else is.
386
+ if (!ended) throw err;
387
+ } finally {
388
+ for (const t of timers) clearTimeout(t);
389
+ }
390
+ if (ended === "timeout") {
391
+ emit("error", `timed out after ${opts.timeoutSec}s (timeout: in the flow file) — the step was stopped with the files it wrote so far kept`);
392
+ status = "failed";
393
+ } else if (ended === "stopped") {
394
+ emit("error", "stopped by a person mid-step");
395
+ status = "failed";
396
+ }
254
397
  // The SDK ends a healthy run with a `result` message. A stream that just
255
398
  // stops — subprocess OOM-killed, crashed, or torn down — used to fall
256
399
  // through as "completed", which reported a run that produced nothing as a
@@ -279,6 +422,14 @@ export async function executeStep(opts: ExecOptions): Promise<ExecOutcome> {
279
422
  if (extracted.ok) {
280
423
  data = extracted.value;
281
424
  emit("info", `output: json — ${describeJson(data)}`);
425
+ // The declared shape, checked here beside the extraction: a value
426
+ // that parses but is not what the next step was promised is the same
427
+ // failure as no value, and the message names the field.
428
+ const errors = opts.schema !== undefined && looksLikeSchema(opts.schema) ? validateSchema(data, opts.schema) : [];
429
+ if (errors.length) {
430
+ emit("error", `schema: the value does not fit — ${describeSchemaErrors(errors)}`);
431
+ status = "failed";
432
+ }
282
433
  } else {
283
434
  emit("error", `output: json — ${extracted.reason}`);
284
435
  status = "failed";
@@ -475,9 +626,13 @@ function runVerify(
475
626
  return new Promise((resolve) => {
476
627
  // No clock of the platform's: a verify like `npm run build` takes what
477
628
  // it takes, and the step's own `timeout:` is the bound if the flow set one.
629
+ // The allowlisted host base plus what the caller passed: the step's
630
+ // secrets and identifiers on the in-process path (runner.ts), the
631
+ // container's own environment on the isolated one (run-container.ts).
632
+ // Never process.env whole — see host-env.ts.
478
633
  const child = spawn("bash", ["-lc", command], {
479
634
  cwd: agentDir,
480
- env: { ...process.env, ...env },
635
+ env: { ...hostSafeEnv(), ...env },
481
636
  stdio: ["pipe", "pipe", "pipe"],
482
637
  });
483
638
  // An `output: json` step's data arrives on stdin, so a check can be
package/src/storage.ts CHANGED
@@ -302,8 +302,14 @@ export function s3Config(): S3Config | null {
302
302
  };
303
303
  }
304
304
 
305
- const sha256 = (b: crypto.BinaryLike) => crypto.createHash("sha256").update(b).digest("hex");
306
- const hmac = (key: crypto.BinaryLike, data: string) =>
305
+ // Narrower than crypto.BinaryLike on purpose. BinaryLike also covers views
306
+ // over a SharedArrayBuffer, which .update() has never accepted and which
307
+ // @types/node 26 finally says so about — the old typings let this compile by
308
+ // being vaguer than the runtime. Every caller here passes a string or a
309
+ // Buffer, so this is the honest signature, not a workaround.
310
+ type Hashable = string | Uint8Array;
311
+ const sha256 = (b: Hashable) => crypto.createHash("sha256").update(b).digest("hex");
312
+ const hmac = (key: Hashable, data: string) =>
307
313
  crypto.createHmac("sha256", key).update(data, "utf8").digest();
308
314
 
309
315
  /** RFC 3986. encodeURIComponent leaves !'()* alone and SigV4 does not. */