@foldrun/core 0.4.0 → 0.6.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 +7 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/browser-pod.d.ts +148 -0
- package/dist/src/browser-pod.js +333 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +218 -3
- package/dist/src/container.d.ts +9 -0
- package/dist/src/container.js +115 -5
- package/dist/src/context-tools.d.ts +1 -1
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +7 -0
- package/dist/src/egress.js +6 -0
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +5 -0
- package/dist/src/evals.js +9 -2
- package/dist/src/flow-lint.d.ts +8 -0
- package/dist/src/flow-lint.js +223 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.js +5 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +30 -3
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +247 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +58 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +53 -7
- package/dist/src/notify.js +194 -126
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +28 -0
- package/dist/src/paths.js +55 -0
- package/dist/src/platform.d.ts +43 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/provider-health.js +3 -2
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +149 -13
- package/dist/src/run-container.js +424 -43
- package/dist/src/runner.d.ts +177 -9
- package/dist/src/runner.js +1146 -154
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +17 -2
- package/dist/src/script-tools.js +82 -10
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +30 -6
- package/dist/src/secret-health.js +42 -20
- package/dist/src/secrets.d.ts +19 -0
- package/dist/src/secrets.js +155 -86
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +92 -11
- package/dist/src/step-exec.d.ts +72 -2
- package/dist/src/step-exec.js +431 -37
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +222 -37
- package/dist/src/store.js +349 -62
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +104 -12
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +69 -23
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +3 -0
- package/dist/src/triggers.js +10 -2
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +4 -0
- package/dist/src/webhook.js +39 -0
- package/index.ts +4 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +350 -0
- package/src/catalog.ts +2 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +218 -3
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +14 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +5 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +37 -3
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +318 -0
- package/src/model-credential.ts +71 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +189 -103
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +54 -0
- package/src/platform.ts +52 -3
- package/src/provider-health.ts +3 -2
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +460 -44
- package/src/runner.ts +1168 -155
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +81 -10
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +64 -19
- package/src/secrets.ts +149 -87
- package/src/starter.ts +93 -11
- package/src/step-exec.ts +447 -38
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +506 -70
- package/src/subagents.ts +189 -0
- package/src/test-mode.ts +51 -11
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +110 -12
- package/src/translator.ts +72 -22
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +10 -2
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +41 -0
- package/templates/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +4 -4
- package/templates/hello/agents/notetaker/agent.md +3 -3
package/dist/src/step-exec.js
CHANGED
|
@@ -8,17 +8,39 @@
|
|
|
8
8
|
// results. That is what lets the same function run somewhere the vault, the
|
|
9
9
|
// library and the account do not exist.
|
|
10
10
|
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
11
12
|
import path from "node:path";
|
|
12
13
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
13
14
|
import { spawn } from "node:child_process";
|
|
14
|
-
import { checkPaths, checkBash, isFilesystemTool } from "./confine.js";
|
|
15
|
+
import { checkPaths, checkBash, isFilesystemTool, linkWorkspace, workspaceLinkEnv, resolveAgentPath, isWithin } from "./confine.js";
|
|
16
|
+
import { DELEGATE_TOOLS, subagentGuard, toAgentDefinitions } from "./subagents.js";
|
|
15
17
|
import { hostSafeEnv } from "./host-env.js";
|
|
16
18
|
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
|
|
19
|
+
import { CallCounter, limitKeysFor } from "./limits.js";
|
|
20
|
+
import { classifyCall, podAt, readPodEvents } from "./browser-pod.js";
|
|
17
21
|
/** How often the stop flag is read while a step runs. */
|
|
18
22
|
export const STOP_POLL_MS = 2000;
|
|
23
|
+
/** The least a `verify:` gets when the step used up most of its `timeout:` —
|
|
24
|
+
* capped by the timeout itself, so the step and its check together never
|
|
25
|
+
* run past timeout + this. Under the isolated path's backstop (+60 s). */
|
|
26
|
+
export const VERIFY_FLOOR_MS = 30_000;
|
|
27
|
+
/** How long a `verify:` may run: what is left of the step's `timeout:`,
|
|
28
|
+
* with a floor. Null when the flow set no timeout — `timeout:` is the only
|
|
29
|
+
* clock there is, and a stop still ends the check. */
|
|
30
|
+
export function verifyBudgetMs(timeoutSec, elapsedMs) {
|
|
31
|
+
if (!timeoutSec || timeoutSec <= 0)
|
|
32
|
+
return null;
|
|
33
|
+
const total = timeoutSec * 1000;
|
|
34
|
+
return Math.max(total - elapsedMs, Math.min(VERIFY_FLOOR_MS, total));
|
|
35
|
+
}
|
|
19
36
|
/** After an interrupt is asked for, how long the model loop gets to wind
|
|
20
37
|
* down on its own before the query is aborted outright. */
|
|
21
38
|
export const INTERRUPT_GRACE_MS = 10_000;
|
|
39
|
+
/** The tag on every event a failed check writes — `verify:`, `output: json`,
|
|
40
|
+
* a schema — headline and detail alike. The runner's no-retry rule for
|
|
41
|
+
* outward steps reads it (actedThenFailedCheck); reading the wording of the
|
|
42
|
+
* last error missed a check whose detail line came after its headline. */
|
|
43
|
+
const CHECK = { check: true };
|
|
22
44
|
/** Conservative per-token rates for a model nothing else can price — an
|
|
23
45
|
* unknown id on a gateway with no catalogue. Opus-class, so a ceiling
|
|
24
46
|
* still means something. Only ever reached when `knownPrice` below does
|
|
@@ -84,15 +106,19 @@ export function stepCeiling(budgetUsd, spentUsd, launching) {
|
|
|
84
106
|
*
|
|
85
107
|
* Two caps can apply to one step: the flow's `budget:` gives it an equal
|
|
86
108
|
* 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
|
|
109
|
+
* agent may spend in one run, less what its steps in this run have cost or
|
|
110
|
+
* may still cost, shared across `sharing` — this step and the agent's steps
|
|
111
|
+
* launched with it that have not started yet. Without the share, twenty
|
|
112
|
+
* fan-out copies of one agent each got the whole remainder, because none of
|
|
113
|
+
* them has recorded a cent when the next one starts.
|
|
88
114
|
* The tighter one wins, and the note says which, because "over budget" with
|
|
89
115
|
* no line to go and raise is a message that sends someone to the wrong file.
|
|
90
116
|
*/
|
|
91
|
-
export function stepCeilingFor(flowShareUsd, agentBudgetUsd, agentSpentUsd, agent) {
|
|
117
|
+
export function stepCeilingFor(flowShareUsd, agentBudgetUsd, agentSpentUsd, agent, sharing = 1) {
|
|
92
118
|
const flow = { ceilingUsd: flowShareUsd, note: "budget: in the flow file" };
|
|
93
119
|
if (!agentBudgetUsd || agentBudgetUsd <= 0)
|
|
94
120
|
return flow;
|
|
95
|
-
const left = Math.max(0, agentBudgetUsd - agentSpentUsd);
|
|
121
|
+
const left = Math.max(0, agentBudgetUsd - agentSpentUsd) / Math.max(1, sharing);
|
|
96
122
|
if (flowShareUsd !== null && flowShareUsd <= left)
|
|
97
123
|
return flow;
|
|
98
124
|
return { ceilingUsd: left, note: `budget: on the ${agent} agent` };
|
|
@@ -101,10 +127,31 @@ export async function executeStep(opts,
|
|
|
101
127
|
/** The model loop. Injected so the clock and the stop can be tested
|
|
102
128
|
* without a model; production passes nothing and gets the SDK. */
|
|
103
129
|
runQuery = query) {
|
|
130
|
+
// `workspace/…` for the shell, the scripts and a shell `verify:` — the
|
|
131
|
+
// file tools expand the prefix themselves. Held for the step, verify
|
|
132
|
+
// included, and gone before anyone reads the step's files back
|
|
133
|
+
// (confine.ts#linkWorkspace says why it is not permanent).
|
|
134
|
+
fs.mkdirSync(opts.agentDir, { recursive: true });
|
|
135
|
+
const link = linkWorkspace(opts.agentDir, opts.workspaceRoot);
|
|
136
|
+
if (link.note)
|
|
137
|
+
opts.emit("info", `workspace/: ${link.note}`);
|
|
138
|
+
try {
|
|
139
|
+
// And a Node program started through it is still its own main module
|
|
140
|
+
// (confine.ts#workspaceLinkEnv) — for the model's Bash, which inherits
|
|
141
|
+
// the SDK's env; the shell verify: and the scripts get it where they
|
|
142
|
+
// spawn.
|
|
143
|
+
return await executeStepInner({ ...opts, env: workspaceLinkEnv(opts.env, opts.agentDir) }, runQuery);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
link.release();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function executeStepInner(opts, runQuery) {
|
|
104
150
|
const { agentDir, workspaceRoot, libraryRoot, emit } = opts;
|
|
105
151
|
let status = "running";
|
|
106
152
|
let costUsd = null;
|
|
107
153
|
let usage = null;
|
|
154
|
+
let stopReason = null;
|
|
108
155
|
const texts = [];
|
|
109
156
|
fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
|
|
110
157
|
fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
|
|
@@ -112,6 +159,16 @@ runQuery = query) {
|
|
|
112
159
|
// stop both ask the query to interrupt, and abort it outright if it has
|
|
113
160
|
// not wound down within the grace period.
|
|
114
161
|
const abort = new AbortController();
|
|
162
|
+
const subagents = opts.subagents?.length ? opts.subagents : null;
|
|
163
|
+
const guardSubagent = subagents ? subagentGuard(subagents) : null;
|
|
164
|
+
// A sub-agent's calls reach both the hook (which sees its id AND name) and
|
|
165
|
+
// canUseTool (which sees only the id). Remembered here so every refusal —
|
|
166
|
+
// its own list, a path outside the workspace, a shell command — names the
|
|
167
|
+
// agent that tried, not just the step.
|
|
168
|
+
const subagentNames = new Map();
|
|
169
|
+
// `limits:` — this step's counts. A new step, a retry of one, or a
|
|
170
|
+
// fan-out instance each start from zero.
|
|
171
|
+
const counter = new CallCounter(opts.limits);
|
|
115
172
|
const q = runQuery({
|
|
116
173
|
prompt: opts.prompt,
|
|
117
174
|
options: {
|
|
@@ -124,6 +181,9 @@ runQuery = query) {
|
|
|
124
181
|
...(opts.effort ? { effort: opts.effort } : {}),
|
|
125
182
|
systemPrompt: opts.systemPrompt,
|
|
126
183
|
...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}),
|
|
184
|
+
// Sub-agents: the SDK's own delegation. Every definition carries an
|
|
185
|
+
// explicit tool list (an absent one inherits all of ours).
|
|
186
|
+
...(subagents ? { agents: toAgentDefinitions(subagents) } : {}),
|
|
127
187
|
// Restrict the toolset itself, not just approval: an agent that
|
|
128
188
|
// declares no tools gets none, instead of seeing the full Claude
|
|
129
189
|
// Code toolset and burning turns on denied calls.
|
|
@@ -150,13 +210,98 @@ runQuery = query) {
|
|
|
150
210
|
autoAllowBashIfSandboxed: false,
|
|
151
211
|
failIfUnavailable: false,
|
|
152
212
|
},
|
|
153
|
-
|
|
213
|
+
// With sub-agents, the SDK would also offer its built-in ones
|
|
214
|
+
// (general-purpose has every tool the session has). Only the declared
|
|
215
|
+
// ones may run; the guard in the hook below refuses the rest as well.
|
|
216
|
+
env: sdkEnv(opts.env, subagents ? { CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS: "1" } : {}),
|
|
154
217
|
mcpServers: opts.mcpServers,
|
|
155
|
-
|
|
218
|
+
// Only the servers foldrun passes. See sdkEnv for the claude.ai half.
|
|
219
|
+
strictMcpConfig: true,
|
|
220
|
+
// canUseTool is only asked when the SDK wants permission, and it never
|
|
221
|
+
// asks for a read inside the cwd — so `Read workspace/storage/x` went
|
|
222
|
+
// to `<agentDir>/workspace/storage/x` unchecked and unrewritten. On
|
|
223
|
+
// 2026-09-24 that read a stray copy a broken write had left there,
|
|
224
|
+
// with no error. A PreToolUse hook runs on every call: expand the
|
|
225
|
+
// virtual prefix (and refuse an escape) here for every filesystem tool,
|
|
226
|
+
// and leave the rest of the decision to canUseTool.
|
|
227
|
+
hooks: {
|
|
228
|
+
PreToolUse: [{
|
|
229
|
+
hooks: [async (hookInput) => {
|
|
230
|
+
if (hookInput.hook_event_name !== "PreToolUse")
|
|
231
|
+
return {};
|
|
232
|
+
// A sub-agent's calls come through here too, tagged with its
|
|
233
|
+
// name; anything outside its own list — or from a sub-agent this
|
|
234
|
+
// step did not declare — is refused before the shared checks
|
|
235
|
+
// below (which apply to it unchanged). So is a delegation from
|
|
236
|
+
// the main thread to anything but a declared sub-agent.
|
|
237
|
+
const agentId = "agent_id" in hookInput && hookInput.agent_id ? String(hookInput.agent_id) : undefined;
|
|
238
|
+
const agentType = agentId ? hookInput.agent_type : undefined;
|
|
239
|
+
const via = agentId ? (agentType ?? "subagent") : undefined;
|
|
240
|
+
if (agentId && via)
|
|
241
|
+
subagentNames.set(agentId, via);
|
|
242
|
+
if (guardSubagent) {
|
|
243
|
+
const why = guardSubagent({ agentId, agentType, tool: hookInput.tool_name, input: (hookInput.tool_input ?? {}) });
|
|
244
|
+
if (why) {
|
|
245
|
+
emit("error", why, { subagent: via });
|
|
246
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: why } };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const verdict = isFilesystemTool(hookInput.tool_name)
|
|
250
|
+
? checkPaths(hookInput.tool_name, hookInput.tool_input, { agentDir, workspaceRoot, libraryRoot })
|
|
251
|
+
: null;
|
|
252
|
+
if (verdict && !verdict.ok) {
|
|
253
|
+
emit("error", verdict.reason, via ? { subagent: via } : undefined);
|
|
254
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: verdict.reason } };
|
|
255
|
+
}
|
|
256
|
+
// `limits:` — last, so a call another check refused counts
|
|
257
|
+
// toward nothing. Every call passes here, the step's own and its
|
|
258
|
+
// sub-agents', so one count covers both. A call past a limit is
|
|
259
|
+
// refused before it runs: the tool, and any paid API behind it,
|
|
260
|
+
// never sees it.
|
|
261
|
+
if (counter.active) {
|
|
262
|
+
const refusal = counter.take(limitKeysFor(hookInput.tool_name, hookInput.tool_input, opts.toolOwners));
|
|
263
|
+
if (refusal) {
|
|
264
|
+
emit("info", `${refusal.message} (${hookInput.tool_name} refused)`, via ? { subagent: via } : undefined);
|
|
265
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: refusal.message } };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!verdict?.updatedInput)
|
|
269
|
+
return {};
|
|
270
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: verdict.updatedInput } };
|
|
271
|
+
}],
|
|
272
|
+
}],
|
|
273
|
+
// A message a person sent into the running step reaches the model
|
|
274
|
+
// after its next tool call, as context — never as a change to what it
|
|
275
|
+
// may do. A model that is only writing, calling no tools, hears it at
|
|
276
|
+
// the next call it makes.
|
|
277
|
+
...(opts.inbox
|
|
278
|
+
? {
|
|
279
|
+
PostToolUse: [{
|
|
280
|
+
hooks: [async (hookInput) => {
|
|
281
|
+
if (hookInput.hook_event_name !== "PostToolUse")
|
|
282
|
+
return {};
|
|
283
|
+
// A sub-agent's call: the message is for the step's own
|
|
284
|
+
// model, and draining it here would hand it to a context
|
|
285
|
+
// that ends with the delegation — and lose it.
|
|
286
|
+
if ("agent_id" in hookInput && hookInput.agent_id)
|
|
287
|
+
return {};
|
|
288
|
+
const context = await opts.inbox().catch(() => null);
|
|
289
|
+
return context ? { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: context } } : {};
|
|
290
|
+
}],
|
|
291
|
+
}],
|
|
292
|
+
}
|
|
293
|
+
: {}),
|
|
294
|
+
},
|
|
295
|
+
canUseTool: async (toolName, input, options) => {
|
|
296
|
+
const via = options?.agentID ? (subagentNames.get(options.agentID) ?? "subagent") : undefined;
|
|
156
297
|
// The toolset was already narrowed to what the agent declared, so
|
|
157
298
|
// anything outside it is a denial with a reason the model can act on.
|
|
158
299
|
const fromGrantedServer = opts.mcpNames.some((n) => toolName.startsWith(`mcp__${n}__`));
|
|
300
|
+
// A call the hook counted and this refuses never ran: give it back.
|
|
301
|
+
const refund = () => { if (counter.active)
|
|
302
|
+
counter.refund(limitKeysFor(toolName, input, opts.toolOwners)); };
|
|
159
303
|
if (!opts.allowed.includes(toolName) && !fromGrantedServer) {
|
|
304
|
+
refund();
|
|
160
305
|
return {
|
|
161
306
|
behavior: "deny",
|
|
162
307
|
message: `Tool ${toolName} is not enabled for this agent.`,
|
|
@@ -168,7 +313,8 @@ runQuery = query) {
|
|
|
168
313
|
? checkPaths(toolName, input, { agentDir, workspaceRoot, libraryRoot })
|
|
169
314
|
: { ok: true };
|
|
170
315
|
if (!verdict.ok) {
|
|
171
|
-
|
|
316
|
+
refund();
|
|
317
|
+
emit("error", verdict.reason, via ? { subagent: via } : undefined);
|
|
172
318
|
return { behavior: "deny", message: verdict.reason };
|
|
173
319
|
}
|
|
174
320
|
return { behavior: "allow", updatedInput: verdict.updatedInput ?? input };
|
|
@@ -181,6 +327,7 @@ runQuery = query) {
|
|
|
181
327
|
// was nothing to check it against. The backstop timer fires regardless,
|
|
182
328
|
// and a stop is read on a clock rather than between groups.
|
|
183
329
|
let ended = null;
|
|
330
|
+
const startedAt = Date.now();
|
|
184
331
|
const timers = [];
|
|
185
332
|
const endWith = (why) => {
|
|
186
333
|
if (ended)
|
|
@@ -213,31 +360,136 @@ runQuery = query) {
|
|
|
213
360
|
// that is the honest direction to be wrong in.
|
|
214
361
|
const ceiling = typeof opts.budgetUsd === "number" && opts.budgetUsd > 0 ? opts.budgetUsd : null;
|
|
215
362
|
let spentUsd = 0;
|
|
363
|
+
// Every turn's usage, by the model message's id. The SDK can emit one
|
|
364
|
+
// turn as several assistant messages that repeat the same usage, so a
|
|
365
|
+
// turn is counted once — and the sum is the step's cost whenever the
|
|
366
|
+
// closing `result` never arrives: a timeout or a stop ends the loop before
|
|
367
|
+
// it, and a step that made 193 model calls was recorded as $0 (lawyer-desk,
|
|
368
|
+
// 2026-09-25 09:00).
|
|
369
|
+
// Each with the rate it is priced at: a sub-agent with its own `model:`
|
|
370
|
+
// runs its turns on that model, and pricing them at the parent's put a
|
|
371
|
+
// max sub-agent under a fast parent at a fifth of its cost — against the
|
|
372
|
+
// ceiling and on the bill.
|
|
373
|
+
const turns = new Map();
|
|
374
|
+
let anonymousTurns = 0;
|
|
375
|
+
const specByName = new Map((subagents ?? []).map((s) => [s.name, s]));
|
|
376
|
+
const priceOf = (message) => {
|
|
377
|
+
const parent = message.parent_tool_use_id;
|
|
378
|
+
if (!parent)
|
|
379
|
+
return opts.price ?? null;
|
|
380
|
+
const declared = specByName.get(delegations.get(parent) ?? "")?.model;
|
|
381
|
+
if (declared === undefined || declared === "inherit" || declared === opts.model)
|
|
382
|
+
return opts.price ?? null;
|
|
383
|
+
const model = message.message?.model;
|
|
384
|
+
return knownPrice(typeof model === "string" ? model : undefined) ?? knownPrice(declared);
|
|
385
|
+
};
|
|
386
|
+
const turnsTotal = () => [...turns.values()].reduce((sum, t) => sum + priceTurn(t.u, t.price), 0);
|
|
387
|
+
const mixedModels = () => [...turns.values()].some((t) => t.price !== (opts.price ?? null));
|
|
216
388
|
// Open tool calls, by the provider's id, so the result can be paired with
|
|
217
389
|
// its call and the trace can say how long each tool ran.
|
|
218
390
|
const openCalls = new Map();
|
|
391
|
+
// Delegations in flight: the parent's Agent call id → the sub-agent's name.
|
|
392
|
+
// The SDK tags every message a sub-agent produces with that call's id
|
|
393
|
+
// (parent_tool_use_id), which is how its tool calls are labelled here.
|
|
394
|
+
const delegations = new Map();
|
|
395
|
+
const subagentOf = (message) => {
|
|
396
|
+
const parent = message.parent_tool_use_id;
|
|
397
|
+
return parent ? (delegations.get(parent) ?? "subagent") : undefined;
|
|
398
|
+
};
|
|
399
|
+
// Slim browsing (browser-pod.ts): every call the step makes, read or
|
|
400
|
+
// write, in the order the model asked for them — the step's own and its
|
|
401
|
+
// sub-agents'. Classified when asked for, not when answered: a call in
|
|
402
|
+
// flight when the pod goes is counted as made. And the web tool's pod log,
|
|
403
|
+
// read after every tool result.
|
|
404
|
+
const pod = opts.browserPod ?? null;
|
|
405
|
+
const ledger = new Map();
|
|
406
|
+
const podState = { reconnects: 0, reconnected: 0 };
|
|
407
|
+
let podOffset = 0;
|
|
408
|
+
const readPod = (callId) => {
|
|
409
|
+
if (!pod)
|
|
410
|
+
return;
|
|
411
|
+
let text;
|
|
412
|
+
try {
|
|
413
|
+
text = fs.readFileSync(pod.events, "utf8");
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return; // nothing written yet
|
|
417
|
+
}
|
|
418
|
+
const { events, next } = readPodEvents(text, podOffset);
|
|
419
|
+
podOffset = next;
|
|
420
|
+
let closedAgain = false;
|
|
421
|
+
for (const [i, e] of events.entries()) {
|
|
422
|
+
if (e.kind === "reconnect") {
|
|
423
|
+
podState.reconnects += 1;
|
|
424
|
+
// A reconnect that reached the pod, and then the call dropped again
|
|
425
|
+
// at once (the web tool's next line is "lost"), did not get
|
|
426
|
+
// through: the pod answered while it shut down. Live, 2026-10-01,
|
|
427
|
+
// "reconnected (try 1 of 3)" was followed by "lost after 1
|
|
428
|
+
// reconnect try" and the step read "1 got through".
|
|
429
|
+
closedAgain = !!e.ok && events[i + 1]?.kind === "lost";
|
|
430
|
+
if (closedAgain)
|
|
431
|
+
podState.closedAgain = (podState.closedAgain ?? 0) + 1;
|
|
432
|
+
else if (e.ok)
|
|
433
|
+
podState.reconnected += 1;
|
|
434
|
+
// `at` is when the try happened: these lines reach the run when the
|
|
435
|
+
// call returns, so the run's own stamps on them are all the same.
|
|
436
|
+
emit("info", `browser pod: ${e.ok ? "reconnected" : "reconnect failed"} (try ${e.attempt ?? "?"} of ${e.of ?? "?"}${podAt(e.at)})${!e.ok && e.error ? ` — ${e.error}` : ""}${closedAgain ? ", but the pod closed again" : ""}`);
|
|
437
|
+
}
|
|
438
|
+
else if (!podState.lost) {
|
|
439
|
+
// A call that never reached the pod changed nothing, whatever its
|
|
440
|
+
// arguments asked for.
|
|
441
|
+
if (e.ran === false)
|
|
442
|
+
ledger.delete(callId);
|
|
443
|
+
podState.lost = { cause: e.kind, detail: (e.kind === "needs-full" ? e.why : e.error) ?? "connection closed" };
|
|
444
|
+
emit("info", e.kind === "needs-full"
|
|
445
|
+
? `browser pod: this call needs a browser in the step, which the slim image has not (${podState.lost.detail}) — stopping the step`
|
|
446
|
+
: closedAgain
|
|
447
|
+
? `browser pod: lost — the call dropped again after the reconnect (${podState.lost.detail}${podAt(e.at)}) — stopping the step`
|
|
448
|
+
: `browser pod: lost after ${podState.reconnects} reconnect tr${podState.reconnects === 1 ? "y" : "ies"} (${podState.lost.detail}${podAt(e.at)}) — stopping the step`);
|
|
449
|
+
endWith("pod-lost");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
};
|
|
219
453
|
try {
|
|
220
454
|
for await (const message of q) {
|
|
221
455
|
if (ended)
|
|
222
456
|
break;
|
|
223
457
|
if (message.type === "assistant") {
|
|
224
|
-
const
|
|
458
|
+
const m = message.message;
|
|
459
|
+
const u = m.usage;
|
|
460
|
+
if (u)
|
|
461
|
+
turns.set(m.id ?? `turn-${anonymousTurns++}`, { u, price: priceOf(message) });
|
|
225
462
|
if (ceiling && u) {
|
|
226
|
-
spentUsd
|
|
463
|
+
spentUsd = turnsTotal();
|
|
227
464
|
if (spentUsd >= ceiling) {
|
|
228
465
|
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
466
|
status = "failed";
|
|
230
467
|
break;
|
|
231
468
|
}
|
|
232
469
|
}
|
|
470
|
+
const via = subagentOf(message);
|
|
233
471
|
for (const block of message.message.content) {
|
|
234
472
|
if (block.type === "text" && block.text.trim()) {
|
|
473
|
+
// A sub-agent's own words are its business; the parent's reply is
|
|
474
|
+
// the step's result. (The SDK forwards only tool blocks by default.)
|
|
475
|
+
if (via)
|
|
476
|
+
continue;
|
|
235
477
|
texts.push(block.text);
|
|
236
478
|
emit("text", block.text);
|
|
237
479
|
}
|
|
238
480
|
else if (block.type === "tool_use") {
|
|
239
|
-
|
|
240
|
-
|
|
481
|
+
if (!via && DELEGATE_TOOLS.includes(block.name)) {
|
|
482
|
+
const input = block.input;
|
|
483
|
+
const to = String(input?.subagent_type ?? "subagent");
|
|
484
|
+
delegations.set(block.id, to);
|
|
485
|
+
// One line a person can read: who got the job, and what it was.
|
|
486
|
+
const job = String(input?.description ?? input?.prompt ?? "").replace(/\s+/g, " ").trim().slice(0, 160);
|
|
487
|
+
emit("info", `delegating to ${to}${job ? `: ${job}` : ""}`, { subagent: to });
|
|
488
|
+
}
|
|
489
|
+
openCalls.set(block.id, { name: block.name, at: Date.now(), ...(via ? { subagent: via } : {}) });
|
|
490
|
+
if (pod)
|
|
491
|
+
ledger.set(block.id, classifyCall(block.name, block.input, pod));
|
|
492
|
+
emit("tool", block.name, { call: block.id, ...(via ? { subagent: via } : {}) });
|
|
241
493
|
}
|
|
242
494
|
}
|
|
243
495
|
}
|
|
@@ -257,7 +509,9 @@ runQuery = query) {
|
|
|
257
509
|
call: block.tool_use_id,
|
|
258
510
|
ms: Date.now() - open.at,
|
|
259
511
|
...(block.is_error ? { err: true } : {}),
|
|
512
|
+
...(open.subagent ? { subagent: open.subagent } : {}),
|
|
260
513
|
});
|
|
514
|
+
readPod(block.tool_use_id);
|
|
261
515
|
}
|
|
262
516
|
}
|
|
263
517
|
}
|
|
@@ -267,6 +521,7 @@ runQuery = query) {
|
|
|
267
521
|
emit("error", `stopped after ${opts.maxTurns ?? "its"} turns (max_turns: in the flow file) — the step did not finish`);
|
|
268
522
|
}
|
|
269
523
|
costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
|
|
524
|
+
stopReason = "stop_reason" in message && typeof message.stop_reason === "string" ? message.stop_reason : null;
|
|
270
525
|
if ("usage" in message && message.usage) {
|
|
271
526
|
const u = message.usage;
|
|
272
527
|
usage = {
|
|
@@ -291,6 +546,27 @@ runQuery = query) {
|
|
|
291
546
|
for (const t of timers)
|
|
292
547
|
clearTimeout(t);
|
|
293
548
|
}
|
|
549
|
+
// What the step used of each limit, on the trace whenever any is set —
|
|
550
|
+
// so "it stopped searching at 40" is read off the run, not guessed.
|
|
551
|
+
const limitLine = counter.summary();
|
|
552
|
+
if (limitLine)
|
|
553
|
+
emit("info", limitLine);
|
|
554
|
+
// No result message — a timeout, a stop, a budget stop, a stream that
|
|
555
|
+
// died — means no total from the SDK. The turns it did take were billed by
|
|
556
|
+
// the provider all the same, so the step's cost is their sum.
|
|
557
|
+
if (costUsd === null && turns.size) {
|
|
558
|
+
let input = 0;
|
|
559
|
+
let output = 0;
|
|
560
|
+
let priced = 0;
|
|
561
|
+
for (const { u: t, price } of turns.values()) {
|
|
562
|
+
input += (t.input_tokens ?? 0) + (t.cache_creation_input_tokens ?? 0) + (t.cache_read_input_tokens ?? 0);
|
|
563
|
+
output += t.output_tokens ?? 0;
|
|
564
|
+
priced += priceTurn(t, price);
|
|
565
|
+
}
|
|
566
|
+
costUsd = priced;
|
|
567
|
+
usage ??= { inputTokens: input, outputTokens: output };
|
|
568
|
+
emit("info", `cost from ${turns.size} model turn${turns.size === 1 ? "" : "s"}: $${priced.toFixed(4)} — the step ended before the model's closing total`);
|
|
569
|
+
}
|
|
294
570
|
if (ended === "timeout") {
|
|
295
571
|
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
572
|
status = "failed";
|
|
@@ -299,6 +575,17 @@ runQuery = query) {
|
|
|
299
575
|
emit("error", "stopped by a person mid-step");
|
|
300
576
|
status = "failed";
|
|
301
577
|
}
|
|
578
|
+
else if (ended === "pod-lost") {
|
|
579
|
+
// The runner says what happens next — a re-run on the full image, or a
|
|
580
|
+
// failure naming what was written. Not an error of the step's own.
|
|
581
|
+
status = "failed";
|
|
582
|
+
}
|
|
583
|
+
if (pod) {
|
|
584
|
+
readPod("");
|
|
585
|
+
if (podState.lost) {
|
|
586
|
+
podState.writes = [...ledger.values()].flatMap((k) => (k.write ? [k.what] : []));
|
|
587
|
+
}
|
|
588
|
+
}
|
|
302
589
|
// The SDK ends a healthy run with a `result` message. A stream that just
|
|
303
590
|
// stops — subprocess OOM-killed, crashed, or torn down — used to fall
|
|
304
591
|
// through as "completed", which reported a run that produced nothing as a
|
|
@@ -328,31 +615,83 @@ runQuery = query) {
|
|
|
328
615
|
// failure as no value, and the message names the field.
|
|
329
616
|
const errors = opts.schema !== undefined && looksLikeSchema(opts.schema) ? validateSchema(data, opts.schema) : [];
|
|
330
617
|
if (errors.length) {
|
|
331
|
-
emit("error", `schema: the value does not fit — ${describeSchemaErrors(errors)}
|
|
618
|
+
emit("error", `schema: the value does not fit — ${describeSchemaErrors(errors)}`, CHECK);
|
|
332
619
|
status = "failed";
|
|
333
620
|
}
|
|
334
621
|
}
|
|
335
622
|
else {
|
|
336
|
-
emit("error", `output: json — ${extracted.reason}
|
|
623
|
+
emit("error", `output: json — ${extracted.reason}`, CHECK);
|
|
337
624
|
status = "failed";
|
|
338
625
|
}
|
|
339
626
|
}
|
|
340
627
|
// "Done" should mean a check passed, not that the model stopped talking.
|
|
628
|
+
// The loop's clocks are cleared by now, so the check gets its own: what is
|
|
629
|
+
// left of the step's `timeout:` (never less than VERIFY_FLOOR_MS of it),
|
|
630
|
+
// and a stop read on the same clock the loop used. No `timeout:`, no clock:
|
|
631
|
+
// it is the only one there is, here as in the loop. A `verify:` that hangs
|
|
632
|
+
// — a build waiting on a lock, a judge that never answers — used to hold
|
|
633
|
+
// the step, and the run, with nothing to end it.
|
|
341
634
|
if (status === "completed" && opts.verify) {
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (
|
|
635
|
+
const verifyMs = verifyBudgetMs(opts.timeoutSec, Date.now() - startedAt);
|
|
636
|
+
const halt = new AbortController();
|
|
637
|
+
let cut = null;
|
|
638
|
+
const clocks = [];
|
|
639
|
+
if (verifyMs !== null) {
|
|
640
|
+
clocks.push(setTimeout(() => {
|
|
641
|
+
cut ??= "timeout";
|
|
642
|
+
halt.abort();
|
|
643
|
+
}, verifyMs));
|
|
644
|
+
}
|
|
645
|
+
if (opts.stopRequested) {
|
|
646
|
+
clocks.push(setInterval(() => {
|
|
647
|
+
try {
|
|
648
|
+
if (opts.stopRequested()) {
|
|
649
|
+
cut ??= "stopped";
|
|
650
|
+
halt.abort();
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
// an unreadable record is not a stop
|
|
655
|
+
}
|
|
656
|
+
}, STOP_POLL_MS));
|
|
657
|
+
}
|
|
658
|
+
let verdict;
|
|
659
|
+
try {
|
|
660
|
+
verdict = await checkVerify(agentDir, opts.verify, {
|
|
661
|
+
env: opts.verifyEnv ?? {},
|
|
662
|
+
result,
|
|
663
|
+
conclusion,
|
|
664
|
+
data,
|
|
665
|
+
modelEnv: opts.env,
|
|
666
|
+
signal: halt.signal,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
finally {
|
|
670
|
+
for (const t of clocks)
|
|
671
|
+
clearTimeout(t);
|
|
672
|
+
}
|
|
673
|
+
if (cut === "timeout") {
|
|
674
|
+
emit("error", `verify \`${opts.verify}\` → timed out after ${Math.round(verifyMs / 1000)}s (what was left of timeout: in the flow file) — it was stopped`, CHECK);
|
|
675
|
+
status = "failed";
|
|
676
|
+
}
|
|
677
|
+
else if (cut === "stopped") {
|
|
678
|
+
emit("error", `verify \`${opts.verify}\` → stopped by a person mid-check`, CHECK);
|
|
353
679
|
status = "failed";
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
emit(verdict.ok ? "info" : "error", `verify \`${opts.verify}\` → ${verdict.headline}`, verdict.ok ? undefined : CHECK);
|
|
683
|
+
if (verdict.detail.trim())
|
|
684
|
+
emit(verdict.ok ? "info" : "error", verdict.detail.slice(0, 1000), verdict.ok ? undefined : CHECK);
|
|
685
|
+
if (!verdict.ok)
|
|
686
|
+
status = "failed";
|
|
687
|
+
}
|
|
354
688
|
}
|
|
355
|
-
return {
|
|
689
|
+
return {
|
|
690
|
+
status, result, conclusion, ...(opts.output ? { data } : {}), costUsd, usage,
|
|
691
|
+
...(mixedModels() ? { turnsCostUsd: turnsTotal() } : {}),
|
|
692
|
+
stopReason,
|
|
693
|
+
...(pod ? { browserPod: podState } : {}),
|
|
694
|
+
};
|
|
356
695
|
}
|
|
357
696
|
// ------------------------------------------------------------ output: json
|
|
358
697
|
/**
|
|
@@ -410,8 +749,21 @@ const VERIFY_ASSERTION = /^(contains|not-contains|matches|file|judge):\s*([\s\S]
|
|
|
410
749
|
export async function checkVerify(agentDir, verify, ctx) {
|
|
411
750
|
const m = verify.trim().match(VERIFY_ASSERTION);
|
|
412
751
|
if (!m) {
|
|
413
|
-
|
|
414
|
-
|
|
752
|
+
// The step's final turn, as a file named in FOLDRUN_REPLY_FILE — so one
|
|
753
|
+
// command can check the reply and the artefact together
|
|
754
|
+
// (`grep -q '^READY' "$FOLDRUN_REPLY_FILE" && test -s out.json`). A step
|
|
755
|
+
// has one `verify:`, and a shell check that could not see the reply
|
|
756
|
+
// made authors choose between the two.
|
|
757
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-reply-"));
|
|
758
|
+
const replyFile = path.join(dir, "reply.md");
|
|
759
|
+
fs.writeFileSync(replyFile, ctx.conclusion ?? ctx.result ?? "");
|
|
760
|
+
try {
|
|
761
|
+
const { code, out } = await runVerify(agentDir, verify, { ...ctx.env, FOLDRUN_REPLY_FILE: replyFile }, ctx.data, ctx.signal);
|
|
762
|
+
return { ok: code === 0, headline: `exit ${code ?? "error"}`, detail: out };
|
|
763
|
+
}
|
|
764
|
+
finally {
|
|
765
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
766
|
+
}
|
|
415
767
|
}
|
|
416
768
|
const [, kind, rawValue] = m;
|
|
417
769
|
const value = rawValue.trim().replace(/^["']|["']$/g, "");
|
|
@@ -436,15 +788,20 @@ export async function checkVerify(agentDir, verify, ctx) {
|
|
|
436
788
|
}
|
|
437
789
|
}
|
|
438
790
|
case "file": {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
791
|
+
// Agent-relative like every path in a flow, or `workspace/…` from the
|
|
792
|
+
// workspace root; confined to the workspace. It was confined to the
|
|
793
|
+
// agent's own folder, so the one place a step's deliverable belongs —
|
|
794
|
+
// `workspace/storage/x`, or `../../storage/x` — was always "escapes".
|
|
795
|
+
const workspaceRoot = path.resolve(agentDir, "..", "..");
|
|
796
|
+
const target = resolveAgentPath(workspaceRoot, agentDir, value);
|
|
797
|
+
if (!isWithin(workspaceRoot, target) || target === workspaceRoot) {
|
|
798
|
+
return { ok: false, headline: "path escapes the workspace", detail: "" };
|
|
442
799
|
}
|
|
443
800
|
const ok = fs.existsSync(target) && fs.statSync(target).size > 0;
|
|
444
801
|
return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
|
|
445
802
|
}
|
|
446
803
|
case "judge": {
|
|
447
|
-
const verdict = await judgeReply(value, output, ctx.modelEnv ?? {});
|
|
804
|
+
const verdict = await judgeReply(value, output, ctx.modelEnv ?? {}, ctx.signal);
|
|
448
805
|
const ok = /^\s*PASS\b/i.test(verdict);
|
|
449
806
|
return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
|
|
450
807
|
}
|
|
@@ -456,8 +813,12 @@ export async function checkVerify(agentDir, verify, ctx) {
|
|
|
456
813
|
* Same shape as the eval judge, and answered with one word first so the
|
|
457
814
|
* verdict is a prefix test rather than a reading.
|
|
458
815
|
*/
|
|
459
|
-
async function judgeReply(rubric, output, env) {
|
|
816
|
+
async function judgeReply(rubric, output, env, signal) {
|
|
460
817
|
const texts = [];
|
|
818
|
+
const abortController = new AbortController();
|
|
819
|
+
if (signal?.aborted)
|
|
820
|
+
abortController.abort();
|
|
821
|
+
signal?.addEventListener("abort", () => abortController.abort(), { once: true });
|
|
461
822
|
try {
|
|
462
823
|
const q = query({
|
|
463
824
|
prompt: `You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
|
|
@@ -468,8 +829,10 @@ async function judgeReply(rubric, output, env) {
|
|
|
468
829
|
systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
|
|
469
830
|
tools: [],
|
|
470
831
|
settingSources: [],
|
|
471
|
-
env,
|
|
832
|
+
env: sdkEnv(env),
|
|
833
|
+
strictMcpConfig: true,
|
|
472
834
|
maxTurns: 1,
|
|
835
|
+
abortController,
|
|
473
836
|
},
|
|
474
837
|
});
|
|
475
838
|
for await (const message of q) {
|
|
@@ -488,19 +851,37 @@ async function judgeReply(rubric, output, env) {
|
|
|
488
851
|
}
|
|
489
852
|
// Verification runs in the agent's directory with its secrets available, so
|
|
490
853
|
// a check can be as simple as `npm run build` or `test -s outputs/report.md`.
|
|
491
|
-
function runVerify(agentDir, command, env, data) {
|
|
854
|
+
function runVerify(agentDir, command, env, data, signal) {
|
|
492
855
|
return new Promise((resolve) => {
|
|
493
|
-
//
|
|
494
|
-
//
|
|
856
|
+
// The clock is the caller's (executeStep): what is left of the step's
|
|
857
|
+
// `timeout:`, ended through `signal`, which a stop also fires.
|
|
495
858
|
// The allowlisted host base plus what the caller passed: the step's
|
|
496
859
|
// secrets and identifiers on the in-process path (runner.ts), the
|
|
497
860
|
// container's own environment on the isolated one (run-container.ts).
|
|
498
861
|
// Never process.env whole — see host-env.ts.
|
|
499
862
|
const child = spawn("bash", ["-lc", command], {
|
|
500
863
|
cwd: agentDir,
|
|
501
|
-
|
|
864
|
+
// workspaceLinkEnv: `node workspace/tools/x/check.mjs` must run its
|
|
865
|
+
// check, not pass for having skipped it (confine.ts says how).
|
|
866
|
+
env: workspaceLinkEnv({ ...hostSafeEnv(), ...env }, agentDir),
|
|
502
867
|
stdio: ["pipe", "pipe", "pipe"],
|
|
868
|
+
// Its own process group, so an abort ends what the shell started
|
|
869
|
+
// (`npm run build` and its children) and not only bash.
|
|
870
|
+
detached: true,
|
|
503
871
|
});
|
|
872
|
+
const kill = () => {
|
|
873
|
+
try {
|
|
874
|
+
process.kill(-child.pid, "SIGKILL");
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
child.kill("SIGKILL");
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
if (signal?.aborted)
|
|
881
|
+
kill();
|
|
882
|
+
else
|
|
883
|
+
signal?.addEventListener("abort", kill, { once: true });
|
|
884
|
+
child.on("close", () => signal?.removeEventListener("abort", kill));
|
|
504
885
|
// An `output: json` step's data arrives on stdin, so a check can be
|
|
505
886
|
// `jq -e '.total > 0'` — arithmetic in a real tool, reading the value
|
|
506
887
|
// the step actually returned rather than re-parsing its prose.
|
|
@@ -520,3 +901,16 @@ function runVerify(agentDir, command, env, data) {
|
|
|
520
901
|
child.on("close", (code) => resolve({ code, out }));
|
|
521
902
|
});
|
|
522
903
|
}
|
|
904
|
+
/**
|
|
905
|
+
* The environment an Agent SDK session starts with: the step's, minus the
|
|
906
|
+
* connectors of whoever is signed in to claude.ai on this machine. Claude
|
|
907
|
+
* Code loads an account's claude.ai MCP servers (Docs, Drive, …) unless told
|
|
908
|
+
* not to, and `settingSources: []` does not reach them — they come with the
|
|
909
|
+
* login, not from a settings file. A local `foldrun run` handed every agent
|
|
910
|
+
* the person's own connectors: the hello template's agents reported that
|
|
911
|
+
* "the only tools I have are the Claude Docs ones". `strictMcpConfig` keeps
|
|
912
|
+
* the rest of the on-disk MCP configuration out; this keeps the account's.
|
|
913
|
+
*/
|
|
914
|
+
export function sdkEnv(base, extra = {}) {
|
|
915
|
+
return { ...(base ?? process.env), ...extra, ENABLE_CLAUDEAI_MCP_SERVERS: "false" };
|
|
916
|
+
}
|