@foldrun/core 2.7.0 → 2.8.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/src/runner.ts CHANGED
@@ -7,6 +7,7 @@ import { latestRevisionId } from "./history.ts";
7
7
  import { recordCallIo, redactionsFor } from "./capture.ts";
8
8
  import { AUTH_REFUSAL } from "./issues.ts";
9
9
  import { hookNames } from "./hooks.ts";
10
+ import crypto from "node:crypto";
10
11
  import fs from "node:fs";
11
12
  import os from "node:os";
12
13
  import path from "node:path";
@@ -36,7 +37,10 @@ const isolatedRun = () => {
36
37
  return mode === "container" || (!!mode && mode in platform.isolation);
37
38
  };
38
39
  import { gatherConsults, buildConsultTools } from "./agent-tools.ts";
39
- import { DELEGATE_TOOL, gatherSubagents, subagentPrompt, subagentTools, type SubagentSpec } from "./subagents.ts";
40
+ import { DELEGATE_TOOL, gatherSubagents, ownSandbox, subagentPrompt, subagentTools, type DelegateSpec, type SubagentSpec } from "./subagents.ts";
41
+ import { DELEGATE_SERVER, buildDelegateTools, delegatePromptLine, delegateToolNames, localDelegateChannel } from "./delegate-tools.ts";
42
+ import type { DelegationResult } from "./operator.ts";
43
+ import type { StepDelegation } from "./store.ts";
40
44
  import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskTool, buildPeerTools, inboxReader, localPeerBus, peersPromptLine, type PeerInfo } from "./operator.ts";
41
45
  import { sendPlainNotification } from "./notify.ts";
42
46
  import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.ts";
