@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
@@ -12,19 +12,62 @@ import path from "node:path";
12
12
  import { query } from "@anthropic-ai/claude-agent-sdk";
13
13
  import { spawn } from "node:child_process";
14
14
  import { checkPaths, checkBash, isFilesystemTool } from "./confine.js";
15
- /** Conservative per-token rates for a model the catalogue cannot price —
16
- * Opus-class — so a ceiling still means something on an unknown gateway. */
15
+ import { hostSafeEnv } from "./host-env.js";
16
+ import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
17
+ /** How often the stop flag is read while a step runs. */
18
+ export const STOP_POLL_MS = 2000;
19
+ /** After an interrupt is asked for, how long the model loop gets to wind
20
+ * down on its own before the query is aborted outright. */
21
+ export const INTERRUPT_GRACE_MS = 10_000;
22
+ /** Conservative per-token rates for a model nothing else can price — an
23
+ * unknown id on a gateway with no catalogue. Opus-class, so a ceiling
24
+ * still means something. Only ever reached when `knownPrice` below does
25
+ * not recognise the model either. */
17
26
  export const FALLBACK_PRICE = { input: 15e-6, output: 75e-6 };
27
+ /** The rates we know without asking anyone: the tiers this runtime resolves
28
+ * (`fast`/`default`/`max` → haiku/sonnet/opus) and the model ids that carry
29
+ * those words. A gateway with a catalogue still wins — that is the
30
+ * authority — but the platform's own Anthropic key has no catalogue, and
31
+ * pricing its steps at Opus rates made every mid-turn ceiling read up to
32
+ * fifteen times high: an indexing run was stopped at "$1.17" having
33
+ * actually spent $0.15 (2026-09-08). Per token, input/output. */
34
+ const KNOWN_PRICES = [
35
+ [/haiku/i, { input: 1e-6, output: 5e-6 }],
36
+ [/sonnet/i, { input: 2e-6, output: 10e-6 }],
37
+ [/\bopus\b/i, { input: 5e-6, output: 25e-6 }],
38
+ ];
39
+ /** What a token costs on this model, when we can say without a catalogue. */
40
+ export function knownPrice(model) {
41
+ if (!model)
42
+ return null;
43
+ return KNOWN_PRICES.find(([re]) => re.test(model))?.[1] ?? null;
44
+ }
45
+ /** Anthropic's cache multipliers, and the shape every gateway that bills
46
+ * for caching at all has copied: a write costs a quarter more than fresh
47
+ * input, a read a tenth of it. Counting both at the full input rate — as
48
+ * this did — makes a long cached prompt read about ten times its price,
49
+ * which on a ceiling is not "the honest direction to be wrong in", it is
50
+ * a step killed for spending money it never spent. */
51
+ const CACHE_WRITE_MULTIPLIER = 1.25;
52
+ const CACHE_READ_MULTIPLIER = 0.1;
18
53
  /**
19
- * What one assistant turn cost. Cache traffic is counted as input at the
20
- * input rate, which over-approximates on gateways with cheaper cache reads
21
- * — on the money side, the honest direction to be wrong in. Pure.
54
+ * What one assistant turn cost, from the usage the SDK reports. Pure.
55
+ *
56
+ * A gateway whose cache is priced differently is over- or under-counted
57
+ * here by that difference alone; the run record's own cost is repriced
58
+ * from the catalogue afterwards and stays the authority on the bill. This
59
+ * number exists to stop a runaway step mid-turn, and wants to be close.
22
60
  */
23
61
  export function priceTurn(u, price) {
24
- const inTok = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
25
- const outTok = u.output_tokens ?? 0;
26
62
  const p = price ?? FALLBACK_PRICE;
27
- return inTok * p.input + outTok * p.output;
63
+ const fresh = u.input_tokens ?? 0;
64
+ const written = u.cache_creation_input_tokens ?? 0;
65
+ const read = u.cache_read_input_tokens ?? 0;
66
+ const outTok = u.output_tokens ?? 0;
67
+ return (fresh * p.input +
68
+ written * p.input * CACHE_WRITE_MULTIPLIER +
69
+ read * p.input * CACHE_READ_MULTIPLIER +
70
+ outTok * p.output);
28
71
  }
