@foldrun/core 0.7.0 → 1.1.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 +14 -1
- package/dist/src/completions.js +1 -0
- package/dist/src/deploy.js +5 -0
- package/dist/src/egress.d.ts +21 -0
- package/dist/src/egress.js +8 -0
- package/dist/src/hooks.d.ts +114 -0
- package/dist/src/hooks.js +370 -0
- package/dist/src/kinds.d.ts +1 -1
- package/dist/src/kinds.js +26 -0
- package/dist/src/library.d.ts +1 -1
- package/dist/src/library.js +17 -1
- package/dist/src/mail.js +1 -1
- package/dist/src/network.d.ts +120 -0
- package/dist/src/network.js +360 -0
- package/dist/src/notify.d.ts +8 -0
- package/dist/src/notify.js +10 -1
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +21 -1
- package/dist/src/run-container.d.ts +13 -0
- package/dist/src/run-container.js +8 -1
- package/dist/src/runner.d.ts +1 -0
- package/dist/src/runner.js +144 -16
- package/dist/src/secrets.d.ts +20 -0
- package/dist/src/secrets.js +65 -1
- package/dist/src/starter.js +9 -1
- package/dist/src/step-exec.d.ts +10 -0
- package/dist/src/step-exec.js +113 -2
- package/dist/src/store.d.ts +23 -1
- package/dist/src/store.js +33 -1
- package/dist/src/tool-loop.d.ts +45 -0
- package/dist/src/tool-loop.js +133 -0
- package/package.json +19 -1
- package/src/completions.ts +1 -0
- package/src/deploy.ts +4 -0
- package/src/egress.ts +26 -0
- package/src/hooks.ts +406 -0
- package/src/kinds.ts +28 -0
- package/src/library.ts +18 -2
- package/src/mail.ts +1 -1
- package/src/network.ts +390 -0
- package/src/notify.ts +11 -1
- package/src/providers.ts +27 -1
- package/src/run-container.ts +16 -2
- package/src/runner.ts +145 -14
- package/src/secrets.ts +69 -1
- package/src/starter.ts +9 -1
- package/src/step-exec.ts +115 -3
- package/src/store.ts +39 -1
- package/src/tool-loop.ts +176 -0
package/src/runner.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// fail without failing the flow. A direct agent run is a one-step ad-hoc
|
|
4
4
|
// flow, so there is a single execution path.
|
|
5
5
|
|
|
6
|
+
import { hookNames } from "./hooks.ts";
|
|
6
7
|
import fs from "node:fs";
|
|
7
8
|
import os from "node:os";
|
|
8
9
|
import path from "node:path";
|
|
@@ -80,7 +81,8 @@ import {
|
|
|
80
81
|
runCost,
|
|
81
82
|
} from "./store.ts";
|
|
82
83
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, type Catalog } from "./catalog.ts";
|
|
83
|
-
import { resolveSecrets, getSecret, materializeSecrets } from "./secrets.ts";
|
|
84
|
+
import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.ts";
|
|
85
|
+
import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.ts";
|
|
84
86
|
import { materializeFileSecrets, cleanupFileSecrets } from "./secret-files.ts";
|
|
85
87
|
import { buildApiTools, secretsUsedByApi } from "./api-tools.ts";
|
|
86
88
|
import { attachOperations, prefetchOpenApi } from "./openapi.ts";
|
|
@@ -108,6 +110,30 @@ export { localDate } from "./clock.ts";
|
|
|
108
110
|
// An MCP tool definition becomes an SDK server config. ${SECRET} placeholders
|
|
109
111
|
// in env and headers resolve server-side, so a credential reaches the server
|
|
110
112
|
// process without ever entering the model's context.
|
|
113
|
+
/** Remote MCP servers with each bound secret's value put back as its
|
|
114
|
+
* `${NAME}`, granted to that server's host — the sandbox then holds the
|
|
115
|
+
* name and the proxy fills it, as for an http tool. */
|
|
116
|
+
function unbindMcpHeaders(
|
|
117
|
+
servers: Record<string, McpServerConfig>,
|
|
118
|
+
bound: string[],
|
|
119
|
+
values: Record<string, string>,
|
|
120
|
+
grant: EgressGrant,
|
|
121
|
+
): Record<string, McpServerConfig> {
|
|
122
|
+
const known = Object.fromEntries(bound.filter((n) => n in values).map((n) => [n, values[n]]));
|
|
123
|
+
const out: Record<string, McpServerConfig> = {};
|
|
124
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
125
|
+
if (server.type !== "http" || !server.headers) {
|
|
126
|
+
out[name] = server;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const host = hostOf(server.url);
|
|
130
|
+
out[name] = host
|
|
131
|
+
? { ...server, headers: Object.fromEntries(Object.entries(server.headers).map(([k, v]) => [k, unsubstitute(v, known, host, grant)])) }
|
|
132
|
+
: server;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
function mcpConfig(spec: McpSpec, tenant: string, workspace: string): McpServerConfig {
|
|
112
138
|
const fill = (values: Record<string, string>) => {
|
|
113
139
|
const out: Record<string, string> = {};
|
|
@@ -1341,6 +1367,7 @@ export function agentContext(
|
|
|
1341
1367
|
...(browseSettings.webgpu ? { FOLDRUN_BROWSER_WEBGPU: "1" } : {}),
|
|
1342
1368
|
...(browseSettings.ignore_https_errors ? { FOLDRUN_BROWSER_IGNORE_HTTPS_ERRORS: "1" } : {}),
|
|
1343
1369
|
...(browseSettings.state_key ? { FOLDRUN_BROWSER_STATE_KEY: browseSettings.state_key } : {}),
|
|
1370
|
+
...(browseSettings.state ? { FOLDRUN_BROWSER_STATE: browseSettings.state } : {}),
|
|
1344
1371
|
...(browseSettings.video ? { FOLDRUN_BROWSER_VIDEO: "1" } : {}),
|
|
1345
1372
|
...(browseSettings.live_view === false ? { FOLDRUN_BROWSER_LIVE_VIEW: "0" } : {}),
|
|
1346
1373
|
};
|
|
@@ -2355,6 +2382,17 @@ async function runStep(
|
|
|
2355
2382
|
const limitOwners = toolOwners(apiTools.owners, scriptSpecs);
|
|
2356
2383
|
const limitOpts = Object.keys(stepLimits).length ? { limits: stepLimits, toolOwners: limitOwners } : {};
|
|
2357
2384
|
if (limitOpts.limits) push("info", `limits: ${Object.entries(stepLimits).map(([k, n]) => `${k} ${n}`).join(", ")} — per step; a call past one is refused`);
|
|
2385
|
+
// `hooks:` — the account's, the workspace's, then the agent's own, each
|
|
2386
|
+
// once; resolved and run inside the step (hooks.ts, step-exec.ts).
|
|
2387
|
+
const stepHookNames = [...new Set([
|
|
2388
|
+
...hookNames(readAgentsMd(accountDir(tenant))?.data?.hooks),
|
|
2389
|
+
...hookNames(readAgentsMd(workspaceRootOf(agentDir))?.data?.hooks),
|
|
2390
|
+
...hookNames(front.hooks),
|
|
2391
|
+
])];
|
|
2392
|
+
const hookOpts = stepHookNames.length
|
|
2393
|
+
? { hookNames: stepHookNames, hookBase: { run_id: runId ?? null, step: stepIndex ?? null, agent: step.agent, workspace: path.basename(workspaceRootOf(agentDir)) } }
|
|
2394
|
+
: {};
|
|
2395
|
+
if (stepHookNames.length) push("info", `hooks: ${stepHookNames.join(", ")}`);
|
|
2358
2396
|
|
|
2359
2397
|
const isolation = process.env.FOLDRUN_RUN_ISOLATION;
|
|
2360
2398
|
if (isolatedRun()) {
|
|
@@ -2407,7 +2445,45 @@ async function runStep(
|
|
|
2407
2445
|
}
|
|
2408
2446
|
}
|
|
2409
2447
|
}
|
|
2448
|
+
// `network:` — which sites the sandbox may reach (network.ts). Resolved
|
|
2449
|
+
// before the lease, because the proxy holds the list per request. A
|
|
2450
|
+
// step that may fetch or browse the open web defaults to open; every
|
|
2451
|
+
// other to the trusted list plus what it declares.
|
|
2452
|
+
const workspaceName = path.basename(workspaceRootOf(agentDir));
|
|
2453
|
+
const readsWeb = scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.some((a) => a === "fetch" || a === "browse"));
|
|
2454
|
+
// Secrets bound to sites in the vault: filled by the proxy for those
|
|
2455
|
+
// sites alone, never put in the sandbox (egress.ts).
|
|
2456
|
+
const boundSites = secretSites(tenant, Object.keys(liveSecrets), workspaceName);
|
|
2457
|
+
const network = resolveNetwork({
|
|
2458
|
+
account: readAgentsMd(accountDir(tenant))?.data?.network,
|
|
2459
|
+
workspace: readAgentsMd(workspaceRootOf(agentDir))?.data?.network,
|
|
2460
|
+
agent: front.network,
|
|
2461
|
+
readsWeb,
|
|
2462
|
+
implied: [
|
|
2463
|
+
...apiSpecs.map((a) => hostOf(a.base)).filter((h): h is string => !!h),
|
|
2464
|
+
...Object.values(mcpServers).map((m) => ("url" in m && typeof m.url === "string" ? hostOf(m.url) : null)).filter((h): h is string => !!h),
|
|
2465
|
+
...Object.values(grant.secrets).flatMap((g) => g.hosts),
|
|
2466
|
+
...Object.values(boundSites).flat(),
|
|
2467
|
+
],
|
|
2468
|
+
});
|
|
2469
|
+
grant.network = { level: network.level, hosts: network.hosts };
|
|
2410
2470
|
const lease = await platform.egress.lease({ tenant, runId: runId ?? "adhoc", grant, test: testRun, ...(stepIndex !== undefined && stepIndex >= 0 ? { step: stepIndex } : {}) });
|
|
2471
|
+
// The forward door: every program in the sandbox sends through the
|
|
2472
|
+
// proxy, which holds the site list and fills bound secrets. A proxy
|
|
2473
|
+
// without the door (an older platform) leaves programs going direct.
|
|
2474
|
+
// A step that may browse gets no proxy variables: the browser in it
|
|
2475
|
+
// reads HTTPS_PROXY too, and Chromium cannot send credentials written
|
|
2476
|
+
// into a proxy URL — every page failed ERR_INVALID_AUTH_CREDENTIALS
|
|
2477
|
+
// (14 Oct 2026, the reel posters). Such a step is open by default,
|
|
2478
|
+
// and its bound secrets materialise as they did before the door.
|
|
2479
|
+
const browses = scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse"));
|
|
2480
|
+
const forward = lease?.proxy && !browses ? lease : null;
|
|
2481
|
+
push("info", networkLine(network, forward ? forward.enforce === true : null));
|
|
2482
|
+
const bound = forward ? Object.keys(boundSites).filter((name) => name in liveSecrets) : [];
|
|
2483
|
+
for (const name of bound) {
|
|
2484
|
+
for (const site of boundSites[name]) addGrant(grant, name, liveSecrets[name], site);
|
|
2485
|
+
(grant.healthKeys ??= {})[name] = healthKey(name, secretScopes[name] === "workspace" ? "workspace" : "account", workspaceName);
|
|
2486
|
+
}
|
|
2411
2487
|
// Which secrets must still be real inside the sandbox: a script tool
|
|
2412
2488
|
// reads them from its environment, and unless it says `secrets:
|
|
2413
2489
|
// proxied` it needs the value, not the name. bash is a script by
|
|
@@ -2419,22 +2495,27 @@ async function runStep(
|
|
|
2419
2495
|
...(scriptSpecs.some((sc) => sc.name === "web") && (!web.actions || web.actions.includes("browse")) ? ["web (browse)"] : []),
|
|
2420
2496
|
...(allowed.includes("Bash") ? ["bash"] : []),
|
|
2421
2497
|
];
|
|
2422
|
-
const materialise = !lease || materialisers.length > 0;
|
|
2423
2498
|
const declared = Object.keys(liveSecrets);
|
|
2499
|
+
// A bound secret is never materialised, whatever the step grants.
|
|
2500
|
+
const unbound = declared.filter((name) => !bound.includes(name));
|
|
2501
|
+
const materialise = (!lease || materialisers.length > 0) && unbound.length > 0;
|
|
2424
2502
|
if (lease) {
|
|
2503
|
+
const boundNote = bound.length ? `${bound.join(", ")} bound to sites — filled by the egress proxy, never in the sandbox` : "";
|
|
2425
2504
|
push(
|
|
2426
2505
|
"info",
|
|
2427
2506
|
declared.length
|
|
2428
2507
|
? materialise
|
|
2429
|
-
? `secrets: ${
|
|
2430
|
-
: `secrets: ${declared.join(", ")} proxied — nothing in the sandbox but the names`
|
|
2508
|
+
? `secrets: ${unbound.join(", ")} materialised in the sandbox for ${materialisers.join(", ")}${boundNote ? `; ${boundNote}` : ""}; api tools and the model key go through the egress proxy`
|
|
2509
|
+
: `secrets: ${declared.join(", ")} proxied — nothing in the sandbox but the names${boundNote ? ` (${boundNote})` : ""}`
|
|
2431
2510
|
: "secrets: none declared; the model key goes through the egress proxy",
|
|
2432
2511
|
);
|
|
2433
2512
|
}
|
|
2434
2513
|
// Sandbox env for the declared secrets: the value, or its own name as
|
|
2435
2514
|
// a placeholder that the proxy fills on the way out.
|
|
2436
2515
|
const placeholders = Object.fromEntries(declared.map((name) => [name, `\${${name}}`]));
|
|
2437
|
-
let sandboxSecrets: Record<string, string> = materialise
|
|
2516
|
+
let sandboxSecrets: Record<string, string> = materialise
|
|
2517
|
+
? { ...liveSecrets, ...Object.fromEntries(bound.map((name) => [name, placeholders[name]])) }
|
|
2518
|
+
: placeholders;
|
|
2438
2519
|
// A test run hands the sandbox no send-capable value. The env var is
|
|
2439
2520
|
// still there, reading TEST_MODE_WITHHELD, so a script that never
|
|
2440
2521
|
// checked FOLDRUN_TEST_MODE fails at the provider's door with an auth
|
|
@@ -2442,11 +2523,13 @@ async function runStep(
|
|
|
2442
2523
|
// has a declared sender (everything withheld) or a declared reader
|
|
2443
2524
|
// (nothing withheld); see test-mode.ts for the order.
|
|
2444
2525
|
if (testRun && materialise) {
|
|
2445
|
-
const held = withholdSecrets(liveSecrets, {
|
|
2526
|
+
const held = withholdSecrets(Object.fromEntries(unbound.map((name) => [name, liveSecrets[name]])), {
|
|
2446
2527
|
outward: scriptSpecs.some((sc) => sc.outward),
|
|
2447
2528
|
allow: scriptSpecs.some((sc) => sc.testMode === "allow"),
|
|
2448
2529
|
});
|
|
2449
|
-
|
|
2530
|
+
// Bound secrets stay placeholders: the proxy, which holds the test
|
|
2531
|
+
// policy, is the only party that ever fills them.
|
|
2532
|
+
sandboxSecrets = { ...held.env, ...Object.fromEntries(bound.map((name) => [name, placeholders[name]])) };
|
|
2450
2533
|
for (const name of held.withheld) {
|
|
2451
2534
|
push("info", `test: ${name} withheld from the sandbox — TEST_MODE_WITHHELD`, { effect: { kind: "withheld", summary: `${name} withheld from the sandbox` } });
|
|
2452
2535
|
}
|
|
@@ -2504,7 +2587,9 @@ async function runStep(
|
|
|
2504
2587
|
systemPrompt,
|
|
2505
2588
|
allowed,
|
|
2506
2589
|
mcpNames: consultNames,
|
|
2507
|
-
|
|
2590
|
+
// A remote MCP server's headers had their secrets filled host-side;
|
|
2591
|
+
// a bound one goes back to its ${NAME} for the proxy to fill.
|
|
2592
|
+
mcpServers: bound.length ? unbindMcpHeaders(mcpServers, bound, liveSecrets, grant) : mcpServers,
|
|
2508
2593
|
apis: substitutedApis,
|
|
2509
2594
|
scripts: scriptSpecs,
|
|
2510
2595
|
// The merged spec, not `front.runtime` — see the plan's `runtime`
|
|
@@ -2521,6 +2606,7 @@ async function runStep(
|
|
|
2521
2606
|
schema: resolvedSchema ?? undefined,
|
|
2522
2607
|
maxTurns: step.maxTurns,
|
|
2523
2608
|
...limitOpts,
|
|
2609
|
+
...hookOpts,
|
|
2524
2610
|
translator: keyName === MODEL_KEY_NAME ? primaryTranslator : secondTranslator,
|
|
2525
2611
|
// Outward steps keep a browser of their own: an executor that
|
|
2526
2612
|
// could browse from the slim image through the account's pod
|
|
@@ -2547,6 +2633,14 @@ async function runStep(
|
|
|
2547
2633
|
// after and carries the agent's chosen endpoint + token.
|
|
2548
2634
|
...proxied(Object.keys(providerEnv).length === 0 ? modelEnv : providerEnv, keyName),
|
|
2549
2635
|
...(lease ? { [EGRESS_ENV]: lease.url } : {}),
|
|
2636
|
+
// Every program's HTTP(S) through the proxy's forward door, and
|
|
2637
|
+
// the CA it signs bound sites with, for the sandbox to trust.
|
|
2638
|
+
...(forward ? proxyEnv(forward.proxy!) : {}),
|
|
2639
|
+
// Only a step with a bound secret has a site the proxy
|
|
2640
|
+
// intercepts, so only that step's TLS clients are pointed at a
|
|
2641
|
+
// bundle with the egress CA in it; every other step's are as
|
|
2642
|
+
// they always were.
|
|
2643
|
+
...(forward?.ca && bound.length ? { [EGRESS_CA_ENV]: Buffer.from(forward.ca).toString("base64") } : {}),
|
|
2550
2644
|
...clockEnv,
|
|
2551
2645
|
// Who is running: the in-process path stamps scripts with these
|
|
2552
2646
|
// host-side, but a container rebuilds its tools from its own
|
|
@@ -2564,6 +2658,9 @@ async function runStep(
|
|
|
2564
2658
|
// Stamps the sandbox, so stopping this run can destroy it.
|
|
2565
2659
|
runId,
|
|
2566
2660
|
size,
|
|
2661
|
+
// The pod's network, for the executor's own wall (the k8s tier's
|
|
2662
|
+
// network policy): a filtered pod reaches the proxy and DNS only.
|
|
2663
|
+
network: { level: network.level, enforced: forward?.enforce === true },
|
|
2567
2664
|
// A test run's state/ and storage/ changes come out under the run.
|
|
2568
2665
|
divert: testRun && runId ? divertFor(runId) : undefined,
|
|
2569
2666
|
test: testRun,
|
|
@@ -2885,6 +2982,7 @@ async function runStep(
|
|
|
2885
2982
|
schema: resolvedSchema ?? undefined,
|
|
2886
2983
|
maxTurns: step.maxTurns,
|
|
2887
2984
|
...limitOpts,
|
|
2985
|
+
...hookOpts,
|
|
2888
2986
|
stopRequested,
|
|
2889
2987
|
// What the scripts saw, the verify sees: a flow can then check that
|
|
2890
2988
|
// the proof a step left names THIS run, not one that came before —
|
|
@@ -4543,9 +4641,12 @@ function driveRunInner(
|
|
|
4543
4641
|
// Not after a stop: the failure a stop leaves behind is not one a
|
|
4544
4642
|
// rescuer should take over. The merge in save() surfaces the flag.
|
|
4545
4643
|
save();
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4644
|
+
// One rescue of one failed step. Also used when an evaluator loop
|
|
4645
|
+
// runs out of cycles below, which used to fail the run without ever
|
|
4646
|
+
// reaching the step's on-fail (medium-desk's claim-trimmer was
|
|
4647
|
+
// written for exactly that case and could never run).
|
|
4648
|
+
const rescueStep = async (step: StepRecord): Promise<void> => {
|
|
4649
|
+
if (!step.onFail) return;
|
|
4549
4650
|
const rescuerDir = path.join(pDir, "agents", step.onFail);
|
|
4550
4651
|
if (!fs.existsSync(path.join(rescuerDir, "agent.md"))) {
|
|
4551
4652
|
step.events.push({
|
|
@@ -4553,7 +4654,7 @@ function driveRunInner(
|
|
|
4553
4654
|
type: "error",
|
|
4554
4655
|
text: `on-fail: "${step.onFail}" is not an agent in this workspace — no rescue`,
|
|
4555
4656
|
});
|
|
4556
|
-
|
|
4657
|
+
return;
|
|
4557
4658
|
}
|
|
4558
4659
|
step.events.push({
|
|
4559
4660
|
t: new Date().toISOString(),
|
|
@@ -4561,11 +4662,22 @@ function driveRunInner(
|
|
|
4561
4662
|
text: `failed — handing to [[${step.onFail}]] (on-fail)`,
|
|
4562
4663
|
});
|
|
4563
4664
|
const failure = (step.result ?? step.events.filter((e) => e.type === "error").map((e) => e.text).join("\n")).slice(0, 4000);
|
|
4665
|
+
// A gated step's approval travels with it: the rescuer acts inside
|
|
4666
|
+
// the same answer the failed agent had, and must be told what it
|
|
4667
|
+
// was. Fixers used to dig it out of the run record, and stop when
|
|
4668
|
+
// they could not (15 Oct 2026).
|
|
4669
|
+
const approved = step.approvedAt
|
|
4670
|
+
? `\nA person approved this step before it ran${step.approvalNote ? " — their answer is below" : ", with no note: a plain yes"}. Act within that approval and nothing wider.`
|
|
4671
|
+
: "";
|
|
4672
|
+
const owesMarker = step.until ? `\nThe step's loop ended without its marker: your reply must end with a line saying ${step.until}.` : "";
|
|
4564
4673
|
const rescue: StepRecord = {
|
|
4565
4674
|
agent: step.onFail,
|
|
4566
4675
|
instruction:
|
|
4567
4676
|
`${step.instruction}\n\n<rescue>\nYou are taking this step over: [[${step.agent}]] attempted it and failed. ` +
|
|
4568
|
-
`Its failure, for whatever it is worth:\n${failure}\n</rescue>`,
|
|
4677
|
+
`Its failure, for whatever it is worth:\n${failure}${approved}${owesMarker}\n</rescue>`,
|
|
4678
|
+
...(step.ask ? { ask: step.ask } : {}),
|
|
4679
|
+
...(step.approvedAt ? { approvedAt: step.approvedAt } : {}),
|
|
4680
|
+
...(step.approvalNote ? { approvalNote: step.approvalNote } : {}),
|
|
4569
4681
|
group: step.group,
|
|
4570
4682
|
optional: false,
|
|
4571
4683
|
model: step.model,
|
|
@@ -4611,6 +4723,11 @@ function driveRunInner(
|
|
|
4611
4723
|
});
|
|
4612
4724
|
}
|
|
4613
4725
|
save();
|
|
4726
|
+
};
|
|
4727
|
+
for (const step of freshGroup) {
|
|
4728
|
+
if (run.stopRequested) break;
|
|
4729
|
+
if (step.status !== "failed" || !step.onFail) continue;
|
|
4730
|
+
await rescueStep(step);
|
|
4614
4731
|
}
|
|
4615
4732
|
|
|
4616
4733
|
// A step carried into a re-run failed in the run it came from, not
|
|
@@ -4711,7 +4828,21 @@ function driveRunInner(
|
|
|
4711
4828
|
text: `loop exhausted: after ${looper.loop} cycle${looper.loop === 1 ? "" : "s"} the result still does not say "${looper.until}"`,
|
|
4712
4829
|
});
|
|
4713
4830
|
save();
|
|
4714
|
-
if (
|
|
4831
|
+
if (looper.onFail && !run.stopRequested) {
|
|
4832
|
+
await rescueStep(looper);
|
|
4833
|
+
// The rescuer owes what the loop owed: its marker.
|
|
4834
|
+
// rescueStep mutates the status; widen past TS's "failed" narrowing.
|
|
4835
|
+
if ((looper.status as string) === "completed" && !saysUntilMarker(looper.result ?? null, looper.until)) {
|
|
4836
|
+
looper.status = "failed";
|
|
4837
|
+
looper.events.push({
|
|
4838
|
+
t: new Date().toISOString(),
|
|
4839
|
+
type: "error",
|
|
4840
|
+
text: `on-fail: [[${looper.onFail}]]'s result does not say "${looper.until}" either`,
|
|
4841
|
+
});
|
|
4842
|
+
save();
|
|
4843
|
+
}
|
|
4844
|
+
}
|
|
4845
|
+
if ((looper.status as string) === "failed" && !looper.optional) {
|
|
4715
4846
|
run.status = "failed";
|
|
4716
4847
|
break;
|
|
4717
4848
|
}
|
package/src/secrets.ts
CHANGED
|
@@ -42,6 +42,7 @@ const tenantKey = (tenant: string) => platform.tenantKey(tenant);
|
|
|
42
42
|
import crypto from "node:crypto";
|
|
43
43
|
import { assertSafeName } from "./store.ts";
|
|
44
44
|
import { trimSlashes } from "./paths.ts";
|
|
45
|
+
import { SITE } from "./network.ts";
|
|
45
46
|
|
|
46
47
|
const keyFile = () => path.join(dataRoot(), ".secret-key");
|
|
47
48
|
|
|
@@ -99,6 +100,11 @@ interface StoredSecret {
|
|
|
99
100
|
* record written before per-account keys existed is encrypted with — so an
|
|
100
101
|
* old vault stays readable without a migration having to run first. */
|
|
101
102
|
k?: "tenant";
|
|
103
|
+
/** The sites this secret is bound to (network.ts SITE shape). A bound
|
|
104
|
+
* secret never enters a sandbox: the egress proxy fills its `${NAME}` for
|
|
105
|
+
* these sites alone. Metadata, not a value, so it is stored in the clear
|
|
106
|
+
* beside the ciphertext and survives the value being set again. */
|
|
107
|
+
sites?: string[];
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
type SecretsFile = Record<string, StoredSecret>;
|
|
@@ -195,15 +201,67 @@ export function setSecret(
|
|
|
195
201
|
if (value.length > 8192) throw new Error("secret too long");
|
|
196
202
|
const sealed = encryptValue(tenant, value);
|
|
197
203
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
204
|
+
// A new value keeps the sites the old one was bound to: binding is a
|
|
205
|
+
// property of the credential's use, not of its current value, and a
|
|
206
|
+
// rotation that silently unbound it would put the new value in pods.
|
|
207
|
+
const sites = all[name]?.sites;
|
|
198
208
|
all[name] = {
|
|
199
209
|
...sealed,
|
|
200
210
|
updatedAt: new Date().toISOString(),
|
|
201
211
|
...(kind ? { kind } : {}),
|
|
212
|
+
...(sites?.length ? { sites } : {}),
|
|
202
213
|
};
|
|
203
214
|
return all;
|
|
204
215
|
});
|
|
205
216
|
}
|
|
206
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Bind a secret to sites, or unbind it (an empty list). The secret must
|
|
220
|
+
* exist in that scope. Sites are `api.example.com` or `*.example.com` — no
|
|
221
|
+
* scheme, path or port. Returns the list as stored.
|
|
222
|
+
*/
|
|
223
|
+
export function setSecretSites(tenant: string, name: string, sites: string[], workspace?: string): string[] {
|
|
224
|
+
const clean = [...new Set(sites.map((x) => x.trim().toLowerCase()).filter(Boolean))];
|
|
225
|
+
for (const site of clean) {
|
|
226
|
+
if (!SITE.test(site)) throw new Error(`"${site}" is not a site — a domain like api.example.com or *.example.com, no scheme, path or port`);
|
|
227
|
+
}
|
|
228
|
+
let found = false;
|
|
229
|
+
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
230
|
+
const rec = all[name];
|
|
231
|
+
if (!rec) return null;
|
|
232
|
+
found = true;
|
|
233
|
+
// A file, an ssh key or an @api header set is not a value that travels
|
|
234
|
+
// in a header — it is unpacked inside the sandbox, so there is nothing
|
|
235
|
+
// for the proxy to fill.
|
|
236
|
+
if (clean.length && (rec.kind === "file" || rec.kind === "ssh" || rec.kind === "api")) {
|
|
237
|
+
throw new Error(`${name} is a ${rec.kind} secret, unpacked inside the sandbox — only a value sent in a request can be bound to sites`);
|
|
238
|
+
}
|
|
239
|
+
if (clean.length) rec.sites = clean;
|
|
240
|
+
else delete rec.sites;
|
|
241
|
+
return all;
|
|
242
|
+
});
|
|
243
|
+
if (!found) throw new Error(`no secret ${name}${workspace ? ` in workspace ${workspace}` : " in the account"}`);
|
|
244
|
+
return clean;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The sites each named secret is bound to, nearest scope first — the same
|
|
249
|
+
* record getSecret would read the value from. A name with no binding (or
|
|
250
|
+
* no secret) is absent from the answer.
|
|
251
|
+
*/
|
|
252
|
+
export function secretSites(tenant: string, names: string[], workspace?: string): Record<string, string[]> {
|
|
253
|
+
const out: Record<string, string[]> = {};
|
|
254
|
+
const own = workspace ? read(tenant, workspace) : {};
|
|
255
|
+
const source = workspace ? platform.previewSourceOf(tenant, workspace) : null;
|
|
256
|
+
const inherited = source ? read(tenant, source) : {};
|
|
257
|
+
const account = read(tenant);
|
|
258
|
+
for (const name of names) {
|
|
259
|
+
const rec = own[name] ?? inherited[name] ?? account[name];
|
|
260
|
+
if (rec?.sites?.length) out[name] = [...rec.sites];
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
207
265
|
/**
|
|
208
266
|
* Which key writes this account's records.
|
|
209
267
|
*
|
|
@@ -321,6 +379,9 @@ export interface SecretEntry {
|
|
|
321
379
|
/** How this credential works: auto-refreshing oauth2, a signed service
|
|
322
380
|
* account, a file/ssh/api materialised into runs — absent for static values. */
|
|
323
381
|
kind?: SecretKind;
|
|
382
|
+
/** The sites it is bound to — the proxy fills it for these and no other,
|
|
383
|
+
* and it never enters a sandbox. Absent when unbound. */
|
|
384
|
+
sites?: string[];
|
|
324
385
|
}
|
|
325
386
|
|
|
326
387
|
// Metadata only — never values. Without a workspace this lists the account
|
|
@@ -332,6 +393,7 @@ export function listSecrets(tenant: string, workspace?: string): SecretEntry[] {
|
|
|
332
393
|
updatedAt: rec.updatedAt,
|
|
333
394
|
scope: "account" as const,
|
|
334
395
|
...(rec.kind ? { kind: rec.kind } : {}),
|
|
396
|
+
...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
|
|
335
397
|
}));
|
|
336
398
|
if (!workspace) return account.sort((a, b) => a.name.localeCompare(b.name));
|
|
337
399
|
|
|
@@ -341,6 +403,7 @@ export function listSecrets(tenant: string, workspace?: string): SecretEntry[] {
|
|
|
341
403
|
scope: "workspace" as const,
|
|
342
404
|
shadowed: account.some((a) => a.name === name),
|
|
343
405
|
...(rec.kind ? { kind: rec.kind } : {}),
|
|
406
|
+
...(rec.sites?.length ? { sites: [...rec.sites] } : {}),
|
|
344
407
|
}));
|
|
345
408
|
const ownNames = new Set(own.map((s) => s.name));
|
|
346
409
|
return [...own, ...account.filter((a) => !ownNames.has(a.name))].sort((a, b) =>
|
|
@@ -913,7 +976,12 @@ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable
|
|
|
913
976
|
if (plain.size === 0 || unreadableHere.length) return null;
|
|
914
977
|
const next: SecretsFile = { ...all };
|
|
915
978
|
for (const [name, value] of plain) {
|
|
916
|
-
next[name] = {
|
|
979
|
+
next[name] = {
|
|
980
|
+
...encryptValue(tenant, value),
|
|
981
|
+
updatedAt: all[name].updatedAt,
|
|
982
|
+
...(all[name].kind ? { kind: all[name].kind } : {}),
|
|
983
|
+
...(all[name].sites?.length ? { sites: all[name].sites } : {}),
|
|
984
|
+
};
|
|
917
985
|
}
|
|
918
986
|
moved += plain.size;
|
|
919
987
|
return next;
|
package/src/starter.ts
CHANGED
|
@@ -279,6 +279,7 @@ only when they asked for exactly that deletion.
|
|
|
279
279
|
| \`memory/\` | what past runs learned — agents write here themselves |
|
|
280
280
|
| \`evals/\` | tests for agents, same idea as unit tests |
|
|
281
281
|
| \`skills/\` | a procedure an agent can follow, named in \`skills:\` |
|
|
282
|
+
| \`hooks/\` | your code at every tool call, step start or step end (\`hook.md\` + \`run.mjs\`), turned on with \`hooks:\` — refuse, rewrite, add context; same on every model |
|
|
282
283
|
| \`tools/\` | a folder tool: its definition and its code together |
|
|
283
284
|
| \`scripts/\` | code the agents call, referenced by \`scripts:\` |
|
|
284
285
|
| \`state/\` | what runs accumulate. Yours to read, never deployed over |
|
|
@@ -310,7 +311,11 @@ memory an agent wrote that your push does not mention. Those belong to the platf
|
|
|
310
311
|
reverted what an agent learned would make every run a little dumber.
|
|
311
312
|
|
|
312
313
|
Secrets never go in these files. \`foldrun secrets\` puts them in the vault, and
|
|
313
|
-
agents reference them by name.
|
|
314
|
+
agents reference them by name. Bind one to the sites it is for
|
|
315
|
+
(\`foldrun secrets sites NAME api.example.com\`) and it never enters a sandbox:
|
|
316
|
+
the egress proxy fills \`\${NAME}\` in requests to those sites alone. Which sites
|
|
317
|
+
a sandbox may reach at all is \`network:\` — open for an agent that reads the web,
|
|
318
|
+
the trusted list plus what it declares for every other (\`foldrun agent network\`).
|
|
314
319
|
|
|
315
320
|
## Talking to a deployed workspace
|
|
316
321
|
|
|
@@ -356,6 +361,7 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
|
|
|
356
361
|
| \`/export\` · \`/import\` | GET · POST | the workspace, a flow (\`?kind=flow&name=\`) or an agent as a .zip; take one in (\`?dryRun=1\` first, \`?overwrite=1\` to replace) |
|
|
357
362
|
| \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
|
|
358
363
|
| \`/vocabulary\` | GET | what this workspace's documents may say |
|
|
364
|
+
| \`/assistant\` | POST | ask the assistant, or have it write agents, flows, tools and skills — from the docs only; proposals come back, nothing is written |
|
|
359
365
|
|
|
360
366
|
Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
|
|
361
367
|
\`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
|
|
@@ -455,6 +461,7 @@ size: large # small | large | heavy — the sandbox it rents
|
|
|
455
461
|
tools: [web, read, ask, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step
|
|
456
462
|
disallowedTools: [Bash] # subtract from what it would otherwise have
|
|
457
463
|
skills: [house-style] # from skills/
|
|
464
|
+
hooks: [no-force-push] # from hooks/ or the library: code at every tool call
|
|
458
465
|
scripts: [summarise.py] # from scripts/, each becomes a callable tool
|
|
459
466
|
apis: [{ name: crm }] # an HTTP API, as a tool
|
|
460
467
|
mcpServers: {} # an MCP server, as a tool
|
|
@@ -462,6 +469,7 @@ agents: [writer] # colleagues this one may consult (an answer, no too
|
|
|
462
469
|
subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
|
|
463
470
|
secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
|
|
464
471
|
limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
|
|
472
|
+
network: [api.example.com] # sites its sandbox may reach, added to the trusted list; open | trusted | none
|
|
465
473
|
timezone: Australia/Sydney # the calendar it works to — IANA name or UTC+10
|
|
466
474
|
provider: {} # BYOK for this agent; account-wide: \`foldrun account model set\`
|
|
467
475
|
permissionMode: plan # plan first, act once approved
|
package/src/step-exec.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
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
|
|
|
11
|
+
import { runHook, runToolCallHooks, matchesTool, hookLine, resolveHooks, type HookDef, type HookRecord } from "./hooks.ts";
|
|
11
12
|
import fs from "node:fs";
|
|
12
13
|
import os from "node:os";
|
|
13
14
|
import path from "node:path";
|
|
@@ -57,6 +58,14 @@ export interface ExecOutcome {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
export interface ExecOptions {
|
|
61
|
+
/** `hooks:` resolved host-side (hooks.ts): run at this step's own dispatch,
|
|
62
|
+
* inside its sandbox. `hookBase` is what every hook event carries. */
|
|
63
|
+
hooks?: HookDef[];
|
|
64
|
+
/** Or the names (`hooks:` from the account, workspace and agent), resolved
|
|
65
|
+
* here against this step's own roots — in a sandbox those are not the
|
|
66
|
+
* host's paths. A name that resolves to nothing refuses the step. */
|
|
67
|
+
hookNames?: string[];
|
|
68
|
+
hookBase?: Record<string, unknown>;
|
|
60
69
|
agentDir: string;
|
|
61
70
|
workspaceRoot: string;
|
|
62
71
|
libraryRoot: string;
|
|
@@ -158,7 +167,7 @@ export type QueryLike = AsyncIterable<unknown> & { interrupt(): Promise<unknown>
|
|
|
158
167
|
export type QueryFn = (args: { prompt: string; options: Record<string, unknown> }) => QueryLike;
|
|
159
168
|
|
|
160
169
|
/** The pairing fields on a tool event — see RunEvent in store.ts. */
|
|
161
|
-
export type EventExtra = { call?: string; ms?: number; err?: boolean; effect?: TestEffect; operator?: OperatorEvent; subagent?: string; check?: boolean };
|
|
170
|
+
export type EventExtra = { call?: string; ms?: number; err?: boolean; effect?: TestEffect; operator?: OperatorEvent; subagent?: string; check?: boolean; hook?: HookRecord };
|
|
162
171
|
|
|
163
172
|
/** The tag on every event a failed check writes — `verify:`, `output: json`,
|
|
164
173
|
* a schema — headline and detail alike. The runner's no-retry rule for
|
|
@@ -310,8 +319,51 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
310
319
|
// fan-out instance each start from zero.
|
|
311
320
|
const counter = new CallCounter(opts.limits);
|
|
312
321
|
|
|
322
|
+
// Hooks (hooks.ts): the person's own code at this dispatch, same on every
|
|
323
|
+
// model. Each runs in the agent's directory with a bare environment.
|
|
324
|
+
let hooks = opts.hooks ?? [];
|
|
325
|
+
if (opts.hookNames?.length) {
|
|
326
|
+
const r = resolveHooks({ workspaceRoot, libraryRoot, names: opts.hookNames });
|
|
327
|
+
const bad = [...r.missing.map((n) => `no hook named ${n} in hooks/ or the library`), ...r.errors];
|
|
328
|
+
if (bad.length) {
|
|
329
|
+
emit("error", `hooks: ${bad.join("; ")} — the step is refused rather than run without its hooks`);
|
|
330
|
+
return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
|
|
331
|
+
}
|
|
332
|
+
hooks = [...hooks, ...r.hooks];
|
|
333
|
+
}
|
|
334
|
+
const hookBase = { ...(opts.hookBase ?? {}) };
|
|
335
|
+
const callHook = (hook: HookDef, event: Record<string, unknown>) => runHook(hook, event, { cwd: agentDir, env: opts.env });
|
|
336
|
+
const record = (r: HookRecord, via?: string) =>
|
|
337
|
+
emit(r.did === "error" || r.did === "deny" ? "error" : "info", hookLine(r), { hook: r, ...(via ? { subagent: via } : {}) });
|
|
338
|
+
|
|
339
|
+
// step.start — a gate: context it returns goes in front of the task; a
|
|
340
|
+
// hook that fails refuses the step rather than run it unguarded.
|
|
341
|
+
let prompt = opts.prompt;
|
|
342
|
+
for (const hook of hooks.filter((h) => h.on === "step.start")) {
|
|
343
|
+
const a = await callHook(hook, { ...hookBase, event: "step.start", hook: hook.name, prompt });
|
|
344
|
+
if (!a.ok) {
|
|
345
|
+
record({ hook: hook.name, event: "step.start", did: "error", reason: `${a.failure}, so the step is refused`, ms: a.ms });
|
|
346
|
+
return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
|
|
347
|
+
}
|
|
348
|
+
// Exit 2 is a deny on every event (runHook). On a gate that means the
|
|
349
|
+
// step does not run — it used to come back ok with no context, and the
|
|
350
|
+
// step went ahead as if the hook had agreed.
|
|
351
|
+
if (a.decision === "deny") {
|
|
352
|
+
record({ hook: hook.name, event: "step.start", did: "deny", reason: `${a.reason}, so the step is refused`, ms: a.ms });
|
|
353
|
+
return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
|
|
354
|
+
}
|
|
355
|
+
if (a.context) {
|
|
356
|
+
prompt = `${prompt}\n\n${a.context}`;
|
|
357
|
+
record({ hook: hook.name, event: "step.start", did: "context", ms: a.ms });
|
|
358
|
+
} else if (a.note) {
|
|
359
|
+
record({ hook: hook.name, event: "step.start", did: "note", reason: a.note, ms: a.ms });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const toolHooks = hooks.filter((h) => h.on === "tool.call");
|
|
363
|
+
const resultHooks = hooks.filter((h) => h.on === "tool.result");
|
|
364
|
+
|
|
313
365
|
const q = runQuery({
|
|
314
|
-
prompt
|
|
366
|
+
prompt,
|
|
315
367
|
options: {
|
|
316
368
|
cwd: agentDir,
|
|
317
369
|
abortController: abort,
|
|
@@ -411,6 +463,44 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
411
463
|
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: refusal.message } };
|
|
412
464
|
}
|
|
413
465
|
}
|
|
466
|
+
// tool.call hooks — after foldrun's own checks, so a hook never
|
|
467
|
+
// sees a call that was going to be refused anyway, and handed the
|
|
468
|
+
// input as the tool would get it (the workspace/ prefix expanded).
|
|
469
|
+
if (toolHooks.length) {
|
|
470
|
+
const input = (verdict?.updatedInput ?? hookInput.tool_input ?? {}) as Record<string, unknown>;
|
|
471
|
+
// A sub-agent's call says so: a hook that guards the step's own
|
|
472
|
+
// model differently from its delegates can tell them apart.
|
|
473
|
+
const v = await runToolCallHooks(toolHooks, { tool: hookInput.tool_name, input, base: via ? { ...hookBase, subagent: via } : hookBase }, callHook);
|
|
474
|
+
for (const r of v.records) record(r, via);
|
|
475
|
+
const refundCall = () => { if (counter.active) counter.refund(limitKeysFor(hookInput.tool_name, hookInput.tool_input as Record<string, unknown>, opts.toolOwners)); };
|
|
476
|
+
if (v.decision === "deny") {
|
|
477
|
+
refundCall();
|
|
478
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: v.reason! } };
|
|
479
|
+
}
|
|
480
|
+
if (v.input) {
|
|
481
|
+
// A rewritten call is answered "allow", and an allow skips
|
|
482
|
+
// canUseTool — where the shell and path confinement live. So
|
|
483
|
+
// the rewrite goes through the same checks here: a hook can
|
|
484
|
+
// narrow what the agent does, never widen it.
|
|
485
|
+
const again = hookInput.tool_name === "Bash"
|
|
486
|
+
? checkBash(String(v.input.command ?? ""))
|
|
487
|
+
: isFilesystemTool(hookInput.tool_name)
|
|
488
|
+
? checkPaths(hookInput.tool_name, v.input, { agentDir, workspaceRoot, libraryRoot })
|
|
489
|
+
: { ok: true as const };
|
|
490
|
+
const granted = opts.allowed.includes(hookInput.tool_name) || opts.mcpNames.some((n) => hookInput.tool_name.startsWith(`mcp__${n}__`));
|
|
491
|
+
// The sub-agent guard read the ORIGINAL input: a rewrite of a
|
|
492
|
+
// delegation could otherwise swap subagent_type for one this
|
|
493
|
+
// step never declared (or the SDK's general-purpose).
|
|
494
|
+
const delegation = guardSubagent ? guardSubagent({ agentId, agentType, tool: hookInput.tool_name, input: v.input }) : null;
|
|
495
|
+
if (!again.ok || !granted || delegation) {
|
|
496
|
+
refundCall();
|
|
497
|
+
const why = !granted ? `Tool ${hookInput.tool_name} is not enabled for this agent.` : delegation ? `the rewritten call: ${delegation}` : `the rewritten call: ${again.reason}`;
|
|
498
|
+
emit("error", why, via ? { subagent: via } : undefined);
|
|
499
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: why } };
|
|
500
|
+
}
|
|
501
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "allow" as const, updatedInput: again.updatedInput ?? v.input } };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
414
504
|
if (!verdict?.updatedInput) return {};
|
|
415
505
|
return { hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "allow" as const, updatedInput: verdict.updatedInput } };
|
|
416
506
|
}],
|
|
@@ -419,11 +509,21 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
419
509
|
// after its next tool call, as context — never as a change to what it
|
|
420
510
|
// may do. A model that is only writing, calling no tools, hears it at
|
|
421
511
|
// the next call it makes.
|
|
422
|
-
...(opts.inbox
|
|
512
|
+
...(opts.inbox || resultHooks.length
|
|
423
513
|
? {
|
|
424
514
|
PostToolUse: [{
|
|
425
515
|
hooks: [async (hookInput: HookInput) => {
|
|
426
516
|
if (hookInput.hook_event_name !== "PostToolUse") return {};
|
|
517
|
+
// tool.result hooks watch: the call has happened, so one
|
|
518
|
+
// that fails is recorded and the step goes on.
|
|
519
|
+
for (const hook of resultHooks) {
|
|
520
|
+
if (!matchesTool(hook.tool, hookInput.tool_name)) continue;
|
|
521
|
+
const response = (hookInput as { tool_response?: unknown }).tool_response;
|
|
522
|
+
const a = await callHook(hook, { ...hookBase, event: "tool.result", hook: hook.name, tool: { name: hookInput.tool_name, input: hookInput.tool_input ?? {} }, result: response ?? null });
|
|
523
|
+
if (!a.ok) record({ hook: hook.name, event: "tool.result", did: "error", tool: hookInput.tool_name, reason: a.failure, ms: a.ms });
|
|
524
|
+
else if (a.note) record({ hook: hook.name, event: "tool.result", did: "note", tool: hookInput.tool_name, reason: a.note, ms: a.ms });
|
|
525
|
+
}
|
|
526
|
+
if (!opts.inbox) return {};
|
|
427
527
|
// A sub-agent's call: the message is for the step's own
|
|
428
528
|
// model, and draining it here would hand it to a context
|
|
429
529
|
// that ends with the delegation — and lose it.
|
|
@@ -730,6 +830,18 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
730
830
|
"the model stream ended without a result — the model process likely died (out of memory?)",
|
|
731
831
|
);
|
|
732
832
|
}
|
|
833
|
+
// step.end hooks watch the reply, and may rewrite it. One that fails is
|
|
834
|
+
// recorded and the reply stands: the work it describes is done.
|
|
835
|
+
if (status === "completed") {
|
|
836
|
+
for (const hook of hooks.filter((h) => h.on === "step.end")) {
|
|
837
|
+
const a = await callHook(hook, { ...hookBase, event: "step.end", hook: hook.name, reply: texts.join("\n").trim() });
|
|
838
|
+
if (!a.ok) record({ hook: hook.name, event: "step.end", did: "error", reason: a.failure, ms: a.ms });
|
|
839
|
+
else if (a.reply !== undefined) {
|
|
840
|
+
texts.splice(0, texts.length, a.reply);
|
|
841
|
+
record({ hook: hook.name, event: "step.end", did: "reply", ms: a.ms });
|
|
842
|
+
} else if (a.note) record({ hook: hook.name, event: "step.end", did: "note", reason: a.note, ms: a.ms });
|
|
843
|
+
}
|
|
844
|
+
}
|
|
733
845
|
const result = texts.join("\n").trim() || null;
|
|
734
846
|
// A step's reply is not one message: the model narrates ("Now let me read
|
|
735
847
|
// the outputs…"), calls tools, and answers last. Joining those and reading
|