@foldrun/core 2.7.0 → 2.9.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.
@@ -4,8 +4,9 @@
4
4
  // flow, so there is a single execution path.
5
5
  import { latestRevisionId } from "./history.js";
6
6
  import { recordCallIo, redactionsFor } from "./capture.js";
7
- import { AUTH_REFUSAL } from "./issues.js";
7
+ import { AUTH_REFUSAL, platformCause } from "./issues.js";
8
8
  import { hookNames } from "./hooks.js";
9
+ import crypto from "node:crypto";
9
10
  import fs from "node:fs";
10
11
  import os from "node:os";
11
12
  import path from "node:path";
@@ -32,12 +33,13 @@ const isolatedRun = () => {
32
33
  return mode === "container" || (!!mode && mode in platform.isolation);
33
34
  };
34
35
  import { gatherConsults, buildConsultTools } from "./agent-tools.js";
35
- import { DELEGATE_TOOL, gatherSubagents, subagentPrompt, subagentTools } from "./subagents.js";
36
+ import { DELEGATE_TOOL, gatherSubagents, ownSandbox, subagentPrompt, subagentTools } from "./subagents.js";
37
+ import { DELEGATE_SERVER, buildDelegateTools, delegatePromptLine, delegateToolNames, localDelegateChannel } from "./delegate-tools.js";
36
38
  import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine } from "./operator.js";
37
39
  import { sendPlainNotification } from "./notify.js";
38
40
  import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
39
41
  import { refNames } from "./refs.js";
40
- import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isRescueRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
42
+ import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isSplicedRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
41
43
  import { canReadBack } from "./tool-output.js";
42
44
  import { PromptLayers, promptCacheKey } from "./prompt-layers.js";