@@ -71,7 +75,7 @@ import {
71
75
  checkFormatVersion,
72
76
  readRun,
73
77
  writeRun,
74
- isRescueRecord,
78
+ isSplicedRecord,
75
79
  runSummary,
76
80
  verdictOf,
77
81
  type FlowStep,
@@ -618,6 +622,9 @@ export function buildSubagentSpecs(o: {
618
622
  try {
619
623
  const tags = o.tags ?? [];
620
624
  const own = agentContext(sub.dir, o.tenant, tags, { runId: o.runId, agent: sub.name }, o.flowTimezone, true);
625
+ // sandbox: own — delegated to as a step of its own, not inside this
626
+ // one (buildDelegateSpecs).
627
+ if (ownSandbox(own.front)) continue;
621
628
  const description = typeof own.front.description === "string" ? own.front.description.trim() : "";
622
629
  if (!description) {
623
630
  o.push("error", `subagents: "${sub.name}" has no description — the model picks a sub-agent by it; not delegated to`);
@@ -649,6 +656,60 @@ export function buildSubagentSpecs(o: {
649
656
  return out;
650
657
  }
651
658
 
659
+ /**
660
+ * The `sandbox: own` sub-agents a step may hand jobs to: a name and a
661
+ * description each, all the parent's sandbox needs. What they may hold is
662
+ * read from their own file when a job arrives — never cut to the parent's,
663
+ * and never seen by it. `canRun` is false where nothing can start their
664
+ * sandbox (a sub-agent's own step: depth one; a step outside a run).
665
+ */
666
+ export function buildDelegateSpecs(o: {
667
+ names: unknown;
668
+ self: string;
669
+ workspaceRoot: string;
670
+ canRun: boolean;
671
+ push: (type: "info" | "error", text: string) => void;
672
+ /** Told, per offered sub-agent, whether a tool it holds acts outside the
673
+ * workspace — a delegating step whose job may have sent something must
674
+ * not be re-run as if it had only read (launchStep's retry policy). */
675
+ outward?: (name: string, outward: boolean) => void;
676
+ tenant?: string;
677
+ tags?: string[];
678
+ }): DelegateSpec[] {
679
+ const out: DelegateSpec[] = [];
680
+ for (const sub of gatherSubagents(o.workspaceRoot, o.names).found) {
681
+ if (sub.name === o.self) continue; // buildSubagentSpecs says so
682
+ let front: Record<string, unknown>;
683
+ try {
684
+ front = matter(fs.readFileSync(path.join(sub.dir, "agent.md"), "utf8")).data as Record<string, unknown>;
685
+ } catch {
686
+ continue; // buildSubagentSpecs says so
687
+ }
688
+ if (!ownSandbox(front)) continue;
689
+ const description = typeof front.description === "string" ? front.description.trim() : "";
690
+ if (!description) {
691
+ o.push("error", `subagents: "${sub.name}" has no description — the model picks a sub-agent by it; not delegated to`);
692
+ continue;
693
+ }
694
+ if (!o.canRun) {
695
+ 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`);
696
+ continue;
697
+ }
698
+ if (o.outward) {
699
+ let acts = true; // unreadable is treated as able to act: the safe side
700
+ const tags = o.tags ?? [];
701
+ try {
702
+ acts = agentContext(sub.dir, o.tenant ?? "default", tags, { agent: sub.name }, null, true).outward;
703
+ } catch {
704
+ /* as above */
705
+ }
706
+ o.outward(sub.name, acts);
707
+ }
708
+ out.push({ name: sub.name, description });
709
+ }
710
+ return out;
711
+ }
712
+
652
713
  export function sharedInstructions(agentDir: string, tenant: string): string | null {
653
714
  const sections = [
654
715
  ["Everyone in this account", readAgentsMd(accountDir(tenant))?.body],
@@ -2026,6 +2087,10 @@ async function runStep(
2026
2087
  /** The in-process colleagues' bus for this group (localPeerBus), when
2027
2088
  * the step runs in this process rather than a pod. */
2028
2089
  peerBus?: ReturnType<typeof localPeerBus>,
2090
+ /** Runs one job for a `sandbox: own` sub-agent as a step of its own and
2091
+ * answers with its reply (the drive loop's delegateFor). Absent for a
2092
+ * step that is itself a sub-agent's: depth one. */
2093
+ delegateJob?: (agent: string, job: string, cancelled: () => boolean) => Promise<DelegationResult>,
2029
2094
  ) {
2030
2095
  // Secret values are injected into scripts as environment variables and
2031
2096
  // substituted into API headers, so a model that reads one back — from a
@@ -2046,6 +2111,9 @@ async function runStep(
2046
2111
  let fileDir: string | null = null;
2047
2112
  let installDir: string | null = null;
2048
2113
  let disposeRuntime: (() => void) | undefined;
2114
+ /** The delegate door served for this step (sandbox: own sub-agents), held
2115
+ * here so the finally can close it however the step ends. */
2116
+ let serving: { close(): Promise<void> } | null = null;
2049
2117
  // Can this step's tools act outside the workspace? Set once its context is
2050
2118
  // read; the verdict at the end of this function reads it.
2051
2119
  let canActOutward = false;
@@ -2519,8 +2587,13 @@ async function runStep(
2519
2587
  // Colleagues this agent may DELEGATE to: each with its own context and
2520
2588
  // its own tools — resolved exactly as its own step's would be, then cut
2521
2589
  // to what THIS step holds (subagents.ts). Built host-side, like consults.
2590
+ // A sandbox: own job is a sub-agent already: depth one, so neither kind
2591
+ // of delegation is offered to it.
2592
+ if (step.subagentOf && refNames(front.subagents).length) {
2593
+ push("info", `subagents: not offered — ${step.agent} is running a job for ${step.subagentOf}, and a sub-agent does not delegate again`);
2594
+ }
2522
2595
  const subagents = buildSubagentSpecs({
2523
- names: front.subagents, self: step.agent, workspaceRoot, tenant, tags, runId, flowTimezone,
2596
+ names: step.subagentOf ? [] : front.subagents, self: step.agent, workspaceRoot, tenant, tags, runId, flowTimezone,
2524
2597
  parentAllowed: [...allowed, ...consultNames.map((n) => `mcp__${n}`)], push,
2525
2598
  });
2526
2599
  if (subagents.length) {
@@ -2529,6 +2602,29 @@ async function runStep(
2529
2602
  allowed.push(DELEGATE_TOOL);
2530
2603
  for (const s of subagents) push("info", `subagent: ${s.name} — ${s.tools.length ? s.tools.join(", ") : "no tools"}`);
2531
2604
  }
2605
+ // sandbox: own sub-agents: a delegate_<name> tool each. Their jobs run as
2606
+ // steps of their own (delegate-tools.ts); dropped again below where no
2607
+ // door to the worker exists.
2608
+ let delegates = buildDelegateSpecs({
2609
+ names: step.subagentOf ? [] : front.subagents, self: step.agent, workspaceRoot, canRun: !!delegateJob && stepIndex !== undefined && stepIndex >= 0, push,
2610
+ tenant,
2611
+ tags,
2612
+ outward: (_name, acts) => void (canActOutward ||= acts),
2613
+ });
2614
+ const offerDelegates = (specs: DelegateSpec[]) => {
2615
+ for (const n of delegateToolNames(delegates)) if (allowed.includes(n)) allowed.splice(allowed.indexOf(n), 1);
2616
+ if (consultNames.includes(DELEGATE_SERVER)) consultNames.splice(consultNames.indexOf(DELEGATE_SERVER), 1);
2617
+ delegates = specs;
2618
+ if (!specs.length) return;
2619
+ allowed.push(...delegateToolNames(specs));
2620
+ consultNames.push(DELEGATE_SERVER);
2621
+ };
2622
+ offerDelegates(delegates);
2623
+ const delegateLine = delegates.length ? `\n\n${delegatePromptLine(delegates)}` : "";
2624
+ if (delegates.length) {
2625
+ prompt += delegateLine;
2626
+ for (const d of delegates) push("info", `subagent: ${d.name} — own sandbox, its own tools, secrets and network`);
2627
+ }
2532
2628
 
2533
2629
  // The last provider refusal this step saw, for the fallback decision —
2534
2630
  // watched on both paths, because a refusal reads the same from a pod
@@ -2716,7 +2812,31 @@ async function runStep(
2716
2812
  // colleague's message may be addressed to. Only when granted: a
2717
2813
  // step without the tools has no reason to know its siblings.
2718
2814
  ...(peers && stepIndex !== undefined && stepIndex >= 0 ? { agent: step.agent, peers: siblings } : {}),
2815
+ // sandbox: own sub-agents: the only names the delegate door takes.
2816
+ ...(delegates.length ? { delegates: delegates.map((d) => d.name) } : {}),
2719
2817
  });
2818
+ // Serve those jobs while this step runs: each one becomes a step of
2819
+ // its own (delegateJob), and the door answers the long-poll with its
2820
+ // reply. No proxy, or a platform without the door: not offered — the
2821
+ // tools would only fail.
2822
+ if (delegates.length) {
2823
+ // Also: the executor must be able to stop ONE job — record its
2824
+ // sandbox (the resumable ones checkpoint it) and destroy it — or a job
2825
+ // whose parent stopped waiting would run on, sending and writing.
2826
+ const canStopAJob = !!platform.killSandbox && platform.sandboxResumable(isolation!);
2827
+ if (lease && platform.egress.delegations && delegateJob && stepIndex !== undefined && canStopAJob) {
2828
+ serving = platform.egress.delegations(
2829
+ // Re-attaching to a sandbox that outlived its driver: what it
2830
+ // queued before the restart is still its own, not a dead attempt's.
2831
+ { tenant, runId: runId ?? "adhoc", step: stepIndex, resuming: !!step.sandbox && step.sandbox.kind === isolation },
2832
+ (j) => delegateJob(j.agent, j.job, j.cancelled ?? (() => false)),
2833
+ );
2834
+ } else {
2835
+ 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`);
2836
+ offerDelegates([]);
2837
+ prompt = prompt.replace(delegateLine, "");
2838
+ }
2839
+ }
2720
2840
  // The forward door: every program in the sandbox sends through the
2721
2841
  // proxy, which holds the site list and fills bound secrets. A proxy
2722
2842
  // without the door (an older platform) leaves programs going direct.
@@ -2892,6 +3012,7 @@ async function runStep(
2892
3012
  consults,
2893
3013
  ...(peers ? { peers: { self: step.agent, siblings } } : {}),
2894
3014
  ...(subagents.length ? { subagents } : {}),
3015
+ ...(delegates.length ? { delegates } : {}),
2895
3016
  timeoutSec: step.timeout,
2896
3017
  budgetUsd: stepBudgetUsd,
2897
3018
  budgetNote: stepBudgetNote,
@@ -3114,6 +3235,7 @@ async function runStep(
3114
3235
  // to decide; a second go could send or charge twice.
3115
3236
  step.image = outcome.image ?? null;
3116
3237
  step.browserPod = outcome.browserPod ? { ...outcome.browserPod } : null;
3238
+ if (step.subagentOf && outcome.applied?.length) step.wrote = [...new Set([...(step.wrote ?? []), ...outcome.applied])];
3117
3239
  if (outcome.status === "failed" && outcome.browserPod?.lost) {
3118
3240
  const decision = podLossDecision(outcome.browserPod);
3119
3241
  if (decision.rerun) {
@@ -3158,6 +3280,8 @@ async function runStep(
3158
3280
  if (typeof line === "string") pushWatching("info", line);
3159
3281
  else pushWatching("info", line.text, { effect: line.effect });
3160
3282
  }
3283
+ await serving?.close();
3284
+ serving = null;
3161
3285
  await lease.release();
3162
3286
  }
3163
3287
  // Nothing a test run left in storage/public/ reached storage/, so
@@ -3255,6 +3379,16 @@ async function runStep(
3255
3379
  const peerChannel = peerBus && stepIndex !== undefined && stepIndex >= 0 ? peerBus.channelFor({ agent: step.agent, step: stepIndex }, siblings) : null;
3256
3380
  const peerTools = peers ? buildPeerTools({ channel: peerChannel, self: step.agent, siblings, emit: push }) : null;
3257
3381
  const localInbox = peerChannel ? inboxReader(peerChannel, push) : undefined;
3382
+ // sandbox: own sub-agents, here: no proxy between the step and this
3383
+ // process, so the job is handed to the drive loop directly.
3384
+ const delegateTools = delegates.length && delegateJob
3385
+ ? buildDelegateTools({ specs: delegates, channel: localDelegateChannel(delegateJob), emit: push, timeoutSec: step.timeout })
3386
+ : null;
3387
+ // Said, not hidden: on this machine no step has a sandbox, so a job is
3388
+ // its own context, tools and secrets — but the same files and network.
3389
+ if (delegateTools?.server) {
3390
+ 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)`);
3391
+ }
3258
3392
  // @file secrets become 0600 paths here (host run), cleaned up in the
3259
3393
  // finally — once, however many attempts follow.
3260
3394
  const mat = materializeFileSecrets(agentDir, stepSecrets);
@@ -3296,6 +3430,7 @@ async function runStep(
3296
3430
  ...(deskTools.server ? { foldrun_desks: deskTools.server } : {}),
3297
3431
  ...(askTools ? { foldrun_ask: askTools.server } : {}),
3298
3432
  ...(peerTools ? { foldrun_peers: peerTools.server } : {}),
3433
+ ...(delegateTools?.server ? { foldrun_delegate: delegateTools.server } : {}),
3299
3434
  ...(installTools ? { foldrun_install: installTools.server } : {}),
3300
3435
  ...mcpServers,
3301
3436
  },
@@ -3414,6 +3549,15 @@ async function runStep(
3414
3549
  step.status = "failed";
3415
3550
  push("error", err instanceof Error ? err.message : String(err));
3416
3551
  } finally {
3552
+ // The delegate door's service, when the step ended by throwing before
3553
+ // its lease was released: its jobs are settled, not left hanging.
3554
+ if (serving) {
3555
+ try {
3556
+ await serving.close();
3557
+ } catch {
3558
+ // best-effort — the door's records expire on their own
3559
+ }
3560
+ }
3417
3561
  try {
3418
3562
  cleanupFileSecrets(fileDir);
3419
3563
  if (installDir) fs.rmSync(installDir, { recursive: true, force: true });
@@ -4255,6 +4399,29 @@ function driveRunInner(
4255
4399
  // last driver got, runStep passes that as `resume`, and the step
4256
4400
  // carries on from where it was — a deploy no longer costs the run.
4257
4401
  let orphaned = false;
4402
+ // A sandbox: own job whose driver went away is over: the reply it was
4403
+ // making went to a process that no longer exists, and the step that
4404
+ // asked is told it failed (the platform settles the door's side), so
4405
+ // its model can hand the job over again. Never resumed or relaunched.
4406
+ for (const step of run.steps) {
4407
+ if (!step.subagentOf || (step.status !== "running" && step.status !== "pending")) continue;
4408
+ // Its sandbox may still be executing — outward work included — while
4409
+ // the parent hands the job over again: destroy it before letting go
4410
+ // of the only reference to it.
4411
+ if (step.sandbox?.ref) {
4412
+ try {
4413
+ platform.killSandbox?.(step.sandbox);
4414
+ } catch {
4415
+ // a sandbox we cannot reach must not stop the run from resuming
4416
+ }
4417
+ }
4418
+ step.status = "failed";
4419
+ step.sandbox = null;
4420
+ 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" });
4421
+ const parent = run.steps.find((s) => s.delegations?.some((d) => d.id === step.delegation));
4422
+ const entry = parent?.delegations?.find((d) => d.id === step.delegation);
4423
+ if (entry && entry.status === "running") entry.status = "failed";
4424
+ }
4258
4425
  for (const step of run.steps) {
4259
4426
  if (step.status !== "running") continue;
4260
4427
  const resumable = !!step.sandbox && platform.sandboxResumable(step.sandbox.kind);
@@ -4392,7 +4559,7 @@ function driveRunInner(
4392
4559
  // three handoffs below read the flow's steps only: counting the rescue
4393
4560
  // too handed the next group the same output twice, or a stale one
4394
4561
  // from an evaluator loop's earlier cycle.
4395
- const flowStepsOf = (group: StepRecord[]) => group.filter((s) => !isRescueRecord(s));
4562
+ const flowStepsOf = (group: StepRecord[]) => group.filter((s) => !isSplicedRecord(s));
4396
4563
  const dataOf = (all: StepRecord[]): unknown => {
4397
4564
  const group = flowStepsOf(all);
4398
4565
  const carrying = group.filter((s) => s.status === "completed" && s.data !== undefined);
@@ -4803,7 +4970,7 @@ function driveRunInner(
4803
4970
  save();
4804
4971
  if (freshGroup.every((s) => s.status !== "pending")) {
4805
4972
  // everything in this group was rejected
4806
- if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s))) {
4973
+ if (freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isSplicedRecord(s))) {
4807
4974
  run.status = "failed";
4808
4975
  break;
4809
4976
  }
@@ -4821,7 +4988,7 @@ function driveRunInner(
4821
4988
  // of the flow: one left pending by a drive that died is picked up by
4822
4989
  // rescueStep below, never launched alongside the group (21 Oct 2026:
4823
4990
  // a resumed run launched 77 leftover fixers at once).
4824
- const launching = freshGroup.filter((s) => s.status === "pending" && !isRescueRecord(s));
4991
+ const launching = freshGroup.filter((s) => s.status === "pending" && !isSplicedRecord(s));
4825
4992
  // tools: [peers] — what each step of the group is told about the
4826
4993
  // others, and the bus they share when they run in this process.
4827
4994
  const peerBus = localPeerBus();
@@ -4829,6 +4996,117 @@ function driveRunInner(
4829
4996
  launching
4830
4997
  .filter((s) => s !== step)
4831
4998
  .map((s) => ({ agent: s.agent, step: run.steps.indexOf(s), instruction: (s.instruction ?? "").split("\n")[0].trim().slice(0, 200) }));
4999
+ // sandbox: own sub-agents (delegate-tools.ts): one job, run as a step
5000
+ // of its own — its own sandbox, tools, secrets and network, from its
5001
+ // own file — and answered with its reply. The record is APPENDED to
5002
+ // run.steps, in the parent's group: every running step's lease and
5003
+ // inbox are addressed by index, so nothing may shift under them.
5004
+ // Marked subagentOf, it is never launched, handed on or reset as a
5005
+ // step of the flow (isSplicedRecord). It gets no delegateJob of its
5006
+ // own: depth one.
5007
+ const delegateFor = (parent: StepRecord) => async (agent: string, job: string, cancelled: () => boolean): Promise<DelegationResult> => {
5008
+ const dir = path.join(pDir, "agents", agent);
5009
+ if (!fs.existsSync(path.join(dir, "agent.md"))) return { status: "failed", result: `${agent} is not an agent in this workspace` };
5010
+ // A stable id on both records: an index into run.steps moves when a
5011
+ // rescue or a fan-out is spliced in before the child.
5012
+ const id = `d_${crypto.randomBytes(5).toString("hex")}`;
5013
+ const child: StepRecord = {
5014
+ agent,
5015
+ subagentOf: parent.agent,
5016
+ delegation: id,
5017
+ instruction: job,
5018
+ group: parent.group,
5019
+ optional: true,
5020
+ timeout: parent.timeout,
5021
+ attempts: 1,
5022
+ status: "running",
5023
+ events: [],
5024
+ result: null,
5025
+ costUsd: null,
5026
+ };
5027
+ run.steps.push(child);
5028
+ const at = run.steps.length - 1;
5029
+ const entry: StepDelegation = { id, agent, status: "running" };
5030
+ (parent.delegations ??= []).push(entry);
5031
+ const started = Date.now();
5032
+ // The same budget rules as a step of the flow: nothing left means
5033
+ // not started (a zero ceiling would read as no cap at all), and
5034
+ // what it may spend is reserved while it runs, so two jobs at once
5035
+ // do not each get the agent's whole remaining budget.
5036
+ // The flow's budget: what is left after what has been spent AND
5037
+ // what every step and job running now holds — the parent's own
5038
+ // ceiling included. ceilingFor's equal share would hand the job a
5039
+ // second copy of an allowance its parent already holds.
5040
+ const held = [...reserved.values()].reduce((a, b) => a + b, 0);
5041
+ const flowLeft = run.budgetUsd && run.budgetUsd > 0 ? Math.max(0, run.budgetUsd - platform.runSpend(run) - held) : null;
5042
+ const ceiling = stepCeilingFor(
5043
+ flowLeft,
5044
+ agentBudgets.get(agent),
5045
+ run.steps
5046
+ .filter((s) => s.agent === agent)
5047
+ .reduce((sum, s) => sum + (s.costUsd ?? 0) + (s !== child && s.status === "running" ? (reserved.get(s) ?? 0) : 0), 0),
5048
+ agent,
5049
+ 1,
5050
+ );
5051
+ if (ceiling.ceilingUsd === 0) {
5052
+ child.status = "failed";
5053
+ 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` });
5054
+ entry.status = "failed";
5055
+ entry.ms = 0;
5056
+ save();
5057
+ return { status: "failed", result: `${agent} was not started: over budget (${ceiling.note})`, wrote: [] };
5058
+ }
5059
+ if (ceiling.ceilingUsd !== null) reserved.set(child, ceiling.ceilingUsd);
5060
+ save();
5061
+ // A running sandbox does not hear a stop flag: when the parent stops
5062
+ // waiting (its tool timed out, or its step ended), destroy the job's
5063
+ // sandbox, so nothing it does outlives the step that asked for it.
5064
+ const parentGone = () => run.stopRequested === true || cancelled() || (parent.status !== "running" && parent.status !== "pending");
5065
+ let killedIt = false;
5066
+ const watch = setInterval(() => {
5067
+ if (killedIt || !parentGone() || !child.sandbox?.ref) return;
5068
+ killedIt = true;
5069
+ child.events.push({ t: new Date().toISOString(), type: "error", text: "stopped — the step that asked for this job stopped waiting for it" });
5070
+ try {
5071
+ platform.killSandbox?.(child.sandbox);
5072
+ } catch {
5073
+ // its own deadline ends it
5074
+ }
5075
+ }, 2_000);
5076
+ watch.unref?.();
5077
+ try {
5078
+ await runStep(
5079
+ dir, tenant, child, null, save, modelOverride, tags, effortOverride, run.id, undefined,
5080
+ ceiling.ceilingUsd, ceiling.note, run.test === true,
5081
+ () => {
5082
+ save();
5083
+ // The parent's step ending or cancelling ends the job too —
5084
+ // here while it waits for room; the watch above once it runs.
5085
+ return parentGone();
5086
+ },
5087
+ flowTimezone, at,
5088
+ );
5089
+ } catch (err) {
5090
+ child.status = "failed";
5091
+ child.events.push({ t: new Date().toISOString(), type: "error", text: err instanceof Error ? err.message : String(err) });
5092
+ } finally {
5093
+ clearInterval(watch);
5094
+ reserved.delete(child);
5095
+ }
5096
+ // runStep mutates child.status; widen past TS's "running" narrowing.
5097
+ const ended: string = child.status;
5098
+ const status = ended === "completed" ? "completed" : ended === "skipped" ? "skipped" : "failed";
5099
+ entry.status = status;
5100
+ entry.costUsd = child.costUsd ?? null;
5101
+ entry.ms = Date.now() - started;
5102
+ save();
5103
+ const errors = child.events.filter((e) => e.type === "error").map((e) => e.text).join("\n");
5104
+ return {
5105
+ status,
5106
+ result: child.result ?? (errors || "(no reply)"),
5107
+ wrote: child.wrote ?? [],
5108
+ };
5109
+ };
4832
5110
  // An agent's own `budget:` — the most it may spend in one run — is
4833
5111
  // read at launch, so a cap raised mid-run applies to the next step.
4834
5112
  const agentBudgets = new Map(listAgents(tenant, workspace).map((a) => [a.name, a.budget]));
@@ -4972,6 +5250,7 @@ function driveRunInner(
4972
5250
  run.steps.indexOf(step),
4973
5251
  siblingsOf(step),
4974
5252
  peerBus,
5253
+ delegateFor(step),
4975
5254
  ).finally(() => reserved.delete(step));
4976
5255
  // runStep mutates step.status; read it through a widened local
4977
5256
  // so TS doesn't keep the "running" narrowing from above.
@@ -5095,8 +5374,9 @@ function driveRunInner(
5095
5374
  // answered an earlier failure — an evaluator loop's previous cycle
5096
5375
  // — not this one.
5097
5376
  const leftovers: StepRecord[] = [];
5098
- for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isRescueRecord(run.steps[j]); j++) {
5377
+ for (let j = run.steps.indexOf(step) + 1; j < run.steps.length && isSplicedRecord(run.steps[j]); j++) {
5099
5378
  const r = run.steps[j];
5379
+ if (r.subagentOf) continue;
5100
5380
  if (r.agent === step.onFail && (r.rescueOf === undefined || r.rescueOf === step.agent) && (r.status === "pending" || r.status === "running")) {
5101
5381
  leftovers.push(r);
5102
5382
  }
@@ -5202,7 +5482,7 @@ function driveRunInner(
5202
5482
  // A rescue record's own failure is already on the step it took over
5203
5483
  // ("also failed"); counted here it would fail a group whose step a
5204
5484
  // later rescue completed.
5205
- const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isRescueRecord(s));
5485
+ const requiredFailed = freshGroup.some((s) => s.status === "failed" && !s.optional && !s.carriedFrom && !isSplicedRecord(s));
5206
5486
  if (requiredFailed) {
5207
5487
  run.status = "failed";
5208
5488
  break;
@@ -5281,7 +5561,7 @@ function driveRunInner(
5281
5561
  // A rescue answered its own failure and stays as it ended: reset
5282
5562
  // to pending it read as an interrupted rescue, and the next
5283
5563
  // cycle's failure was handed the old one's instructions.
5284
- if (isRescueRecord(s)) continue;
5564
+ if (isSplicedRecord(s)) continue;
5285
5565
  s.status = "pending";
5286
5566
  s.result = null;
5287
5567
  }
@@ -5675,6 +5955,8 @@ export async function waitForRun(
5675
5955
  */
5676
5956
  export function runResult(run: RunRecord): string | null {
5677
5957
  for (let i = run.steps.length - 1; i >= 0; i--) {
5958
+ // A sandbox: own job's reply went to the step that asked (runSummary).
5959
+ if (run.steps[i].subagentOf) continue;
5678
5960
  const r = run.steps[i].result;
5679
5961
  if (r && r.trim()) return r;
5680
5962
  }
package/src/scan.ts CHANGED
@@ -133,7 +133,7 @@ export const SCAN_RULES: readonly ScanRule[] = [
133
133
  help:
134
134
  "An agent with all three can be told — by any page it reads — to send what it holds anywhere it can reach. " +
135
135
  "Take one leg away: take it off the open network (`network: { level: trusted, allow: [the sites it needs] }` — a bare list only adds sites, " +
136
- "and an agent that reads the web stays open), split the reading and the sending into two agents, " +
136
+ "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\`), " +
137
137
  "or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
138
138
  owasp: ["ASI01", "ASI02", "ASI03"],
139
139
  },
@@ -147,6 +147,17 @@ export const SCAN_RULES: readonly ScanRule[] = [
147
147
  "and if that rule ever changed, they would be a privilege escalation. Grant them to the parent or remove them.",
148
148
  owasp: ["ASI03"],
149
149
  },
150
+ {
151
+ id: "security/subagent-own-sandbox",
152
+ category: "security",
153
+ level: "info",
154
+ title: "A sub-agent works in its own sandbox, with tools its parent does not have",
155
+ help:
156
+ "`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 — " +
157
+ "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), " +
158
+ "so this only says what it holds. Only its reply reaches the parent.",
159
+ owasp: ["ASI03"],
160
+ },
150
161
  {
151
162
  id: "supply-chain/pipe-to-shell",
152
163
  category: "supply-chain",
@@ -725,6 +736,13 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
725
736
  const sub = byName.get(s);
726
737
  if (!sub || s === a.name) continue;
727
738
  const extra = refNames(sub.data.tools).filter((t) => !tools.includes(t));
739
+ if (sub.data.sandbox === "own") {
740
+ if (extra.length) {
741
+ push("security/subagent-own-sandbox", a.file, keyLine(a.lines, "subagents"), `subagent ${s} own ${extra.join(",")}`,
742
+ `subagents: [${s}] — ${s} runs in its own sandbox with ${extra.join(", ")}, which ${a.name} does not have; only its reply comes back`);
743
+ }
744
+ continue;
745
+ }
728
746
  if (extra.length && tools.length) {
729
747
  push("security/subagent-wider", a.file, keyLine(a.lines, "subagents"), `subagent ${s} ${extra.join(",")}`,
730
748
  `subagents: [${s}] — ${s} declares ${extra.join(", ")}, which ${a.name} does not have; inside the delegation they are dropped`);
package/src/starter.ts CHANGED
@@ -494,6 +494,7 @@ apis: [{ name: crm }] # an HTTP API, as a tool
494
494
  mcpServers: {} # an MCP server, as a tool
495
495
  agents: [writer] # colleagues this one may consult (an answer, no tools)
496
496
  subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
497
+ sandbox: own # delegated to: run each job in its own sandbox (own tools, secrets, network)
497
498
  secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
498
499
  limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
499
500
  network: [api.example.com] # sites its sandbox may reach, added to the trusted list; open | trusted | none
package/src/store.ts CHANGED
@@ -981,6 +981,8 @@ export interface AgentInfo {
981
981
  /** Colleagues this agent may DELEGATE to (`subagents:`) — each runs with
982
982
  * its own context and its own tools, no wider than this agent's. */
983
983
  subagents: string[];
984
+ /** `sandbox: own` — delegated to, it runs each job in its own sandbox. */
985
+ sandbox?: "own";
984
986
  /** The `skills:` allowlist, or null when the field is absent — which is
985
987
  * not the same thing: absent inherits every skill in scope, an empty
986
988
  * list withholds all of them. */
@@ -1633,6 +1635,7 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
1633
1635
  retiredTools: retiredToolNames(data),
1634
1636
  consults: refNames(data.agents),
1635
1637
  subagents: refNames(data.subagents),
1638
+ ...(data.sandbox === "own" ? { sandbox: "own" as const } : {}),
1636
1639
  skills: data.skills === undefined ? null : refNames(data.skills),
1637
1640
  secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
1638
1641
  budget: runBudget(data.budget),
@@ -3294,6 +3297,17 @@ export function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" |
3294
3297
  export { type TokenUsage, type PromptLayerRecord, tokensOf, sumTokens, cacheHitRate, measuredHitRate, runTokens, promptLayersChanged, promptCacheNote } from "./tokens.ts";
3295
3298
  import { sumTokens, type TokenUsage, type PromptLayerRecord } from "./tokens.ts";
3296
3299
 
3300
+ /** One job a step handed to an isolated sub-agent. `id` is the job's own
3301
+ * record's `delegation` — not an index, which moves when records are
3302
+ * spliced in before it. */
3303
+ export interface StepDelegation {
3304
+ id: string;
3305
+ agent: string;
3306
+ status: "running" | "completed" | "failed" | "skipped";
3307
+ costUsd?: number | null;
3308
+ ms?: number | null;
3309
+ }
3310
+
3297
3311
  /** The marker rescueStep puts in a rescue's instruction; how a rescue record
3298
3312
  * written before `rescueOf` existed is still recognised. */
3299
3313
  export const RESCUE_MARKER = "<rescue>\nYou are taking this step over:";
@@ -3303,6 +3317,14 @@ export function isRescueRecord(step: { rescueOf?: string; instruction?: string |
3303
3317
  return !!step.rescueOf || (step.instruction ?? "").includes(RESCUE_MARKER);
3304
3318
  }
3305
3319
 
3320
+ /** Is this record put in by the runner rather than by the flow — an on-fail
3321
+ * rescue, or an isolated sub-agent's run (`subagentOf`)? Neither is launched
3322
+ * with a group, handed on as a group's output, reset by a loop, or copied by
3323
+ * a re-run as work. */
3324
+ export function isSplicedRecord(step: { rescueOf?: string; subagentOf?: string; instruction?: string | null }): boolean {
3325
+ return !!step.subagentOf || isRescueRecord(step);
3326
+ }
3327
+
3306
3328
  export interface StepRecord {
3307
3329
  agent: string;
3308
3330
  instruction: string;
@@ -3463,6 +3485,19 @@ export interface StepRecord {
3463
3485
  * steps). Records written before this field are recognised by their
3464
3486
  * instruction's `<rescue>` block instead — see isRescueRecord. */
3465
3487
  rescueOf?: string;
3488
+ /** Set on an isolated sub-agent's run (`sandbox: own`, delegate-tools.ts):
3489
+ * the agent of the step that delegated to it. It ran in its own sandbox
3490
+ * inside that step; only its reply went back. Like a rescue it is not a
3491
+ * step of the flow — see isSplicedRecord. */
3492
+ subagentOf?: string;
3493
+ /** On an isolated sub-agent's run: its job's id, as the delegating step's
3494
+ * `delegations[].id` names it. */
3495
+ delegation?: string;
3496
+ /** On a step that delegated to isolated sub-agents: each job, in order. */
3497
+ delegations?: StepDelegation[];
3498
+ /** On an isolated sub-agent's run: the workspace paths its sandbox's
3499
+ * changes were applied to — what its delegating step is told it wrote. */
3500
+ wrote?: string[];
3466
3501
  /**
3467
3502
  * Sandbox seconds this step rented, and how many of them were cold start.
3468
3503
  * Set only on the isolated path — an in-process step rents nothing, so
@@ -4254,6 +4289,9 @@ export function runVerdict(run: Pick<RunRecord, "verdict" | "summary">): Verdict
4254
4289
 
4255
4290
  export function runSummary(run: RunRecord): string | null {
4256
4291
  for (let i = run.steps.length - 1; i >= 0; i--) {
4292
+ // A sandbox: own job is appended after every step of the flow; its reply
4293
+ // went to the step that asked, which speaks for the run.
4294
+ if (run.steps[i].subagentOf) continue;
4257
4295
  const line = stepHeadline(run.steps[i]);
4258
4296
  if (line) return line;
4259
4297
  }
package/src/subagents.ts CHANGED
@@ -42,6 +42,25 @@ export interface SubagentSpec {
42
42
  model: string;
43
43
  }
44
44
 
45
+ /** An isolated sub-agent (`sandbox: own` on its own agent.md): what the
46
+ * parent's sandbox needs to offer it as a tool. Everything else — its tools,
47
+ * secrets, network, size — is resolved on the worker when the job arrives,
48
+ * from its own file, and never enters the parent's sandbox. */
49
+ export interface DelegateSpec {
50
+ name: string;
51
+ description: string;
52
+ }
53
+
54
+ /** Does this agent run in its own sandbox when delegated to? */
55
+ export function ownSandbox(front: { sandbox?: unknown }): boolean {
56
+ return front.sandbox === "own";
57
+ }
58
+
59
+ /** The tool the parent calls to hand an isolated sub-agent a job. */
60
+ export function delegateToolName(agent: string): string {
61
+ return `delegate_${agent.replaceAll("-", "_")}`;
62
+ }
63
+
45
64
  /** The SDK's delegation tool, under both names it has had. Never granted to a
46
65
  * sub-agent: that is what makes the depth one. */
47
66
  export const DELEGATE_TOOLS = ["Agent", "Task"] as const;