@foldrun/core 0.3.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 (83) hide show
  1. package/README.md +27 -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/egress.d.ts +19 -2
  9. package/dist/src/egress.js +2 -1
  10. package/dist/src/evals.d.ts +4 -0
  11. package/dist/src/evals.js +20 -11
  12. package/dist/src/flow-lint.d.ts +4 -0
  13. package/dist/src/flow-lint.js +108 -0
  14. package/dist/src/host-env.d.ts +8 -0
  15. package/dist/src/host-env.js +48 -0
  16. package/dist/src/json-schema.d.ts +12 -0
  17. package/dist/src/json-schema.js +202 -0
  18. package/dist/src/library.js +13 -1
  19. package/dist/src/notify.d.ts +79 -1
  20. package/dist/src/notify.js +243 -9
  21. package/dist/src/paths.d.ts +5 -0
  22. package/dist/src/paths.js +32 -0
  23. package/dist/src/platform.d.ts +102 -2
  24. package/dist/src/platform.js +18 -2
  25. package/dist/src/provider-health.d.ts +55 -0
  26. package/dist/src/provider-health.js +232 -0
  27. package/dist/src/run-container.d.ts +57 -4
  28. package/dist/src/run-container.js +147 -41
  29. package/dist/src/runner.d.ts +39 -3
  30. package/dist/src/runner.js +422 -73
  31. package/dist/src/script-tools.d.ts +12 -0
  32. package/dist/src/script-tools.js +10 -2
  33. package/dist/src/secret-health.d.ts +59 -0
  34. package/dist/src/secret-health.js +155 -0
  35. package/dist/src/secrets.d.ts +19 -1
  36. package/dist/src/secrets.js +44 -12
  37. package/dist/src/starter.js +3 -0
  38. package/dist/src/step-exec.d.ts +47 -1
  39. package/dist/src/step-exec.js +155 -60
  40. package/dist/src/store.d.ts +276 -3
  41. package/dist/src/store.js +552 -26
  42. package/dist/src/tar.js +2 -1
  43. package/dist/src/test-mode.d.ts +111 -0
  44. package/dist/src/test-mode.js +285 -0
  45. package/dist/src/tool-test.js +48 -21
  46. package/dist/src/translator.js +2 -1
  47. package/dist/src/trigger-log.d.ts +43 -0
  48. package/dist/src/trigger-log.js +122 -0
  49. package/dist/src/triggers.d.ts +20 -0
  50. package/dist/src/triggers.js +91 -10
  51. package/dist/src/webhook.d.ts +15 -3
  52. package/dist/src/webhook.js +11 -11
  53. package/package.json +4 -4
  54. package/src/approvals.ts +253 -2
  55. package/src/budget.ts +178 -0
  56. package/src/catalog.ts +2 -1
  57. package/src/confine.ts +6 -1
  58. package/src/egress.ts +20 -4
  59. package/src/evals.ts +25 -9
  60. package/src/flow-lint.ts +118 -0
  61. package/src/host-env.ts +51 -0
  62. package/src/json-schema.ts +184 -0
  63. package/src/library.ts +13 -1
  64. package/src/notify.ts +272 -11
  65. package/src/paths.ts +29 -0
  66. package/src/platform.ts +107 -3
  67. package/src/provider-health.ts +274 -0
  68. package/src/run-container.ts +175 -36
  69. package/src/runner.ts +435 -65
  70. package/src/script-tools.ts +18 -2
  71. package/src/secret-health.ts +185 -0
  72. package/src/secrets.ts +44 -10
  73. package/src/starter.ts +3 -0
  74. package/src/step-exec.ts +128 -13
  75. package/src/storage.ts +8 -2
  76. package/src/store.ts +746 -25
  77. package/src/tar.ts +2 -1
  78. package/src/test-mode.ts +311 -0
  79. package/src/tool-test.ts +49 -20
  80. package/src/translator.ts +2 -1
  81. package/src/trigger-log.ts +159 -0
  82. package/src/triggers.ts +92 -10
  83. package/src/webhook.ts +17 -11
