@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/src/step-exec.ts
CHANGED
|
@@ -9,15 +9,20 @@
|
|
|
9
9
|
// library and the account do not exist.
|
|
10
10
|
|
|
11
11
|
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
12
13
|
import path from "node:path";
|
|
13
14
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
14
|
-
import type { McpServerConfig, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
15
|
+
import type { HookInput, McpServerConfig, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
15
16
|
import type { Effort } from "./store.ts";
|
|
16
17
|
import type { TestEffect } from "./test-mode.ts";
|
|
18
|
+
import type { OperatorEvent } from "./operator.ts";
|
|
17
19
|
import { spawn } from "node:child_process";
|
|
18
|
-
import { checkPaths, checkBash, isFilesystemTool } from "./confine.ts";
|
|
20
|
+
import { checkPaths, checkBash, isFilesystemTool, linkWorkspace, workspaceLinkEnv, resolveAgentPath, isWithin } from "./confine.ts";
|
|
21
|
+
import { DELEGATE_TOOLS, subagentGuard, toAgentDefinitions, type SubagentSpec } from "./subagents.ts";
|
|
19
22
|
import { hostSafeEnv } from "./host-env.ts";
|
|
20
23
|
import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.ts";
|
|
24
|
+
import { CallCounter, limitKeysFor, type Limits } from "./limits.ts";
|
|
25
|
+
import { classifyCall, podAt, readPodEvents, type BrowserPodOutcome, type CallKind, type ClassifyContext } from "./browser-pod.ts";
|
|
21
26
|
|
|
22
27
|
export interface ExecOutcome {
|
|
23
28
|
status: "completed" | "failed";
|
|
@@ -35,6 +40,20 @@ export interface ExecOutcome {
|
|
|
35
40
|
* Anthropic's table, which is wrong for a routed model — these are the
|
|
36
41
|
* raw numbers a caller with a gateway's own prices can reprice from. */
|
|
37
42
|
usage: { inputTokens: number; outputTokens: number } | null;
|
|
43
|
+
/** Set only when some turns ran on a different model from the step's — a
|
|
44
|
+
* sub-agent with its own `model:`. The sum of every turn, each priced at
|
|
45
|
+
* its own model. `usage` above is one lump and cannot be repriced at the
|
|
46
|
+
* step's model without charging the sub-agent's turns at the wrong rate;
|
|
47
|
+
* the runner reads this instead (repriced in runner.ts). */
|
|
48
|
+
turnsCostUsd?: number;
|
|
49
|
+
/** Why the model's last turn ended, as the SDK's result message says:
|
|
50
|
+
* end_turn, max_tokens, stop_sequence, tool_use, pause_turn, refusal or
|
|
51
|
+
* model_context_window_exceeded. Null when no result arrived. */
|
|
52
|
+
stopReason?: string | null;
|
|
53
|
+
/** Set when the step browsed through the account's browser pod from the
|
|
54
|
+
* slim image (ExecOptions.browserPod): its reconnects, and — when the pod
|
|
55
|
+
* was lost — why, and what the step had written by then. */
|
|
56
|
+
browserPod?: BrowserPodOutcome;
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
export interface ExecOptions {
|
|
@@ -90,11 +109,45 @@ export interface ExecOptions {
|
|
|
90
109
|
* runner answers from the run record (stopRun writes there from another
|
|
91
110
|
* process); a container has a sandbox that is destroyed instead. */
|
|
92
111
|
stopRequested?: () => boolean;
|
|
112
|
+
/** Reads the run's inbox (operator.ts inboxReader): what a person wrote
|
|
113
|
+
* in while the step runs, handed to the model after its next tool call.
|
|
114
|
+
* Absent where nobody can write in (no egress proxy). */
|
|
115
|
+
inbox?: () => Promise<string | null>;
|
|
116
|
+
/** `subagents:` — colleagues the model may delegate to, each with its own
|
|
117
|
+
* context and tools no wider than this step's (subagents.ts). Absent or
|
|
118
|
+
* empty: no Agent tool, as before. */
|
|
119
|
+
subagents?: SubagentSpec[];
|
|
120
|
+
/** `limits:` — the most calls this step may make, per tool and in all,
|
|
121
|
+
* resolved host-side (account, workspace, agent, step: nearest wins per
|
|
122
|
+
* key). Counted in the PreToolUse hook below; see limits.ts. */
|
|
123
|
+
limits?: Limits;
|
|
124
|
+
/** SDK tool name → the foldrun name it counts under, for the tools whose
|
|
125
|
+
* SDK name does not say (limits.ts toolOwners). */
|
|
126
|
+
toolOwners?: Record<string, string>;
|
|
127
|
+
/** The step runs on the slim image and browses through the account's
|
|
128
|
+
* browser pod (browser-pod.ts). `events` is the file the web tool writes
|
|
129
|
+
* its reconnects to; the classify context says which API operations and
|
|
130
|
+
* web actions are writes. A lost pod stops the step, and the outcome
|
|
131
|
+
* says what it had written, so the runner can re-run it or fail it. */
|
|
132
|
+
browserPod?: { events: string } & ClassifyContext;
|
|
93
133
|
emit: (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
|
|
94
134
|
}
|
|
95
135
|
|
|
96
136
|
/** How often the stop flag is read while a step runs. */
|
|
97
137
|
export const STOP_POLL_MS = 2000;
|
|
138
|
+
/** The least a `verify:` gets when the step used up most of its `timeout:` —
|
|
139
|
+
* capped by the timeout itself, so the step and its check together never
|
|
140
|
+
* run past timeout + this. Under the isolated path's backstop (+60 s). */
|
|
141
|
+
export const VERIFY_FLOOR_MS = 30_000;
|
|
142
|
+
|
|
143
|
+
/** How long a `verify:` may run: what is left of the step's `timeout:`,
|
|
144
|
+
* with a floor. Null when the flow set no timeout — `timeout:` is the only
|
|
145
|
+
* clock there is, and a stop still ends the check. */
|
|
146
|
+
export function verifyBudgetMs(timeoutSec: number | undefined, elapsedMs: number): number | null {
|
|
147
|
+
if (!timeoutSec || timeoutSec <= 0) return null;
|
|
148
|
+
const total = timeoutSec * 1000;
|
|
149
|
+
return Math.max(total - elapsedMs, Math.min(VERIFY_FLOOR_MS, total));
|
|
150
|
+
}
|
|
98
151
|
/** After an interrupt is asked for, how long the model loop gets to wind
|
|
99
152
|
* down on its own before the query is aborted outright. */
|
|
100
153
|
export const INTERRUPT_GRACE_MS = 10_000;
|
|
@@ -105,7 +158,13 @@ export type QueryLike = AsyncIterable<unknown> & { interrupt(): Promise<unknown>
|
|
|
105
158
|
export type QueryFn = (args: { prompt: string; options: Record<string, unknown> }) => QueryLike;
|
|
106
159
|
|
|
107
160
|
/** The pairing fields on a tool event — see RunEvent in store.ts. */
|
|
108
|
-
export type EventExtra = { call?: string; ms?: number; err?: boolean; effect?: TestEffect };
|
|
161
|
+
export type EventExtra = { call?: string; ms?: number; err?: boolean; effect?: TestEffect; operator?: OperatorEvent; subagent?: string; check?: boolean };
|
|
162
|
+
|
|
163
|
+
/** The tag on every event a failed check writes — `verify:`, `output: json`,
|
|
164
|
+
* a schema — headline and detail alike. The runner's no-retry rule for
|
|
165
|
+
* outward steps reads it (actedThenFailedCheck); reading the wording of the
|
|
166
|
+
* last error missed a check whose detail line came after its headline. */
|
|
167
|
+
const CHECK = { check: true } as const;
|
|
109
168
|
|
|
110
169
|
|
|
111
170
|
/** Conservative per-token rates for a model nothing else can price — an
|
|
@@ -179,7 +238,11 @@ export function stepCeiling(budgetUsd: number | null | undefined, spentUsd: numb
|
|
|
179
238
|
*
|
|
180
239
|
* Two caps can apply to one step: the flow's `budget:` gives it an equal
|
|
181
240
|
* share of what the run has left; the agent's own `budget:` is the most that
|
|
182
|
-
* agent may spend in one run, less what its
|
|
241
|
+
* agent may spend in one run, less what its steps in this run have cost or
|
|
242
|
+
* may still cost, shared across `sharing` — this step and the agent's steps
|
|
243
|
+
* launched with it that have not started yet. Without the share, twenty
|
|
244
|
+
* fan-out copies of one agent each got the whole remainder, because none of
|
|
245
|
+
* them has recorded a cent when the next one starts.
|
|
183
246
|
* The tighter one wins, and the note says which, because "over budget" with
|
|
184
247
|
* no line to go and raise is a message that sends someone to the wrong file.
|
|
185
248
|
*/
|
|
@@ -188,10 +251,11 @@ export function stepCeilingFor(
|
|
|
188
251
|
agentBudgetUsd: number | null | undefined,
|
|
189
252
|
agentSpentUsd: number,
|
|
190
253
|
agent: string,
|
|
254
|
+
sharing = 1,
|
|
191
255
|
): { ceilingUsd: number | null; note: string } {
|
|
192
256
|
const flow = { ceilingUsd: flowShareUsd, note: "budget: in the flow file" };
|
|
193
257
|
if (!agentBudgetUsd || agentBudgetUsd <= 0) return flow;
|
|
194
|
-
const left = Math.max(0, agentBudgetUsd - agentSpentUsd);
|
|
258
|
+
const left = Math.max(0, agentBudgetUsd - agentSpentUsd) / Math.max(1, sharing);
|
|
195
259
|
if (flowShareUsd !== null && flowShareUsd <= left) return flow;
|
|
196
260
|
return { ceilingUsd: left, note: `budget: on the ${agent} agent` };
|
|
197
261
|
}
|
|
@@ -202,10 +266,30 @@ export async function executeStep(
|
|
|
202
266
|
* without a model; production passes nothing and gets the SDK. */
|
|
203
267
|
runQuery: QueryFn = query as unknown as QueryFn,
|
|
204
268
|
): Promise<ExecOutcome> {
|
|
269
|
+
// `workspace/…` for the shell, the scripts and a shell `verify:` — the
|
|
270
|
+
// file tools expand the prefix themselves. Held for the step, verify
|
|
271
|
+
// included, and gone before anyone reads the step's files back
|
|
272
|
+
// (confine.ts#linkWorkspace says why it is not permanent).
|
|
273
|
+
fs.mkdirSync(opts.agentDir, { recursive: true });
|
|
274
|
+
const link = linkWorkspace(opts.agentDir, opts.workspaceRoot);
|
|
275
|
+
if (link.note) opts.emit("info", `workspace/: ${link.note}`);
|
|
276
|
+
try {
|
|
277
|
+
// And a Node program started through it is still its own main module
|
|
278
|
+
// (confine.ts#workspaceLinkEnv) — for the model's Bash, which inherits
|
|
279
|
+
// the SDK's env; the shell verify: and the scripts get it where they
|
|
280
|
+
// spawn.
|
|
281
|
+
return await executeStepInner({ ...opts, env: workspaceLinkEnv(opts.env, opts.agentDir) }, runQuery);
|
|
282
|
+
} finally {
|
|
283
|
+
link.release();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<ExecOutcome> {
|
|
205
288
|
const { agentDir, workspaceRoot, libraryRoot, emit } = opts;
|
|
206
289
|
let status: "running" | "completed" | "failed" = "running";
|
|
207
290
|
let costUsd: number | null = null;
|
|
208
291
|
let usage: ExecOutcome["usage"] = null;
|
|
292
|
+
let stopReason: string | null = null;
|
|
209
293
|
const texts: string[] = [];
|
|
210
294
|
|
|
211
295
|
fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
|
|
@@ -215,6 +299,16 @@ export async function executeStep(
|
|
|
215
299
|
// stop both ask the query to interrupt, and abort it outright if it has
|
|
216
300
|
// not wound down within the grace period.
|
|
217
301
|
const abort = new AbortController();
|
|
302
|
+
const subagents = opts.subagents?.length ? opts.subagents : null;
|
|
303
|
+
const guardSubagent = subagents ? subagentGuard(subagents) : null;
|
|
304
|
+
// A sub-agent's calls reach both the hook (which sees its id AND name) and
|
|
305
|
+
// canUseTool (which sees only the id). Remembered here so every refusal —
|
|
306
|
+
// its own list, a path outside the workspace, a shell command — names the
|
|
307
|
+
// agent that tried, not just the step.
|
|
308
|
+
const subagentNames = new Map<string, string>();
|
|
309
|
+
// `limits:` — this step's counts. A new step, a retry of one, or a
|
|
310
|
+
// fan-out instance each start from zero.
|
|
311
|
+
const counter = new CallCounter(opts.limits);
|
|
218
312
|
|
|
219
313
|
const q = runQuery({
|
|
220
314
|
prompt: opts.prompt,
|
|
@@ -228,6 +322,9 @@ export async function executeStep(
|
|
|
228
322
|
...(opts.effort ? { effort: opts.effort } : {}),
|
|
229
323
|
systemPrompt: opts.systemPrompt,
|
|
230
324
|
...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}),
|
|
325
|
+
// Sub-agents: the SDK's own delegation. Every definition carries an
|
|
326
|
+
// explicit tool list (an absent one inherits all of ours).
|
|
327
|
+
...(subagents ? { agents: toAgentDefinitions(subagents) } : {}),
|
|
231
328
|
// Restrict the toolset itself, not just approval: an agent that
|
|
232
329
|
// declares no tools gets none, instead of seeing the full Claude
|
|
233
330
|
// Code toolset and burning turns on denied calls.
|
|
@@ -254,13 +351,92 @@ export async function executeStep(
|
|
|
254
351
|
autoAllowBashIfSandboxed: false,
|
|
255
352
|
failIfUnavailable: false,
|
|
256
353
|
},
|
|
257
|
-
|
|
354
|
+
// With sub-agents, the SDK would also offer its built-in ones
|
|
355
|
+
// (general-purpose has every tool the session has). Only the declared
|
|
356
|
+
// ones may run; the guard in the hook below refuses the rest as well.
|
|
357
|
+
env: sdkEnv(opts.env, subagents ? { CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS: "1" } : {}),
|
|
258
358
|
mcpServers: opts.mcpServers,
|
|
259
|
-
|
|
359
|
+
// Only the servers foldrun passes. See sdkEnv for the claude.ai half.
|
|
360
|
+
strictMcpConfig: true,
|
|
361
|
+
// canUseTool is only asked when the SDK wants permission, and it never
|
|
362
|
+
// asks for a read inside the cwd — so `Read workspace/storage/x` went
|
|
363
|
+
// to `<agentDir>/workspace/storage/x` unchecked and unrewritten. On
|
|
364
|
+
// 2026-09-24 that read a stray copy a broken write had left there,
|
|
365
|
+
// with no error. A PreToolUse hook runs on every call: expand the
|
|
366
|
+
// virtual prefix (and refuse an escape) here for every filesystem tool,
|
|
367
|
+
// and leave the rest of the decision to canUseTool.
|
|
368
|
+
hooks: {
|
|
369
|
+
PreToolUse: [{
|
|
370
|
+
hooks: [async (hookInput: HookInput) => {
|
|
371
|
+
if (hookInput.hook_event_name !== "PreToolUse") return {};
|
|
372
|
+
// A sub-agent's calls come through here too, tagged with its
|
|
373
|
+
// name; anything outside its own list — or from a sub-agent this
|
|
374
|
+
// step did not declare — is refused before the shared checks
|
|
375
|
+
// below (which apply to it unchanged). So is a delegation from
|
|
376
|
+
// the main thread to anything but a declared sub-agent.
|
|
377
|
+
const agentId = "agent_id" in hookInput && hookInput.agent_id ? String(hookInput.agent_id) : undefined;
|
|
378
|
+
const agentType = agentId ? (hookInput as { agent_type?: string }).agent_type : undefined;
|
|
379
|
+
const via = agentId ? (agentType ?? "subagent") : undefined;
|
|
380
|
+
if (agentId && via) subagentNames.set(agentId, via);
|
|
381
|
+
if (guardSubagent) {
|
|
382
|
+
const why = guardSubagent({ agentId, agentType, tool: hookInput.tool_name, input: (hookInput.tool_input ?? {}) as Record<string, unknown> });
|
|
383
|
+
if (why) {
|
|
384
|
+
emit("error", why, { subagent: via });
|
|
385
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: why } };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const verdict = isFilesystemTool(hookInput.tool_name)
|
|
389
|
+
? checkPaths(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, { agentDir, workspaceRoot, libraryRoot })
|
|
390
|
+
: null;
|
|
391
|
+
if (verdict && !verdict.ok) {
|
|
392
|
+
emit("error", verdict.reason!, via ? { subagent: via } : undefined);
|
|
393
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: verdict.reason! } };
|
|
394
|
+
}
|
|
395
|
+
// `limits:` — last, so a call another check refused counts
|
|
396
|
+
// toward nothing. Every call passes here, the step's own and its
|
|
397
|
+
// sub-agents', so one count covers both. A call past a limit is
|
|
398
|
+
// refused before it runs: the tool, and any paid API behind it,
|
|
399
|
+
// never sees it.
|
|
400
|
+
if (counter.active) {
|
|
401
|
+
const refusal = counter.take(limitKeysFor(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, opts.toolOwners));
|
|
402
|
+
if (refusal) {
|
|
403
|
+
emit("info", `${refusal.message} (${hookInput.tool_name} refused)`, via ? { subagent: via } : undefined);
|
|
404
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: refusal.message } };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (!verdict?.updatedInput) return {};
|
|
408
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "allow" as const, updatedInput: verdict.updatedInput } };
|
|
409
|
+
}],
|
|
410
|
+
}],
|
|
411
|
+
// A message a person sent into the running step reaches the model
|
|
412
|
+
// after its next tool call, as context — never as a change to what it
|
|
413
|
+
// may do. A model that is only writing, calling no tools, hears it at
|
|
414
|
+
// the next call it makes.
|
|
415
|
+
...(opts.inbox
|
|
416
|
+
? {
|
|
417
|
+
PostToolUse: [{
|
|
418
|
+
hooks: [async (hookInput: HookInput) => {
|
|
419
|
+
if (hookInput.hook_event_name !== "PostToolUse") return {};
|
|
420
|
+
// A sub-agent's call: the message is for the step's own
|
|
421
|
+
// model, and draining it here would hand it to a context
|
|
422
|
+
// that ends with the delegation — and lose it.
|
|
423
|
+
if ("agent_id" in hookInput && hookInput.agent_id) return {};
|
|
424
|
+
const context = await opts.inbox!().catch(() => null);
|
|
425
|
+
return context ? { hookSpecificOutput: { hookEventName: "PostToolUse" as const, additionalContext: context } } : {};
|
|
426
|
+
}],
|
|
427
|
+
}],
|
|
428
|
+
}
|
|
429
|
+
: {}),
|
|
430
|
+
},
|
|
431
|
+
canUseTool: async (toolName: string, input: Record<string, unknown>, options?: { agentID?: string }) => {
|
|
432
|
+
const via = options?.agentID ? (subagentNames.get(options.agentID) ?? "subagent") : undefined;
|
|
260
433
|
// The toolset was already narrowed to what the agent declared, so
|
|
261
434
|
// anything outside it is a denial with a reason the model can act on.
|
|
262
435
|
const fromGrantedServer = opts.mcpNames.some((n) => toolName.startsWith(`mcp__${n}__`));
|
|
436
|
+
// A call the hook counted and this refuses never ran: give it back.
|
|
437
|
+
const refund = () => { if (counter.active) counter.refund(limitKeysFor(toolName, input, opts.toolOwners)); };
|
|
263
438
|
if (!opts.allowed.includes(toolName) && !fromGrantedServer) {
|
|
439
|
+
refund();
|
|
264
440
|
return {
|
|
265
441
|
behavior: "deny" as const,
|
|
266
442
|
message: `Tool ${toolName} is not enabled for this agent.`,
|
|
@@ -273,7 +449,8 @@ export async function executeStep(
|
|
|
273
449
|
? checkPaths(toolName, input, { agentDir, workspaceRoot, libraryRoot })
|
|
274
450
|
: { ok: true as const };
|
|
275
451
|
if (!verdict.ok) {
|
|
276
|
-
|
|
452
|
+
refund();
|
|
453
|
+
emit("error", verdict.reason!, via ? { subagent: via } : undefined);
|
|
277
454
|
return { behavior: "deny" as const, message: verdict.reason! };
|
|
278
455
|
}
|
|
279
456
|
return { behavior: "allow" as const, updatedInput: verdict.updatedInput ?? input };
|
|
@@ -286,9 +463,10 @@ export async function executeStep(
|
|
|
286
463
|
// call — a crawl, a build — sailed past its timeout: with no message there
|
|
287
464
|
// was nothing to check it against. The backstop timer fires regardless,
|
|
288
465
|
// and a stop is read on a clock rather than between groups.
|
|
289
|
-
let ended: "timeout" | "stopped" | null = null;
|
|
466
|
+
let ended: "timeout" | "stopped" | "pod-lost" | null = null;
|
|
467
|
+
const startedAt = Date.now();
|
|
290
468
|
const timers: NodeJS.Timeout[] = [];
|
|
291
|
-
const endWith = (why: "timeout" | "stopped") => {
|
|
469
|
+
const endWith = (why: "timeout" | "stopped" | "pod-lost") => {
|
|
292
470
|
if (ended) return;
|
|
293
471
|
ended = why;
|
|
294
472
|
void q.interrupt().catch(() => {});
|
|
@@ -316,31 +494,126 @@ export async function executeStep(
|
|
|
316
494
|
// that is the honest direction to be wrong in.
|
|
317
495
|
const ceiling = typeof opts.budgetUsd === "number" && opts.budgetUsd > 0 ? opts.budgetUsd : null;
|
|
318
496
|
let spentUsd = 0;
|
|
497
|
+
// Every turn's usage, by the model message's id. The SDK can emit one
|
|
498
|
+
// turn as several assistant messages that repeat the same usage, so a
|
|
499
|
+
// turn is counted once — and the sum is the step's cost whenever the
|
|
500
|
+
// closing `result` never arrives: a timeout or a stop ends the loop before
|
|
501
|
+
// it, and a step that made 193 model calls was recorded as $0 (lawyer-desk,
|
|
502
|
+
// 2026-09-25 09:00).
|
|
503
|
+
// Each with the rate it is priced at: a sub-agent with its own `model:`
|
|
504
|
+
// runs its turns on that model, and pricing them at the parent's put a
|
|
505
|
+
// max sub-agent under a fast parent at a fifth of its cost — against the
|
|
506
|
+
// ceiling and on the bill.
|
|
507
|
+
const turns = new Map<string, { u: Record<string, number | undefined>; price: { input: number; output: number } | null }>();
|
|
508
|
+
let anonymousTurns = 0;
|
|
509
|
+
const specByName = new Map((subagents ?? []).map((s) => [s.name, s]));
|
|
510
|
+
const priceOf = (message: unknown): { input: number; output: number } | null => {
|
|
511
|
+
const parent = (message as { parent_tool_use_id?: string | null }).parent_tool_use_id;
|
|
512
|
+
if (!parent) return opts.price ?? null;
|
|
513
|
+
const declared = specByName.get(delegations.get(parent) ?? "")?.model;
|
|
514
|
+
if (declared === undefined || declared === "inherit" || declared === opts.model) return opts.price ?? null;
|
|
515
|
+
const model = (message as { message?: { model?: unknown } }).message?.model;
|
|
516
|
+
return knownPrice(typeof model === "string" ? model : undefined) ?? knownPrice(declared);
|
|
517
|
+
};
|
|
518
|
+
const turnsTotal = () => [...turns.values()].reduce((sum, t) => sum + priceTurn(t.u, t.price), 0);
|
|
519
|
+
const mixedModels = () => [...turns.values()].some((t) => t.price !== (opts.price ?? null));
|
|
319
520
|
|
|
320
521
|
// Open tool calls, by the provider's id, so the result can be paired with
|
|
321
522
|
// its call and the trace can say how long each tool ran.
|
|
322
|
-
const openCalls = new Map<string, { name: string; at: number }>();
|
|
523
|
+
const openCalls = new Map<string, { name: string; at: number; subagent?: string }>();
|
|
524
|
+
// Delegations in flight: the parent's Agent call id → the sub-agent's name.
|
|
525
|
+
// The SDK tags every message a sub-agent produces with that call's id
|
|
526
|
+
// (parent_tool_use_id), which is how its tool calls are labelled here.
|
|
527
|
+
const delegations = new Map<string, string>();
|
|
528
|
+
const subagentOf = (message: unknown): string | undefined => {
|
|
529
|
+
const parent = (message as { parent_tool_use_id?: string | null }).parent_tool_use_id;
|
|
530
|
+
return parent ? (delegations.get(parent) ?? "subagent") : undefined;
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
// Slim browsing (browser-pod.ts): every call the step makes, read or
|
|
534
|
+
// write, in the order the model asked for them — the step's own and its
|
|
535
|
+
// sub-agents'. Classified when asked for, not when answered: a call in
|
|
536
|
+
// flight when the pod goes is counted as made. And the web tool's pod log,
|
|
537
|
+
// read after every tool result.
|
|
538
|
+
const pod = opts.browserPod ?? null;
|
|
539
|
+
const ledger = new Map<string, CallKind>();
|
|
540
|
+
const podState: BrowserPodOutcome = { reconnects: 0, reconnected: 0 };
|
|
541
|
+
let podOffset = 0;
|
|
542
|
+
const readPod = (callId: string) => {
|
|
543
|
+
if (!pod) return;
|
|
544
|
+
let text: string;
|
|
545
|
+
try {
|
|
546
|
+
text = fs.readFileSync(pod.events, "utf8");
|
|
547
|
+
} catch {
|
|
548
|
+
return; // nothing written yet
|
|
549
|
+
}
|
|
550
|
+
const { events, next } = readPodEvents(text, podOffset);
|
|
551
|
+
podOffset = next;
|
|
552
|
+
let closedAgain = false;
|
|
553
|
+
for (const [i, e] of events.entries()) {
|
|
554
|
+
if (e.kind === "reconnect") {
|
|
555
|
+
podState.reconnects += 1;
|
|
556
|
+
// A reconnect that reached the pod, and then the call dropped again
|
|
557
|
+
// at once (the web tool's next line is "lost"), did not get
|
|
558
|
+
// through: the pod answered while it shut down. Live, 2026-10-01,
|
|
559
|
+
// "reconnected (try 1 of 3)" was followed by "lost after 1
|
|
560
|
+
// reconnect try" and the step read "1 got through".
|
|
561
|
+
closedAgain = !!e.ok && events[i + 1]?.kind === "lost";
|
|
562
|
+
if (closedAgain) podState.closedAgain = (podState.closedAgain ?? 0) + 1;
|
|
563
|
+
else if (e.ok) podState.reconnected += 1;
|
|
564
|
+
// `at` is when the try happened: these lines reach the run when the
|
|
565
|
+
// call returns, so the run's own stamps on them are all the same.
|
|
566
|
+
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" : ""}`);
|
|
567
|
+
} else if (!podState.lost) {
|
|
568
|
+
// A call that never reached the pod changed nothing, whatever its
|
|
569
|
+
// arguments asked for.
|
|
570
|
+
if (e.ran === false) ledger.delete(callId);
|
|
571
|
+
podState.lost = { cause: e.kind, detail: (e.kind === "needs-full" ? e.why : e.error) ?? "connection closed" };
|
|
572
|
+
emit("info", e.kind === "needs-full"
|
|
573
|
+
? `browser pod: this call needs a browser in the step, which the slim image has not (${podState.lost.detail}) — stopping the step`
|
|
574
|
+
: closedAgain
|
|
575
|
+
? `browser pod: lost — the call dropped again after the reconnect (${podState.lost.detail}${podAt(e.at)}) — stopping the step`
|
|
576
|
+
: `browser pod: lost after ${podState.reconnects} reconnect tr${podState.reconnects === 1 ? "y" : "ies"} (${podState.lost.detail}${podAt(e.at)}) — stopping the step`);
|
|
577
|
+
endWith("pod-lost");
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
};
|
|
323
581
|
|
|
324
582
|
try {
|
|
325
583
|
for await (const message of q as AsyncIterable<SDKMessage>) {
|
|
326
584
|
if (ended) break;
|
|
327
585
|
if (message.type === "assistant") {
|
|
328
|
-
const
|
|
586
|
+
const m = message.message as unknown as { id?: string; usage?: Record<string, number | undefined> };
|
|
587
|
+
const u = m.usage;
|
|
588
|
+
if (u) turns.set(m.id ?? `turn-${anonymousTurns++}`, { u, price: priceOf(message) });
|
|
329
589
|
if (ceiling && u) {
|
|
330
|
-
spentUsd
|
|
590
|
+
spentUsd = turnsTotal();
|
|
331
591
|
if (spentUsd >= ceiling) {
|
|
332
592
|
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"})`);
|
|
333
593
|
status = "failed";
|
|
334
594
|
break;
|
|
335
595
|
}
|
|
336
596
|
}
|
|
597
|
+
const via = subagentOf(message);
|
|
337
598
|
for (const block of message.message.content) {
|
|
338
599
|
if (block.type === "text" && block.text.trim()) {
|
|
600
|
+
// A sub-agent's own words are its business; the parent's reply is
|
|
601
|
+
// the step's result. (The SDK forwards only tool blocks by default.)
|
|
602
|
+
if (via) continue;
|
|
339
603
|
texts.push(block.text);
|
|
340
604
|
emit("text", block.text);
|
|
341
605
|
} else if (block.type === "tool_use") {
|
|
342
|
-
|
|
343
|
-
|
|
606
|
+
if (!via && (DELEGATE_TOOLS as readonly string[]).includes(block.name)) {
|
|
607
|
+
const input = block.input as { subagent_type?: unknown; description?: unknown; prompt?: unknown } | undefined;
|
|
608
|
+
const to = String(input?.subagent_type ?? "subagent");
|
|
609
|
+
delegations.set(block.id, to);
|
|
610
|
+
// One line a person can read: who got the job, and what it was.
|
|
611
|
+
const job = String(input?.description ?? input?.prompt ?? "").replace(/\s+/g, " ").trim().slice(0, 160);
|
|
612
|
+
emit("info", `delegating to ${to}${job ? `: ${job}` : ""}`, { subagent: to });
|
|
613
|
+
}
|
|
614
|
+
openCalls.set(block.id, { name: block.name, at: Date.now(), ...(via ? { subagent: via } : {}) });
|
|
615
|
+
if (pod) ledger.set(block.id, classifyCall(block.name, block.input as Record<string, unknown> | undefined, pod));
|
|
616
|
+
emit("tool", block.name, { call: block.id, ...(via ? { subagent: via } : {}) });
|
|
344
617
|
}
|
|
345
618
|
}
|
|
346
619
|
} else if (message.type === "user") {
|
|
@@ -357,7 +630,9 @@ export async function executeStep(
|
|
|
357
630
|
call: block.tool_use_id,
|
|
358
631
|
ms: Date.now() - open.at,
|
|
359
632
|
...(block.is_error ? { err: true } : {}),
|
|
633
|
+
...(open.subagent ? { subagent: open.subagent } : {}),
|
|
360
634
|
});
|
|
635
|
+
readPod(block.tool_use_id);
|
|
361
636
|
}
|
|
362
637
|
}
|
|
363
638
|
} else if (message.type === "result") {
|
|
@@ -366,6 +641,7 @@ export async function executeStep(
|
|
|
366
641
|
emit("error", `stopped after ${opts.maxTurns ?? "its"} turns (max_turns: in the flow file) — the step did not finish`);
|
|
367
642
|
}
|
|
368
643
|
costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
|
|
644
|
+
stopReason = "stop_reason" in message && typeof message.stop_reason === "string" ? message.stop_reason : null;
|
|
369
645
|
if ("usage" in message && message.usage) {
|
|
370
646
|
const u = message.usage as unknown as Record<string, number | undefined>;
|
|
371
647
|
usage = {
|
|
@@ -387,12 +663,42 @@ export async function executeStep(
|
|
|
387
663
|
} finally {
|
|
388
664
|
for (const t of timers) clearTimeout(t);
|
|
389
665
|
}
|
|
666
|
+
// What the step used of each limit, on the trace whenever any is set —
|
|
667
|
+
// so "it stopped searching at 40" is read off the run, not guessed.
|
|
668
|
+
const limitLine = counter.summary();
|
|
669
|
+
if (limitLine) emit("info", limitLine);
|
|
670
|
+
// No result message — a timeout, a stop, a budget stop, a stream that
|
|
671
|
+
// died — means no total from the SDK. The turns it did take were billed by
|
|
672
|
+
// the provider all the same, so the step's cost is their sum.
|
|
673
|
+
if (costUsd === null && turns.size) {
|
|
674
|
+
let input = 0;
|
|
675
|
+
let output = 0;
|
|
676
|
+
let priced = 0;
|
|
677
|
+
for (const { u: t, price } of turns.values()) {
|
|
678
|
+
input += (t.input_tokens ?? 0) + (t.cache_creation_input_tokens ?? 0) + (t.cache_read_input_tokens ?? 0);
|
|
679
|
+
output += t.output_tokens ?? 0;
|
|
680
|
+
priced += priceTurn(t, price);
|
|
681
|
+
}
|
|
682
|
+
costUsd = priced;
|
|
683
|
+
usage ??= { inputTokens: input, outputTokens: output };
|
|
684
|
+
emit("info", `cost from ${turns.size} model turn${turns.size === 1 ? "" : "s"}: $${priced.toFixed(4)} — the step ended before the model's closing total`);
|
|
685
|
+
}
|
|
390
686
|
if (ended === "timeout") {
|
|
391
687
|
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`);
|
|
392
688
|
status = "failed";
|
|
393
689
|
} else if (ended === "stopped") {
|
|
394
690
|
emit("error", "stopped by a person mid-step");
|
|
395
691
|
status = "failed";
|
|
692
|
+
} else if (ended === "pod-lost") {
|
|
693
|
+
// The runner says what happens next — a re-run on the full image, or a
|
|
694
|
+
// failure naming what was written. Not an error of the step's own.
|
|
695
|
+
status = "failed";
|
|
696
|
+
}
|
|
697
|
+
if (pod) {
|
|
698
|
+
readPod("");
|
|
699
|
+
if (podState.lost) {
|
|
700
|
+
podState.writes = [...ledger.values()].flatMap((k) => (k.write ? [k.what] : []));
|
|
701
|
+
}
|
|
396
702
|
}
|
|
397
703
|
// The SDK ends a healthy run with a `result` message. A stream that just
|
|
398
704
|
// stops — subprocess OOM-killed, crashed, or torn down — used to fall
|
|
@@ -427,30 +733,77 @@ export async function executeStep(
|
|
|
427
733
|
// failure as no value, and the message names the field.
|
|
428
734
|
const errors = opts.schema !== undefined && looksLikeSchema(opts.schema) ? validateSchema(data, opts.schema) : [];
|
|
429
735
|
if (errors.length) {
|
|
430
|
-
emit("error", `schema: the value does not fit — ${describeSchemaErrors(errors)}
|
|
736
|
+
emit("error", `schema: the value does not fit — ${describeSchemaErrors(errors)}`, CHECK);
|
|
431
737
|
status = "failed";
|
|
432
738
|
}
|
|
433
739
|
} else {
|
|
434
|
-
emit("error", `output: json — ${extracted.reason}
|
|
740
|
+
emit("error", `output: json — ${extracted.reason}`, CHECK);
|
|
435
741
|
status = "failed";
|
|
436
742
|
}
|
|
437
743
|
}
|
|
438
744
|
|
|
439
745
|
// "Done" should mean a check passed, not that the model stopped talking.
|
|
746
|
+
// The loop's clocks are cleared by now, so the check gets its own: what is
|
|
747
|
+
// left of the step's `timeout:` (never less than VERIFY_FLOOR_MS of it),
|
|
748
|
+
// and a stop read on the same clock the loop used. No `timeout:`, no clock:
|
|
749
|
+
// it is the only one there is, here as in the loop. A `verify:` that hangs
|
|
750
|
+
// — a build waiting on a lock, a judge that never answers — used to hold
|
|
751
|
+
// the step, and the run, with nothing to end it.
|
|
440
752
|
if (status === "completed" && opts.verify) {
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
753
|
+
const verifyMs = verifyBudgetMs(opts.timeoutSec, Date.now() - startedAt);
|
|
754
|
+
const halt = new AbortController();
|
|
755
|
+
let cut: "timeout" | "stopped" | null = null;
|
|
756
|
+
const clocks: NodeJS.Timeout[] = [];
|
|
757
|
+
if (verifyMs !== null) {
|
|
758
|
+
clocks.push(setTimeout(() => {
|
|
759
|
+
cut ??= "timeout";
|
|
760
|
+
halt.abort();
|
|
761
|
+
}, verifyMs));
|
|
762
|
+
}
|
|
763
|
+
if (opts.stopRequested) {
|
|
764
|
+
clocks.push(setInterval(() => {
|
|
765
|
+
try {
|
|
766
|
+
if (opts.stopRequested!()) {
|
|
767
|
+
cut ??= "stopped";
|
|
768
|
+
halt.abort();
|
|
769
|
+
}
|
|
770
|
+
} catch {
|
|
771
|
+
// an unreadable record is not a stop
|
|
772
|
+
}
|
|
773
|
+
}, STOP_POLL_MS));
|
|
774
|
+
}
|
|
775
|
+
let verdict: VerifyVerdict;
|
|
776
|
+
try {
|
|
777
|
+
verdict = await checkVerify(agentDir, opts.verify, {
|
|
778
|
+
env: opts.verifyEnv ?? {},
|
|
779
|
+
result,
|
|
780
|
+
conclusion,
|
|
781
|
+
data,
|
|
782
|
+
modelEnv: opts.env,
|
|
783
|
+
signal: halt.signal,
|
|
784
|
+
});
|
|
785
|
+
} finally {
|
|
786
|
+
for (const t of clocks) clearTimeout(t);
|
|
787
|
+
}
|
|
788
|
+
if (cut === "timeout") {
|
|
789
|
+
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);
|
|
790
|
+
status = "failed";
|
|
791
|
+
} else if (cut === "stopped") {
|
|
792
|
+
emit("error", `verify \`${opts.verify}\` → stopped by a person mid-check`, CHECK);
|
|
793
|
+
status = "failed";
|
|
794
|
+
} else {
|
|
795
|
+
emit(verdict.ok ? "info" : "error", `verify \`${opts.verify}\` → ${verdict.headline}`, verdict.ok ? undefined : CHECK);
|
|
796
|
+
if (verdict.detail.trim()) emit(verdict.ok ? "info" : "error", verdict.detail.slice(0, 1000), verdict.ok ? undefined : CHECK);
|
|
797
|
+
if (!verdict.ok) status = "failed";
|
|
798
|
+
}
|
|
451
799
|
}
|
|
452
800
|
|
|
453
|
-
return {
|
|
801
|
+
return {
|
|
802
|
+
status, result, conclusion, ...(opts.output ? { data } : {}), costUsd, usage,
|
|
803
|
+
...(mixedModels() ? { turnsCostUsd: turnsTotal() } : {}),
|
|
804
|
+
stopReason,
|
|
805
|
+
...(pod ? { browserPod: podState } : {}),
|
|
806
|
+
};
|
|
454
807
|
}
|
|
455
808
|
|
|
456
809
|
// ------------------------------------------------------------ output: json
|
|
@@ -531,12 +884,27 @@ export async function checkVerify(
|
|
|
531
884
|
/** The step's own model environment, for `judge:` — it grades on the
|
|
532
885
|
* fast tier through the same credential the step rode. */
|
|
533
886
|
modelEnv?: Record<string, string | undefined>;
|
|
887
|
+
/** Ends a shell check or a judge mid-way: the step's clock ran out, or a
|
|
888
|
+
* person asked the run to stop. */
|
|
889
|
+
signal?: AbortSignal;
|
|
534
890
|
},
|
|
535
891
|
): Promise<VerifyVerdict> {
|
|
536
892
|
const m = verify.trim().match(VERIFY_ASSERTION);
|
|
537
893
|
if (!m) {
|
|
538
|
-
|
|
539
|
-
|
|
894
|
+
// The step's final turn, as a file named in FOLDRUN_REPLY_FILE — so one
|
|
895
|
+
// command can check the reply and the artefact together
|
|
896
|
+
// (`grep -q '^READY' "$FOLDRUN_REPLY_FILE" && test -s out.json`). A step
|
|
897
|
+
// has one `verify:`, and a shell check that could not see the reply
|
|
898
|
+
// made authors choose between the two.
|
|
899
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-reply-"));
|
|
900
|
+
const replyFile = path.join(dir, "reply.md");
|
|
901
|
+
fs.writeFileSync(replyFile, ctx.conclusion ?? ctx.result ?? "");
|
|
902
|
+
try {
|
|
903
|
+
const { code, out } = await runVerify(agentDir, verify, { ...ctx.env, FOLDRUN_REPLY_FILE: replyFile }, ctx.data, ctx.signal);
|
|
904
|
+
return { ok: code === 0, headline: `exit ${code ?? "error"}`, detail: out };
|
|
905
|
+
} finally {
|
|
906
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
907
|
+
}
|
|
540
908
|
}
|
|
541
909
|
const [, kind, rawValue] = m;
|
|
542
910
|
const value = rawValue.trim().replace(/^["']|["']$/g, "");
|
|
@@ -560,15 +928,20 @@ export async function checkVerify(
|
|
|
560
928
|
}
|
|
561
929
|
}
|
|
562
930
|
case "file": {
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
931
|
+
// Agent-relative like every path in a flow, or `workspace/…` from the
|
|
932
|
+
// workspace root; confined to the workspace. It was confined to the
|
|
933
|
+
// agent's own folder, so the one place a step's deliverable belongs —
|
|
934
|
+
// `workspace/storage/x`, or `../../storage/x` — was always "escapes".
|
|
935
|
+
const workspaceRoot = path.resolve(agentDir, "..", "..");
|
|
936
|
+
const target = resolveAgentPath(workspaceRoot, agentDir, value);
|
|
937
|
+
if (!isWithin(workspaceRoot, target) || target === workspaceRoot) {
|
|
938
|
+
return { ok: false, headline: "path escapes the workspace", detail: "" };
|
|
566
939
|
}
|
|
567
940
|
const ok = fs.existsSync(target) && fs.statSync(target).size > 0;
|
|
568
941
|
return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
|
|
569
942
|
}
|
|
570
943
|
case "judge": {
|
|
571
|
-
const verdict = await judgeReply(value, output, ctx.modelEnv ?? {});
|
|
944
|
+
const verdict = await judgeReply(value, output, ctx.modelEnv ?? {}, ctx.signal);
|
|
572
945
|
const ok = /^\s*PASS\b/i.test(verdict);
|
|
573
946
|
return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
|
|
574
947
|
}
|
|
@@ -585,8 +958,12 @@ async function judgeReply(
|
|
|
585
958
|
rubric: string,
|
|
586
959
|
output: string,
|
|
587
960
|
env: Record<string, string | undefined>,
|
|
961
|
+
signal?: AbortSignal,
|
|
588
962
|
): Promise<string> {
|
|
589
963
|
const texts: string[] = [];
|
|
964
|
+
const abortController = new AbortController();
|
|
965
|
+
if (signal?.aborted) abortController.abort();
|
|
966
|
+
signal?.addEventListener("abort", () => abortController.abort(), { once: true });
|
|
590
967
|
try {
|
|
591
968
|
const q = query({
|
|
592
969
|
prompt:
|
|
@@ -598,8 +975,10 @@ async function judgeReply(
|
|
|
598
975
|
systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
|
|
599
976
|
tools: [],
|
|
600
977
|
settingSources: [],
|
|
601
|
-
env,
|
|
978
|
+
env: sdkEnv(env),
|
|
979
|
+
strictMcpConfig: true,
|
|
602
980
|
maxTurns: 1,
|
|
981
|
+
abortController,
|
|
603
982
|
},
|
|
604
983
|
});
|
|
605
984
|
for await (const message of q) {
|
|
@@ -622,19 +1001,35 @@ function runVerify(
|
|
|
622
1001
|
command: string,
|
|
623
1002
|
env: Record<string, string>,
|
|
624
1003
|
data?: unknown,
|
|
1004
|
+
signal?: AbortSignal,
|
|
625
1005
|
): Promise<{ code: number | null; out: string }> {
|
|
626
1006
|
return new Promise((resolve) => {
|
|
627
|
-
//
|
|
628
|
-
//
|
|
1007
|
+
// The clock is the caller's (executeStep): what is left of the step's
|
|
1008
|
+
// `timeout:`, ended through `signal`, which a stop also fires.
|
|
629
1009
|
// The allowlisted host base plus what the caller passed: the step's
|
|
630
1010
|
// secrets and identifiers on the in-process path (runner.ts), the
|
|
631
1011
|
// container's own environment on the isolated one (run-container.ts).
|
|
632
1012
|
// Never process.env whole — see host-env.ts.
|
|
633
1013
|
const child = spawn("bash", ["-lc", command], {
|
|
634
1014
|
cwd: agentDir,
|
|
635
|
-
|
|
1015
|
+
// workspaceLinkEnv: `node workspace/tools/x/check.mjs` must run its
|
|
1016
|
+
// check, not pass for having skipped it (confine.ts says how).
|
|
1017
|
+
env: workspaceLinkEnv({ ...hostSafeEnv(), ...env }, agentDir),
|
|
636
1018
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1019
|
+
// Its own process group, so an abort ends what the shell started
|
|
1020
|
+
// (`npm run build` and its children) and not only bash.
|
|
1021
|
+
detached: true,
|
|
637
1022
|
});
|
|
1023
|
+
const kill = () => {
|
|
1024
|
+
try {
|
|
1025
|
+
process.kill(-child.pid!, "SIGKILL");
|
|
1026
|
+
} catch {
|
|
1027
|
+
child.kill("SIGKILL");
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
1030
|
+
if (signal?.aborted) kill();
|
|
1031
|
+
else signal?.addEventListener("abort", kill, { once: true });
|
|
1032
|
+
child.on("close", () => signal?.removeEventListener("abort", kill));
|
|
638
1033
|
// An `output: json` step's data arrives on stdin, so a check can be
|
|
639
1034
|
// `jq -e '.total > 0'` — arithmetic in a real tool, reading the value
|
|
640
1035
|
// the step actually returned rather than re-parsing its prose.
|
|
@@ -651,3 +1046,17 @@ function runVerify(
|
|
|
651
1046
|
child.on("close", (code) => resolve({ code, out }));
|
|
652
1047
|
});
|
|
653
1048
|
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* The environment an Agent SDK session starts with: the step's, minus the
|
|
1052
|
+
* connectors of whoever is signed in to claude.ai on this machine. Claude
|
|
1053
|
+
* Code loads an account's claude.ai MCP servers (Docs, Drive, …) unless told
|
|
1054
|
+
* not to, and `settingSources: []` does not reach them — they come with the
|
|
1055
|
+
* login, not from a settings file. A local `foldrun run` handed every agent
|
|
1056
|
+
* the person's own connectors: the hello template's agents reported that
|
|
1057
|
+
* "the only tools I have are the Claude Docs ones". `strictMcpConfig` keeps
|
|
1058
|
+
* the rest of the on-disk MCP configuration out; this keeps the account's.
|
|
1059
|
+
*/
|
|
1060
|
+
export function sdkEnv(base: Record<string, string | undefined> | undefined, extra: Record<string, string> = {}): Record<string, string | undefined> {
|
|
1061
|
+
return { ...(base ?? process.env), ...extra, ENABLE_CLAUDEAI_MCP_SERVERS: "false" };
|
|
1062
|
+
}
|