43
45
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf } from "./catalog.js";
@@ -525,6 +527,10 @@ export function buildSubagentSpecs(o) {
525
527
  try {
526
528
  const tags = o.tags ?? [];
527
529
  const own = agentContext(sub.dir, o.tenant, tags, { runId: o.runId, agent: sub.name }, o.flowTimezone, true);
530
+ // sandbox: own — delegated to as a step of its own, not inside this
531
+ // one (buildDelegateSpecs).
532
+ if (ownSandbox(own.front))
533
+ continue;
528
534
  const description = typeof own.front.description === "string" ? own.front.description.trim() : "";
529
535
  if (!description) {
530
536
  o.push("error", `subagents: "${sub.name}" has no description — the model picks a sub-agent by it; not delegated to`);
@@ -556,6 +562,51 @@ export function buildSubagentSpecs(o) {
556
562
  }
557
563
  return out;
558
564
  }
565
+ /**
566
+ * The `sandbox: own` sub-agents a step may hand jobs to: a name and a
567
+ * description each, all the parent's sandbox needs. What they may hold is
568
+ * read from their own file when a job arrives — never cut to the parent's,
569
+ * and never seen by it. `canRun` is false where nothing can start their
570
+ * sandbox (a sub-agent's own step: depth one; a step outside a run).
571
+ */
572
+ export function buildDelegateSpecs(o) {
573
+ const out = [];
574
+ for (const sub of gatherSubagents(o.workspaceRoot, o.names).found) {
575
+ if (sub.name === o.self)
576
+ continue; // buildSubagentSpecs says so
577
+ let front;
578
+ try {
579
+ front = matter(fs.readFileSync(path.join(sub.dir, "agent.md"), "utf8")).data;
580
+ }
581
+ catch {
582
+ continue; // buildSubagentSpecs says so
583
+ }
584
+ if (!ownSandbox(front))
585
+ continue;
586
+ const description = typeof front.description === "string" ? front.description.trim() : "";
587
+ if (!description) {
588
+ o.push("error", `subagents: "${sub.name}" has no description — the model picks a sub-agent by it; not delegated to`);
589
+ continue;
590
+ }
591
+ if (!o.canRun) {
592
+ o.push("info", `subagents: "${sub.name}" runs in its own sandbox, which nothing here can start (a sub-agent cannot delegate again, and a step outside a run has no room for it) — not offered`);
593
+ continue;
594
+ }
595
+ if (o.outward) {
596
+ let acts = true; // unreadable is treated as able to act: the safe side
597
+ const tags = o.tags ?? [];
598
+ try {
599
+ acts = agentContext(sub.dir, o.tenant ?? "default", tags, { agent: sub.name }, null, true).outward;
600
+ }
601
+ catch {
602
+ /* as above */
603
+ }
604
+ o.outward(sub.name, acts);
605
+ }
606
+ out.push({ name: sub.name, description });
607
+ }
608
+ return out;
609
+ }
559
610
  export function sharedInstructions(agentDir, tenant) {
560
611
  const sections = [
561
612
  ["Everyone in this account", readAgentsMd(accountDir(tenant))?.body],
@@ -1852,7 +1903,11 @@ stepIndex,
1852
1903
  siblings = [],
1853
1904
  /** The in-process colleagues' bus for this group (localPeerBus), when
1854
1905
  * the step runs in this process rather than a pod. */
1855
- peerBus) {
1906
+ peerBus,
1907
+ /** Runs one job for a `sandbox: own` sub-agent as a step of its own and
1908
+ * answers with its reply (the drive loop's delegateFor). Absent for a
1909
+ * step that is itself a sub-agent's: depth one. */
1910
+ delegateJob) {
1856
1911
  // Secret values are injected into scripts as environment variables and
1857
1912
  // substituted into API headers, so a model that reads one back — from a
1858
1913
  // script that prints it, an API error that echoes it, or its own `env` —
@@ -1872,6 +1927,9 @@ peerBus) {
1872
1927
  let fileDir = null;
1873
1928
  let installDir = null;
1874
1929
  let disposeRuntime;
1930
+ /** The delegate door served for this step (sandbox: own sub-agents), held
1931
+ * here so the finally can close it however the step ends. */
1932
+ let serving = null;
1875
1933
  // Can this step's tools act outside the workspace? Set once its context is
1876
1934
  // read; the verdict at the end of this function reads it.
1877
1935
  let canActOutward = false;
@@ -2323,8 +2381,13 @@ peerBus) {
2323
2381
  // Colleagues this agent may DELEGATE to: each with its own context and
2324
2382
  // its own tools — resolved exactly as its own step's would be, then cut
2325
2383
  // to what THIS step holds (subagents.ts). Built host-side, like consults.
2384
+ // A sandbox: own job is a sub-agent already: depth one, so neither kind
2385
+ // of delegation is offered to it.
2386
+ if (step.subagentOf && refNames(front.subagents).length) {
2387
+ push("info", `subagents: not offered — ${step.agent} is running a job for ${step.subagentOf}, and a sub-agent does not delegate again`);
2388
+ }
2326
2389
  const subagents = buildSubagentSpecs({
2327
- names: front.subagents, self: step.agent, workspaceRoot, tenant, tags, runId, flowTimezone,
2390
+ names: step.subagentOf ? [] : front.subagents, self: step.agent, workspaceRoot, tenant, tags, runId, flowTimezone,
2328
2391
  parentAllowed: [...allowed, ...consultNames.map((n) => `mcp__${n}`)], push,
2329
2392
  });
2330
2393
  if (subagents.length) {
@@ -2334,6 +2397,34 @@ peerBus) {
2334
2397
  for (const s of subagents)
2335
2398
  push("info", `subagent: ${s.name} — ${s.tools.length ? s.tools.join(", ") : "no tools"}`);
2336
2399
  }
2400
+ // sandbox: own sub-agents: a delegate_<name> tool each. Their jobs run as
2401
+ // steps of their own (delegate-tools.ts); dropped again below where no
2402
+ // door to the worker exists.
2403
+ let delegates = buildDelegateSpecs({
2404
+ names: step.subagentOf ? [] : front.subagents, self: step.agent, workspaceRoot, canRun: !!delegateJob && stepIndex !== undefined && stepIndex >= 0, push,
2405
+ tenant,
2406
+ tags,
2407
+ outward: (_name, acts) => void (canActOutward ||= acts),
2408
+ });
2409
+ const offerDelegates = (specs) => {
2410
+ for (const n of delegateToolNames(delegates))
2411
+ if (allowed.includes(n))
2412
+ allowed.splice(allowed.indexOf(n), 1);
2413
+ if (consultNames.includes(DELEGATE_SERVER))
2414
+ consultNames.splice(consultNames.indexOf(DELEGATE_SERVER), 1);
2415
+ delegates = specs;
2416
+ if (!specs.length)
2417
+ return;
2418
+ allowed.push(...delegateToolNames(specs));
2419
+ consultNames.push(DELEGATE_SERVER);
2420
+ };
2421
+ offerDelegates(delegates);
2422
+ const delegateLine = delegates.length ? `\n\n${delegatePromptLine(delegates)}` : "";
2423
+ if (delegates.length) {
2424
+ prompt += delegateLine;
2425
+ for (const d of delegates)
2426
+ push("info", `subagent: ${d.name} — own sandbox, its own tools, secrets and network`);
2427
+ }
2337
2428
  // The last provider refusal this step saw, for the fallback decision —
2338
2429
  // watched on both paths, because a refusal reads the same from a pod
2339
2430
  // and from this process.
@@ -2519,7 +2610,30 @@ peerBus) {
2519
2610
  // colleague's message may be addressed to. Only when granted: a
2520
2611
  // step without the tools has no reason to know its siblings.
2521
2612
  ...(peers && stepIndex !== undefined && stepIndex >= 0 ? { agent: step.agent, peers: siblings } : {}),
2613
+ // sandbox: own sub-agents: the only names the delegate door takes.
2614
+ ...(delegates.length ? { delegates: delegates.map((d) => d.name) } : {}),
2522
2615
  });
2616
+ // Serve those jobs while this step runs: each one becomes a step of
2617
+ // its own (delegateJob), and the door answers the long-poll with its
2618
+ // reply. No proxy, or a platform without the door: not offered — the
2619
+ // tools would only fail.
2620
+ if (delegates.length) {
2621
+ // Also: the executor must be able to stop ONE job — record its
2622
+ // sandbox (the resumable ones checkpoint it) and destroy it — or a job
2623
+ // whose parent stopped waiting would run on, sending and writing.
2624
+ const canStopAJob = !!platform.killSandbox && platform.sandboxResumable(isolation);
2625
+ if (lease && platform.egress.delegations && delegateJob && stepIndex !== undefined && canStopAJob) {
2626
+ serving = platform.egress.delegations(
2627
+ // Re-attaching to a sandbox that outlived its driver: what it
2628
+ // queued before the restart is still its own, not a dead attempt's.
2629
+ { tenant, runId: runId ?? "adhoc", step: stepIndex, resuming: !!step.sandbox && step.sandbox.kind === isolation }, (j) => delegateJob(j.agent, j.job, j.cancelled ?? (() => false)));
2630
+ }
2631
+ else {
2632
+ push("error", `subagents: ${delegates.map((d) => d.name).join(", ")} run in their own sandbox, and this install ${canStopAJob ? "has no door to start one from inside a step" : "cannot stop one job's sandbox on its own"} — not offered`);
2633
+ offerDelegates([]);
2634
+ prompt = prompt.replace(delegateLine, "");
2635
+ }
2636
+ }
2523
2637
  // The forward door: every program in the sandbox sends through the
2524
2638
  // proxy, which holds the site list and fills bound secrets. A proxy
2525
2639
  // without the door (an older platform) leaves programs going direct.
@@ -2689,6 +2803,7 @@ peerBus) {
2689
2803
  consults,
2690
2804
  ...(peers ? { peers: { self: step.agent, siblings } } : {}),
2691
2805
  ...(subagents.length ? { subagents } : {}),
2806
+ ...(delegates.length ? { delegates } : {}),
2692
2807
  timeoutSec: step.timeout,
2693
2808
  budgetUsd: stepBudgetUsd,
2694
2809
  budgetNote: stepBudgetNote,
@@ -2905,6 +3020,8 @@ peerBus) {
2905
3020
  // to decide; a second go could send or charge twice.
2906
3021
  step.image = outcome.image ?? null;
2907
3022
  step.browserPod = outcome.browserPod ? { ...outcome.browserPod } : null;
3023
+ if (step.subagentOf && outcome.applied?.length)
3024
+ step.wrote = [...new Set([...(step.wrote ?? []), ...outcome.applied])];
2908
3025
  if (outcome.status === "failed" && outcome.browserPod?.lost) {
2909
3026
  const decision = podLossDecision(outcome.browserPod);
2910
3027
  if (decision.rerun) {
@@ -2952,6 +3069,8 @@ peerBus) {
2952
3069
  else
2953
3070
  pushWatching("info", line.text, { effect: line.effect });
2954
3071
  }
3072
+ await serving?.close();
3073
+ serving = null;
2955
3074
  await lease.release();
2956
3075
  }
2957
3076
  // Nothing a test run left in storage/public/ reached storage/, so
@@ -3045,6 +3164,16 @@ peerBus) {
3045
3164
  const peerChannel = peerBus && stepIndex !== undefined && stepIndex >= 0 ? peerBus.channelFor({ agent: step.agent, step: stepIndex }, siblings) : null;
3046
3165
  const peerTools = peers ? buildPeerTools({ channel: peerChannel, self: step.agent, siblings, emit: push }) : null;
3047
3166
  const localInbox = peerChannel ? inboxReader(peerChannel, push) : undefined;
3167
+ // sandbox: own sub-agents, here: no proxy between the step and this
3168
+ // process, so the job is handed to the drive loop directly.
3169
+ const delegateTools = delegates.length && delegateJob
3170
+ ? buildDelegateTools({ specs: delegates, channel: localDelegateChannel(delegateJob), emit: push, timeoutSec: step.timeout })
3171
+ : null;
3172
+ // Said, not hidden: on this machine no step has a sandbox, so a job is
3173
+ // its own context, tools and secrets — but the same files and network.
3174
+ if (delegateTools?.server) {
3175
+ push("info", `subagents: ${delegates.map((d) => d.name).join(", ")} run here in this process — their own tools and secrets, but no sandbox: the same files and network as this step (on the platform each job gets a sandbox of its own)`);
3176
+ }
3048
3177
  // @file secrets become 0600 paths here (host run), cleaned up in the
3049
3178
  // finally — once, however many attempts follow.
3050
3179
  const mat = materializeFileSecrets(agentDir, stepSecrets);
@@ -3086,6 +3215,7 @@ peerBus) {
3086
3215
  ...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
3087
3216
  ...(askTools ? { foldrun_ask: askTools.server } : {}),
3088
3217
  ...(peerTools ? { foldrun_peers: peerTools.server } : {}),
3218
+ ...(delegateTools?.server ? { foldrun_delegate: delegateTools.server } : {}),
3089
3219
  ...(installTools ? { foldrun_install: installTools.server } : {}),
3090
3220
  ...mcpServers,
3091
3221
  },
@@ -3209,6 +3339,16 @@ peerBus) {
3209
3339
  push("error", err instanceof Error ? err.message : String(err));
3210
3340
  }
