@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.
- package/README.md +27 -10
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/confine.js +6 -1
- package/dist/src/egress.d.ts +19 -2
- package/dist/src/egress.js +2 -1
- package/dist/src/evals.d.ts +4 -0
- package/dist/src/evals.js +20 -11
- package/dist/src/flow-lint.d.ts +4 -0
- package/dist/src/flow-lint.js +108 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/library.js +13 -1
- package/dist/src/notify.d.ts +79 -1
- package/dist/src/notify.js +243 -9
- package/dist/src/paths.d.ts +5 -0
- package/dist/src/paths.js +32 -0
- package/dist/src/platform.d.ts +102 -2
- package/dist/src/platform.js +18 -2
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +232 -0
- package/dist/src/run-container.d.ts +57 -4
- package/dist/src/run-container.js +147 -41
- package/dist/src/runner.d.ts +39 -3
- package/dist/src/runner.js +422 -73
- package/dist/src/script-tools.d.ts +12 -0
- package/dist/src/script-tools.js +10 -2
- package/dist/src/secret-health.d.ts +59 -0
- package/dist/src/secret-health.js +155 -0
- package/dist/src/secrets.d.ts +19 -1
- package/dist/src/secrets.js +44 -12
- package/dist/src/starter.js +3 -0
- package/dist/src/step-exec.d.ts +47 -1
- package/dist/src/step-exec.js +155 -60
- package/dist/src/store.d.ts +276 -3
- package/dist/src/store.js +552 -26
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-test.js +48 -21
- package/dist/src/translator.js +2 -1
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +20 -0
- package/dist/src/triggers.js +91 -10
- package/dist/src/webhook.d.ts +15 -3
- package/dist/src/webhook.js +11 -11
- package/package.json +4 -4
- package/src/approvals.ts +253 -2
- package/src/budget.ts +178 -0
- package/src/catalog.ts +2 -1
- package/src/confine.ts +6 -1
- package/src/egress.ts +20 -4
- package/src/evals.ts +25 -9
- package/src/flow-lint.ts +118 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/library.ts +13 -1
- package/src/notify.ts +272 -11
- package/src/paths.ts +29 -0
- package/src/platform.ts +107 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +175 -36
- package/src/runner.ts +435 -65
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +3 -0
- package/src/step-exec.ts +128 -13
- package/src/storage.ts +8 -2
- package/src/store.ts +746 -25
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-test.ts +49 -20
- package/src/translator.ts +2 -1
- package/src/trigger-log.ts +159 -0
- package/src/triggers.ts +92 -10
- package/src/webhook.ts +17 -11
package/dist/src/step-exec.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
usage
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
(u.
|
|
214
|
-
|
|
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: { ...
|
|
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
|