29
72
  /**
30
73
  * Each step launched together gets an equal share of what is left of the
@@ -36,7 +79,28 @@ export function stepCeiling(budgetUsd, spentUsd, launching) {
36
79
  return null;
37
80
  return Math.max(0, budgetUsd - spentUsd) / Math.max(1, launching);
38
81
  }
39
- export async function executeStep(opts) {
82
+ /**
83
+ * The ceiling a step actually runs under, and which line set it.
84
+ *
85
+ * Two caps can apply to one step: the flow's `budget:` gives it an equal
86
+ * share of what the run has left; the agent's own `budget:` is the most that
87
+ * agent may spend in one run, less what its earlier steps in this run cost.
88
+ * The tighter one wins, and the note says which, because "over budget" with
89
+ * no line to go and raise is a message that sends someone to the wrong file.
90
+ */
91
+ export function stepCeilingFor(flowShareUsd, agentBudgetUsd, agentSpentUsd, agent) {
92
+ const flow = { ceilingUsd: flowShareUsd, note: "budget: in the flow file" };
93
+ if (!agentBudgetUsd || agentBudgetUsd <= 0)
94
+ return flow;
95
+ const left = Math.max(0, agentBudgetUsd - agentSpentUsd);
96
+ if (flowShareUsd !== null && flowShareUsd <= left)
97
+ return flow;
98
+ return { ceilingUsd: left, note: `budget: on the ${agent} agent` };
99
+ }
100
+ export async function executeStep(opts,
101
+ /** The model loop. Injected so the clock and the stop can be tested
102
+ * without a model; production passes nothing and gets the SDK. */
103
+ runQuery = query) {
40
104
  const { agentDir, workspaceRoot, libraryRoot, emit } = opts;
41
105
  let status = "running";
42
106
  let costUsd = null;
@@ -44,16 +108,22 @@ export async function executeStep(opts) {
44
108
  const texts = [];
45
109
  fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
46
110
  fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
47
- const q = query({
111
+ // The one handle that can end the loop from outside: the timeout and a
112
+ // stop both ask the query to interrupt, and abort it outright if it has
113
+ // not wound down within the grace period.
114
+ const abort = new AbortController();
115
+ const q = runQuery({
48
116
  prompt: opts.prompt,
49
117
  options: {
50
118
  cwd: agentDir,
119
+ abortController: abort,
51
120
  model: opts.model,
52
121
  // Omitted, not passed as undefined-with-a-default: an unset effort
53
122
  // should mean "whatever this model does normally", which is not a
54
123
  // level we can name — it moves as models ship.
55
124
  ...(opts.effort ? { effort: opts.effort } : {}),
56
125
  systemPrompt: opts.systemPrompt,
126
+ ...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}),
57
127
  // Restrict the toolset itself, not just approval: an agent that
58
128
  // declares no tools gets none, instead of seeing the full Claude
59
129
  // Code toolset and burning turns on denied calls.
@@ -105,7 +175,37 @@ export async function executeStep(opts) {
105
175
  },
106
176
  },
107
177
  });
108
- const deadline = opts.timeoutSec ? Date.now() + opts.timeoutSec * 1000 : null;
178
+ // Why the loop was ended from outside, if it was. The deadline used to be
179
+ // checked only when a message arrived, so a step waiting on one long tool
180
+ // call — a crawl, a build — sailed past its timeout: with no message there
181
+ // was nothing to check it against. The backstop timer fires regardless,
182
+ // and a stop is read on a clock rather than between groups.
183
+ let ended = null;
184
+ const timers = [];
185
+ const endWith = (why) => {
186
+ if (ended)
187
+ return;
188
+ ended = why;
189
+ void q.interrupt().catch(() => { });
190
+ timers.push(setTimeout(() => abort.abort(), INTERRUPT_GRACE_MS));
191
+ };
192
+ if (opts.timeoutSec)
193
+ timers.push(setTimeout(() => endWith("timeout"), opts.timeoutSec * 1000));
194
+ if (opts.stopRequested) {
195
+ timers.push(setInterval(() => {
196
+ try {
197
+ if (opts.stopRequested())
198
+ endWith("stopped");
199
+ }
200
+ catch {
201
+ // an unreadable record is not a stop
202
+ }
203
+ }, STOP_POLL_MS));
204
+ }
205
+ // The clocks stay referenced on purpose: a step hanging in a tool call may
206
+ // be the only thing keeping the loop alive, and an unreferenced timer
207
+ // would let the process exit around it instead of ending it. They are all
208
+ // cleared when the step ends, so nothing outlives the step.
109
209
  // The hard cap. Spend is counted as each assistant turn arrives — its
110
210
  // usage is in the message — so the step can stop at the ceiling rather
111
211
  // than learn afterwards that it crossed it. Cache traffic is counted as
@@ -116,70 +216,89 @@ export async function executeStep(opts) {
116
216
  // Open tool calls, by the provider's id, so the result can be paired with
117
217
  // its call and the trace can say how long each tool ran.
118
218
  const openCalls = new Map();
119
- for await (const message of q) {
120
- if (deadline && Date.now() > deadline) {
121
- emit("error", `step exceeded its ${opts.timeoutSec}s timeout`);
122
- status = "failed";
123
- break;
124
- }
125
- if (message.type === "assistant") {
126
- const u = message.message.usage;
127
- if (ceiling && u) {
128
- spentUsd += priceTurn(u, opts.price ?? null);
129
- if (spentUsd >= ceiling) {
130
- 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)`);
131
- status = "failed";
132
- break;
133
- }
134
- }
135
- for (const block of message.message.content) {
136
- if (block.type === "text" && block.text.trim()) {
137
- texts.push(block.text);
138
- emit("text", block.text);
219
+ try {
220
+ for await (const message of q) {
221
+ if (ended)
222
+ break;
223
+ if (message.type === "assistant") {
224
+ const u = message.message.usage;
225
+ if (ceiling && u) {
226
+ spentUsd += priceTurn(u, opts.price ?? null);
227
+ if (spentUsd >= ceiling) {
228
+ 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"})`);
229
+ status = "failed";
230
+ break;
231
+ }
139
232
  }
140
- else if (block.type === "tool_use") {
141
- openCalls.set(block.id, { name: block.name, at: Date.now() });
142
- emit("tool", block.name, { call: block.id });
233
+ for (const block of message.message.content) {
234
+ if (block.type === "text" && block.text.trim()) {
235
+ texts.push(block.text);
236
+ emit("text", block.text);
237
+ }
238
+ else if (block.type === "tool_use") {
239
+ openCalls.set(block.id, { name: block.name, at: Date.now() });
240
+ emit("tool", block.name, { call: block.id });
241
+ }
143
242
  }
144
243
  }
145
- }
146
- else if (message.type === "user") {
147
- // Tool results ride back as user turns. The completion event closes
148
- // the span the call opened; the runner's journal keeps both.
149
- const content = message.message.content;
150
- if (Array.isArray(content)) {
151
- for (const block of content) {
152
- if (block.type !== "tool_result")
153
- continue;
154
- const open = openCalls.get(block.tool_use_id);
155
- if (!open)
156
- continue;
157
- openCalls.delete(block.tool_use_id);
158
- emit("tool", open.name, {
159
- call: block.tool_use_id,
160
- ms: Date.now() - open.at,
161
- ...(block.is_error ? { err: true } : {}),
162
- });
244
+ else if (message.type === "user") {
245
+ // Tool results ride back as user turns. The completion event closes
246
+ // the span the call opened; the runner's journal keeps both.
247
+ const content = message.message.content;
248
+ if (Array.isArray(content)) {
249
+ for (const block of content) {
250
+ if (block.type !== "tool_result")
251
+ continue;
252
+ const open = openCalls.get(block.tool_use_id);
253
+ if (!open)
254
+ continue;
255
+ openCalls.delete(block.tool_use_id);
256
+ emit("tool", open.name, {
257
+ call: block.tool_use_id,
258
+ ms: Date.now() - open.at,
259
+ ...(block.is_error ? { err: true } : {}),
260
+ });
261
+ }
163
262
  }
164
263
  }
165
- }
166
- else if (message.type === "result") {
167
- status = message.subtype === "success" ? "completed" : "failed";
168
- costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
169
- if ("usage" in message && message.usage) {
170
- const u = message.usage;
171
- usage = {
172
- // Cache traffic is input the provider still bills (at its own
173
- // rates); folding it into the input count over-approximates for
174
- // gateways with cheaper cache reads, which errs on the honest side.
175
- inputTokens: (u.input_tokens ?? 0) +
176
- (u.cache_creation_input_tokens ?? 0) +
177
- (u.cache_read_input_tokens ?? 0),
178
- outputTokens: u.output_tokens ?? 0,
179
- };
264
+ else if (message.type === "result") {
265
+ status = message.subtype === "success" ? "completed" : "failed";
266
+ if (message.subtype === "error_max_turns") {
267
+ emit("error", `stopped after ${opts.maxTurns ?? "its"} turns (max_turns: in the flow file) — the step did not finish`);
268
+ }
269
+ costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
270
+ if ("usage" in message && message.usage) {
271
+ const u = message.usage;
272
+ usage = {
273
+ // Cache traffic is input the provider still bills (at its own
274
+ // rates); folding it into the input count over-approximates for
275
+ // gateways with cheaper cache reads, which errs on the honest side.
276
+ inputTokens: (u.input_tokens ?? 0) +
277
+ (u.cache_creation_input_tokens ?? 0) +
278
+ (u.cache_read_input_tokens ?? 0),
279
+ outputTokens: u.output_tokens ?? 0,
280
+ };
281
+ }
180
282
  }
181
283
  }
182
284
  }
285
+ catch (err) {
286
+ // An abort we asked for is not an error of the step's; anything else is.
287
+ if (!ended)
288
+ throw err;
289
+ }
290
+ finally {
291
+ for (const t of timers)
292
+ clearTimeout(t);
293
+ }
294
+ if (ended === "timeout") {
295
+ 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`);
296
+ status = "failed";
297
+ }
298
+ else if (ended === "stopped") {
299
+ emit("error", "stopped by a person mid-step");
300
+ status = "failed";
301
+ }
183
302
  // The SDK ends a healthy run with a `result` message. A stream that just
184
303
  // stops — subprocess OOM-killed, crashed, or torn down — used to fall
185
304
  // through as "completed", which reported a run that produced nothing as a
@@ -204,6 +323,14 @@ export async function executeStep(opts) {
204
323
  if (extracted.ok) {
205
324
  data = extracted.value;
206
325
  emit("info", `output: json — ${describeJson(data)}`);
326
+ // The declared shape, checked here beside the extraction: a value
327
+ // that parses but is not what the next step was promised is the same
328
+ // failure as no value, and the message names the field.
329
+ const errors = opts.schema !== undefined && looksLikeSchema(opts.schema) ? validateSchema(data, opts.schema) : [];
330
+ if (errors.length) {
331
+ emit("error", `schema: the value does not fit — ${describeSchemaErrors(errors)}`);
332
+ status = "failed";
333
+ }
207
334
  }
208
335
  else {
209
336
  emit("error", `output: json — ${extracted.reason}`);
@@ -365,9 +492,13 @@ function runVerify(agentDir, command, env, data) {
365
492
  return new Promise((resolve) => {
366
493
  // No clock of the platform's: a verify like `npm run build` takes what
367
494
  // it takes, and the step's own `timeout:` is the bound if the flow set one.
495
+ // The allowlisted host base plus what the caller passed: the step's
496
+ // secrets and identifiers on the in-process path (runner.ts), the
497
+ // container's own environment on the isolated one (run-container.ts).
498
+ // Never process.env whole — see host-env.ts.
368
499
  const child = spawn("bash", ["-lc", command], {
369
500
  cwd: agentDir,
370
- env: { ...process.env, ...env },
501
+ env: { ...hostSafeEnv(), ...env },
371
502
  stdio: ["pipe", "pipe", "pipe"],
372
503
  });
373
504
  // An `output: json` step's data arrives on stdin, so a check can be