3211
3341
  finally {
3342
+ // The delegate door's service, when the step ended by throwing before
3343
+ // its lease was released: its jobs are settled, not left hanging.
3344
+ if (serving) {
3345
+ try {
3346
+ await serving.close();
3347
+ }
3348
+ catch {
3349
+ // best-effort — the door's records expire on their own
3350
+ }
3351
+ }
3212
3352
  try {
3213
3353
  cleanupFileSecrets(fileDir);
3214
3354
  if (installDir)
@@ -3370,6 +3510,34 @@ function platformFallbackEnv() {
3370
3510
  */
3371
3511
  const CREDENTIAL_ROTATION_WAIT_MS = 90_000;
3372
3512
  const CREDENTIAL_POLL_MS = 2_000;
3513
+ /** Platform causes a rescuer on the same model supply cannot get past. */
3514
+ const SAME_SUPPLY_WALLS = new Set(["model-cap", "model-auth"]);
3515
+ /** Whether two agents of one workspace call the same model supply: each
3516
+ * resolved the way agentContext does — its own provider: block, else the
3517
+ * workspace's — and compared in a stable key order. The account key is
3518
+ * shared by both, so two that resolve to no block share it (Codex review). */
3519
+ export function sameSupply(workspaceDir, tenant, a, b) {
3520
+ const supplyOf = (agent) => {
3521
+ const agentDir = path.join(workspaceDir, "agents", agent);
3522
+ let own;
3523
+ try {
3524
+ own = matter(fs.readFileSync(path.join(agentDir, "agent.md"), "utf8")).data.provider;
3525
+ }
3526
+ catch {
3527
+ own = undefined;
3528
+ }
3529
+ return stableJson(own ?? workspaceFrontmatter(agentDir, tenant).provider ?? null);
3530
+ };
3531
+ return supplyOf(a) === supplyOf(b);
3532
+ }
3533
+ function stableJson(v) {
3534
+ if (Array.isArray(v))
3535
+ return `[${v.map(stableJson).join(",")}]`;
3536
+ if (v && typeof v === "object") {
3537
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(",")}}`;
3538
+ }
3539
+ return JSON.stringify(v) ?? "null";
3540
+ }
3373
3541
  /** A refusal of the credential itself, in any of the wordings Claude Code
3374
3542
  * has used. "Failed to authenticate: OAuth token revoked" (9 Oct 2026, a
3375
3543
  * step started one second before the 09:07:52Z refresh) matched none of the
@@ -4014,6 +4182,32 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4014
4182
  // last driver got, runStep passes that as `resume`, and the step
4015
4183
  // carries on from where it was — a deploy no longer costs the run.
4016
4184
  let orphaned = false;
4185
+ // A sandbox: own job whose driver went away is over: the reply it was
4186
+ // making went to a process that no longer exists, and the step that
4187
+ // asked is told it failed (the platform settles the door's side), so
4188
+ // its model can hand the job over again. Never resumed or relaunched.
4189
+ for (const step of run.steps) {
4190
+ if (!step.subagentOf || (step.status !== "running" && step.status !== "pending"))
4191
+ continue;
4192
+ // Its sandbox may still be executing — outward work included — while
4193
+ // the parent hands the job over again: destroy it before letting go
4194
+ // of the only reference to it.
4195
+ if (step.sandbox?.ref) {
4196
+ try {
4197
+ platform.killSandbox?.(step.sandbox);
4198
+ }
4199
+ catch {
4200
+ // a sandbox we cannot reach must not stop the run from resuming
4201
+ }
4202
+ }
4203
+ step.status = "failed";
4204
+ step.sandbox = null;
4205
+ step.events.push({ t: new Date().toISOString(), type: "error", text: "interrupted — the worker driving this job restarted; the step that asked for it was told it failed" });
4206
+ const parent = run.steps.find((s) => s.delegations?.some((d) => d.id === step.delegation));
4207
+ const entry = parent?.delegations?.find((d) => d.id === step.delegation);
4208
+ if (entry && entry.status === "running")
4209
+ entry.status = "failed";
4210
+ }
4017
4211
  for (const step of run.steps) {
4018
4212
  if (step.status !== "running")
4019
4213
  continue;
@@ -4158,7 +4352,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4158
4352
  // three handoffs below read the flow's steps only: counting the rescue
4159
4353
  // too handed the next group the same output twice, or a stale one
4160
4354
  // from an evaluator loop's earlier cycle.
4161
- const flowStepsOf = (group) => group.filter((s) => !isRescueRecord(s));
4355
+ const flowStepsOf = (group) => group.filter((s) => !isSplicedRecord(s));
4162
4356
  const dataOf = (all) => {
4163
4357
  const group = flowStepsOf(all);
4164
4358
  const carrying = group.filter((s) => s.status === "completed" && s.data !== undefined);
@@ -4557,7 +4751,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4557
4751
  save();
4558
4752
  if (freshGroup.every((s) => s.status !== "pending")) {
4559
4753
  // everything in this group was rejected
4560
- if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s))) {
4754
+ if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isSplicedRecord(s))) {
4561
4755
  run.status = "failed";
4562
4756
  break;
4563
4757
  }
@@ -4574,13 +4768,119 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4574
4768
  // of the flow: one left pending by a drive that died is picked up by
4575
4769
  // rescueStep below, never launched alongside the group (21 Oct 2026:
4576
4770
  // a resumed run launched 77 leftover fixers at once).
4577
- const launching = freshGroup.filter((s) => s.status === "pending" && !isRescueRecord(s));
4771
+ const launching = freshGroup.filter((s) => s.status === "pending" && !isSplicedRecord(s));
4578
4772
  // tools: [peers] — what each step of the group is told about the
4579
4773
  // others, and the bus they share when they run in this process.
4580
4774
  const peerBus = localPeerBus();
4581
4775
  const siblingsOf = (step) => launching
4582
4776
  .filter((s) => s !== step)
4583
4777
  .map((s) => ({ agent: s.agent, step: run.steps.indexOf(s), instruction: (s.instruction ?? "").split("\n")[0].trim().slice(0, 200) }));
4778
+ // sandbox: own sub-agents (delegate-tools.ts): one job, run as a step
4779
+ // of its own — its own sandbox, tools, secrets and network, from its
4780
+ // own file — and answered with its reply. The record is APPENDED to
4781
+ // run.steps, in the parent's group: every running step's lease and
4782
+ // inbox are addressed by index, so nothing may shift under them.
4783
+ // Marked subagentOf, it is never launched, handed on or reset as a
4784
+ // step of the flow (isSplicedRecord). It gets no delegateJob of its
4785
+ // own: depth one.
4786
+ const delegateFor = (parent) => async (agent, job, cancelled) => {
4787
+ const dir = path.join(pDir, "agents", agent);
4788
+ if (!fs.existsSync(path.join(dir, "agent.md")))
4789
+ return { status: "failed", result: `${agent} is not an agent in this workspace` };
4790
+ // A stable id on both records: an index into run.steps moves when a
4791
+ // rescue or a fan-out is spliced in before the child.
4792
+ const id = `d_${crypto.randomBytes(5).toString("hex")}`;
4793
+ const child = {
4794
+ agent,
4795
+ subagentOf: parent.agent,
4796
+ delegation: id,
4797
+ instruction: job,
4798
+ group: parent.group,
4799
+ optional: true,
4800
+ timeout: parent.timeout,
4801
+ attempts: 1,
4802
+ status: "running",
4803
+ events: [],
4804
+ result: null,
4805
+ costUsd: null,
4806
+ };
4807
+ run.steps.push(child);
4808
+ const at = run.steps.length - 1;
4809
+ const entry = { id, agent, status: "running" };
4810
+ (parent.delegations ??= []).push(entry);
4811
+ const started = Date.now();
4812
+ // The same budget rules as a step of the flow: nothing left means
4813
+ // not started (a zero ceiling would read as no cap at all), and
4814
+ // what it may spend is reserved while it runs, so two jobs at once
4815
+ // do not each get the agent's whole remaining budget.
4816
+ // The flow's budget: what is left after what has been spent AND
4817
+ // what every step and job running now holds — the parent's own
4818
+ // ceiling included. ceilingFor's equal share would hand the job a
4819
+ // second copy of an allowance its parent already holds.
4820
+ const held = [...reserved.values()].reduce((a, b) => a + b, 0);
4821
+ const flowLeft = run.budgetUsd && run.budgetUsd > 0 ? Math.max(0, run.budgetUsd - platform.runSpend(run) - held) : null;
4822
+ const ceiling = stepCeilingFor(flowLeft, agentBudgets.get(agent), run.steps
4823
+ .filter((s) => s.agent === agent)
4824
+ .reduce((sum, s) => sum + (s.costUsd ?? 0) + (s !== child && s.status === "running" ? (reserved.get(s) ?? 0) : 0), 0), agent, 1);
4825
+ if (ceiling.ceilingUsd === 0) {
4826
+ child.status = "failed";
4827
+ child.events.push({ t: new Date().toISOString(), type: "error", text: `over budget — nothing left to spend before this job (${ceiling.note}); it was not started` });
4828
+ entry.status = "failed";
4829
+ entry.ms = 0;
4830
+ save();
4831
+ return { status: "failed", result: `${agent} was not started: over budget (${ceiling.note})`, wrote: [] };
4832
+ }
4833
+ if (ceiling.ceilingUsd !== null)
4834
+ reserved.set(child, ceiling.ceilingUsd);
4835
+ save();
4836
+ // A running sandbox does not hear a stop flag: when the parent stops
4837
+ // waiting (its tool timed out, or its step ended), destroy the job's
4838
+ // sandbox, so nothing it does outlives the step that asked for it.
4839
+ const parentGone = () => run.stopRequested === true || cancelled() || (parent.status !== "running" && parent.status !== "pending");
4840
+ let killedIt = false;
4841
+ const watch = setInterval(() => {
4842
+ if (killedIt || !parentGone() || !child.sandbox?.ref)
4843
+ return;
4844
+ killedIt = true;
4845
+ child.events.push({ t: new Date().toISOString(), type: "error", text: "stopped — the step that asked for this job stopped waiting for it" });
4846
+ try {
4847
+ platform.killSandbox?.(child.sandbox);
4848
+ }
4849
+ catch {
4850
+ // its own deadline ends it
4851
+ }
4852
+ }, 2_000);
4853
+ watch.unref?.();
4854
+ try {
4855
+ await runStep(dir, tenant, child, null, save, modelOverride, tags, effortOverride, run.id, undefined, ceiling.ceilingUsd, ceiling.note, run.test === true, () => {
4856
+ save();
4857
+ // The parent's step ending or cancelling ends the job too —
4858
+ // here while it waits for room; the watch above once it runs.
4859
+ return parentGone();
4860
+ }, flowTimezone, at);
4861
+ }
4862
+ catch (err) {
4863
+ child.status = "failed";
4864
+ child.events.push({ t: new Date().toISOString(), type: "error", text: err instanceof Error ? err.message : String(err) });
4865
+ }
4866
+ finally {
4867
+ clearInterval(watch);
4868
+ reserved.delete(child);
4869
+ }
4870
+ // runStep mutates child.status; widen past TS's "running" narrowing.
4871
+ const ended = child.status;
4872
+ const status = ended === "completed" ? "completed" : ended === "skipped" ? "skipped" : "failed";
4873
+ entry.status = status;
4874
+ entry.costUsd = child.costUsd ?? null;
4875
+ entry.ms = Date.now() - started;
4876
+ save();
4877
+ const errors = child.events.filter((e) => e.type === "error").map((e) => e.text).join("\n");
4878
+ return {
4879
+ status,
4880
+ result: child.result ?? (errors || "(no reply)"),
4881
+ wrote: child.wrote ?? [],
4882
+ };
4883
+ };
4584
4884
  // An agent's own `budget:` — the most it may spend in one run — is
4585
4885
  // read at launch, so a cap raised mid-run applies to the next step.
4586
4886
  const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
@@ -4700,7 +5000,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4700
5000
  () => {
4701
5001
  save();
4702
5002
  return run.stopRequested === true;
4703
- }, flowTimezone, run.steps.indexOf(step), siblingsOf(step), peerBus).finally(() => reserved.delete(step));
5003
+ }, flowTimezone, run.steps.indexOf(step), siblingsOf(step), peerBus, delegateFor(step)).finally(() => reserved.delete(step));
4704
5004
  // runStep mutates step.status; read it through a widened local
4705
5005
  // so TS doesn't keep the "running" narrowing from above.
4706
5006
  const outcome = step.status;
@@ -4745,6 +5045,19 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4745
5045
  save();
4746
5046
  if (run.stopRequested)
4747
5047
  break;
5048
+ // A spent usage window resets in hours or days, never within
5049
+ // the retry waits below: another attempt is refused the same
5050
+ // way (abandon-desk's collector ran twice on 23 Oct 2026).
5051
+ const lastError = [...step.events].reverse().find((e) => e.type === "error")?.text;
5052
+ if (platformCause(lastError)?.key === "model-cap") {
5053
+ step.events.push({
5054
+ t: new Date().toISOString(),
5055
+ type: "info",
5056
+ text: `not retried — the model usage cap is reached and will not reset within a retry's wait (${attempts - attempt} left unused)`,
5057
+ });
5058
+ save();
5059
+ break;
5060
+ }
4748
5061
  // The retry policy. An attempt the cluster ended for want of
