@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.
- package/README.md +31 -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/deploy.js +4 -2
- 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 +112 -2
- package/dist/src/platform.js +20 -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 +80 -4
- package/dist/src/run-container.js +176 -37
- package/dist/src/runner.d.ts +41 -5
- package/dist/src/runner.js +513 -81
- 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 +4 -0
- package/dist/src/step-exec.d.ts +62 -6
- package/dist/src/step-exec.js +199 -68
- package/dist/src/store.d.ts +300 -3
- package/dist/src/store.js +574 -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 +6 -5
- 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/deploy.ts +4 -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 +119 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +215 -35
- package/src/runner.ts +525 -73
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +4 -0
- package/src/step-exec.ts +176 -21
- package/src/storage.ts +8 -2
- package/src/store.ts +785 -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,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
|
-
|
|
16
|
-
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
usage
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
(u.
|
|
178
|
-
|
|
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: { ...
|
|
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
|