@@ -12,6 +12,13 @@ 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
+ 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;
15
22
  /** Conservative per-token rates for a model nothing else can price — an
16
23
  * unknown id on a gateway with no catalogue. Opus-class, so a ceiling
17
24
  * still means something. Only ever reached when `knownPrice` below does
@@ -72,7 +79,28 @@ export function stepCeiling(budgetUsd, spentUsd, launching) {
72
79
  return null;
73
80
  return Math.max(0, budgetUsd - spentUsd) / Math.max(1, launching);
74
81
  }
75
- 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) {
76
104
  const { agentDir, workspaceRoot, libraryRoot, emit } = opts;
77
105
  let status = "running";
78
106
  let costUsd = null;
@@ -80,16 +108,22 @@ export async function executeStep(opts) {
80
108
  const texts = [];
81
109
  fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
82
110
  fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
83
- 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({
84
116
  prompt: opts.prompt,
85
117
  options: {
86
118
  cwd: agentDir,
119
+ abortController: abort,
87
120
  model: opts.model,
88
121
  // Omitted, not passed as undefined-with-a-default: an unset effort
89
122
  // should mean "whatever this model does normally", which is not a
90
123
  // level we can name — it moves as models ship.
91
124
  ...(opts.effort ? { effort: opts.effort } : {}),
92
125
  systemPrompt: opts.systemPrompt,
126
+ ...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}),
93
127
  // Restrict the toolset itself, not just approval: an agent that
94
128
  // declares no tools gets none, instead of seeing the full Claude
95
129
  // Code toolset and burning turns on denied calls.
@@ -141,7 +175,37 @@ export async function executeStep(opts) {
141
175
  },
142
176
  },
143
177
  });
144
- 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.
145
209
  // The hard cap. Spend is counted as each assistant turn arrives — its
146
210
  // usage is in the message — so the step can stop at the ceiling rather
147
211
  // than learn afterwards that it crossed it. Cache traffic is counted as
@@ -152,70 +216,89 @@ export async function executeStep(opts) {
152
216
  // Open tool calls, by the provider's id, so the result can be paired with
153
217
  // its call and the trace can say how long each tool ran.
154
218
  const openCalls = new Map();
155
- for await (const message of q) {
156
- if (deadline && Date.now() > deadline) {
157
- emit("error", `step exceeded its ${opts.timeoutSec}s timeout`);
158
- status = "failed";
159
- break;
160
- }
161
- if (message.type === "assistant") {
162
- const u = message.message.usage;
163
- if (ceiling && u) {
164
- spentUsd += priceTurn(u, opts.price ?? null);
165
- if (spentUsd >= ceiling) {
166
- 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)`);
167
- status = "failed";
168
- break;
169
- }
170
- }
171
- for (const block of message.message.content) {
172
- if (block.type === "text" && block.text.trim()) {
173
- texts.push(block.text);
174
- 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
+ }
175
232
  }
176
- else if (block.type === "tool_use") {
177
- openCalls.set(block.id, { name: block.name, at: Date.now() });
178
- 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
+ }
179
242
  }
180
243
  }
181
- }
182
- else if (message.type === "user") {
183
- // Tool results ride back as user turns. The completion event closes
184
- // the span the call opened; the runner's journal keeps both.
185
- const content = message.message.content;
186
- if (Array.isArray(content)) {
187
- for (const block of content) {
188
- if (block.type !== "tool_result")
189
- continue;
190
- const open = openCalls.get(block.tool_use_id);
191
- if (!open)
192
- continue;
193
- openCalls.delete(block.tool_use_id);
194
- emit("tool", open.name, {
195
- call: block.tool_use_id,
196
- ms: Date.now() - open.at,
197
- ...(block.is_error ? { err: true } : {}),
198
- });
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
+ }
199
262
  }
200
263
  }
201
- }
202
- else if (message.type === "result") {
203
- status = message.subtype === "success" ? "completed" : "failed";
204
- costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
205
- if ("usage" in message && message.usage) {
206
- const u = message.usage;
207
- usage = {
208
- // Cache traffic is input the provider still bills (at its own
209
- // rates); folding it into the input count over-approximates for
210
- // gateways with cheaper cache reads, which errs on the honest side.
211
- inputTokens: (u.input_tokens ?? 0) +
212
- (u.cache_creation_input_tokens ?? 0) +
213
- (u.cache_read_input_tokens ?? 0),
214
- outputTokens: u.output_tokens ?? 0,
215
- };
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
+ }
216
282
  }
217
283
  }
218
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
+ }
219
302
  // The SDK ends a healthy run with a `result` message. A stream that just
220
303
  // stops — subprocess OOM-killed, crashed, or torn down — used to fall
221
304
  // through as "completed", which reported a run that produced nothing as a
@@ -240,6 +323,14 @@ export async function executeStep(opts) {
240
323
  if (extracted.ok) {
241
324
  data = extracted.value;
242
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
+ }
243
334
  }
244
335
  else {
245
336
  emit("error", `output: json — ${extracted.reason}`);
@@ -401,9 +492,13 @@ function runVerify(agentDir, command, env, data) {
401
492
  return new Promise((resolve) => {
402
493
  // No clock of the platform's: a verify like `npm run build` takes what
403
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.
404
499
  const child = spawn("bash", ["-lc", command], {
405
500
  cwd: agentDir,
406
- env: { ...process.env, ...env },
501
+ env: { ...hostSafeEnv(), ...env },
407
502
  stdio: ["pipe", "pipe", "pipe"],
408
503
  });
409
504
  // An `output: json` step's data arrives on stdin, so a check can be