4749
5062
  // memory or disk is retried one size class up — the same
4750
5063
  // size would end the same way. Every retry waits: 15 s, 30 s,
@@ -4815,6 +5128,21 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4815
5128
  });
4816
5129
  return;
4817
5130
  }
5131
+ // A rescuer on the same model supply hits the same wall. On 23 Oct
5132
+ // 2026 every desk's fixer started after a spent weekly window, was
5133
+ // refused the same way, and the issue read "fixer also failed"
5134
+ // instead of the cap. A rescuer on a different supply still tries.
5135
+ const lastError = [...step.events].reverse().find((e) => e.type === "error")?.text;
5136
+ const wall = platformCause(lastError);
5137
+ if (wall && SAME_SUPPLY_WALLS.has(wall.key) && sameSupply(pDir, tenant, step.agent, step.onFail)) {
5138
+ step.events.push({
5139
+ t: new Date().toISOString(),
5140
+ type: "info",
5141
+ text: `not handing to [[${step.onFail}]] (on-fail): ${wall.label.toLowerCase()}, and it runs on the same model supply`,
5142
+ });
5143
+ save();
5144
+ return;
5145
+ }
4818
5146
  // One rescue per failure, across drives. A worker that died
4819
5147
  // mid-rescue left an unfinished record behind it; the drive that
4820
5148
  // resumes picks that one up instead of splicing another — a crash
@@ -4826,8 +5154,10 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4826
5154
  // answered an earlier failure — an evaluator loop's previous cycle
4827
5155
  // — not this one.
4828
5156
  const leftovers = [];
4829
- for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isRescueRecord(run.steps[j]); j++) {
5157
+ for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isSplicedRecord(run.steps[j]); j++) {
4830
5158
  const r = run.steps[j];
5159
+ if (r.subagentOf)
5160
+ continue;
4831
5161
  if (r.agent === step.onFail && (r.rescueOf === undefined || r.rescueOf === step.agent) && (r.status === "pending" || r.status === "running")) {
4832
5162
  leftovers.push(r);
4833
5163
  }
@@ -4935,7 +5265,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4935
5265
  // A rescue record's own failure is already on the step it took over
4936
5266
  // ("also failed"); counted here it would fail a group whose step a
4937
5267
  // later rescue completed.
4938
- const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s));
5268
+ const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isSplicedRecord(s));
4939
5269
  if (requiredFailed) {
4940
5270
  run.status = "failed";
4941
5271
  break;
@@ -5014,7 +5344,7 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
5014
5344
  // A rescue answered its own failure and stays as it ended: reset
5015
5345
  // to pending it read as an interrupted rescue, and the next
5016
5346
  // cycle's failure was handed the old one's instructions.
5017
- if (isRescueRecord(s))
5347
+ if (isSplicedRecord(s))
5018
5348
  continue;
5019
5349
  s.status = "pending";
5020
5350
  s.result = null;
@@ -5360,6 +5690,9 @@ export async function waitForRun(tenant, workspace, runId, timeoutMs = 5 * 60_00
5360
5690
  */
5361
5691
  export function runResult(run) {
5362
5692
  for (let i = run.steps.length - 1; i >= 0; i--) {
5693
+ // A sandbox: own job's reply went to the step that asked (runSummary).
5694
+ if (run.steps[i].subagentOf)
5695
+ continue;
5363
5696
  const r = run.steps[i].result;
5364
5697
  if (r && r.trim())
5365
5698
  return r;
package/dist/src/scan.js CHANGED
@@ -77,7 +77,7 @@ export const SCAN_RULES = [
77
77
  title: "Reads untrusted content, holds private data and can send it out",
78
78
  help: "An agent with all three can be told — by any page it reads — to send what it holds anywhere it can reach. " +
79
79
  "Take one leg away: take it off the open network (`network: { level: trusted, allow: [the sites it needs] }` — a bare list only adds sites, " +
80
- "and an agent that reads the web stays open), split the reading and the sending into two agents, " +
80
+ "and an agent that reads the web stays open), split the reading and the sending into two agents (a step each, or a reader sub-agent with \`sandbox: own\`), " +
81
81
  "or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
82
82
  owasp: ["ASI01", "ASI02", "ASI03"],
83
83
  },
@@ -90,6 +90,16 @@ export const SCAN_RULES = [
90
90
  "and if that rule ever changed, they would be a privilege escalation. Grant them to the parent or remove them.",
91
91
  owasp: ["ASI03"],
92
92
  },
93
+ {
94
+ id: "security/subagent-own-sandbox",
95
+ category: "security",
96
+ level: "info",
97
+ title: "A sub-agent works in its own sandbox, with tools its parent does not have",
98
+ help: "`sandbox: own` on a sub-agent runs each job it is handed in a sandbox of its own, with its own tools, secrets and network — " +
99
+ "not cut to the parent's. That is the point (a reader with the web and no secrets, under a writer with secrets and no web), " +
100
+ "so this only says what it holds. Only its reply reaches the parent.",
101
+ owasp: ["ASI03"],
102
+ },
93
103
  {
94
104
  id: "supply-chain/pipe-to-shell",
95
105
  category: "supply-chain",
@@ -650,6 +660,12 @@ function scanAgents(agents, outward, toolDefs, push, networks) {
650
660
  if (!sub || s === a.name)
651
661
  continue;
652
662
  const extra = refNames(sub.data.tools).filter((t) => !tools.includes(t));
663
+ if (sub.data.sandbox === "own") {
664
+ if (extra.length) {
665
+ push("security/subagent-own-sandbox", a.file, keyLine(a.lines, "subagents"), `subagent ${s} own ${extra.join(",")}`, `subagents: [${s}] — ${s} runs in its own sandbox with ${extra.join(", ")}, which ${a.name} does not have; only its reply comes back`);
666
+ }
667
+ continue;
668
+ }
653
669
  if (extra.length && tools.length) {
654
670
  push("security/subagent-wider", a.file, keyLine(a.lines, "subagents"), `subagent ${s} ${extra.join(",")}`, `subagents: [${s}] — ${s} declares ${extra.join(", ")}, which ${a.name} does not have; inside the delegation they are dropped`);
655
671
  }
@@ -478,6 +478,7 @@ apis: [{ name: crm }] # an HTTP API, as a tool
478
478
  mcpServers: {} # an MCP server, as a tool
479
479
  agents: [writer] # colleagues this one may consult (an answer, no tools)
480
480
  subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
481
+ sandbox: own # delegated to: run each job in its own sandbox (own tools, secrets, network)
481
482
  secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
482
483
  limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
483
484
  network: [api.example.com] # sites its sandbox may reach, added to the trusted list; open | trusted | none