@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.
- package/dist/src/delegate-tools.d.ts +45 -0
- package/dist/src/delegate-tools.js +153 -0
- package/dist/src/egress.d.ts +27 -2
- package/dist/src/frontmatter-fields.js +2 -1
- package/dist/src/issues.js +4 -1
- package/dist/src/oauth-presets.d.ts +7 -0
- package/dist/src/oauth-presets.js +7 -0
- package/dist/src/operator.d.ts +19 -0
- package/dist/src/operator.js +29 -0
- package/dist/src/platform.d.ts +8 -0
- package/dist/src/run-container.d.ts +15 -5
- package/dist/src/run-container.js +13 -4
- package/dist/src/runner.d.ts +26 -1
- package/dist/src/runner.js +345 -12
- package/dist/src/scan.js +17 -1
- package/dist/src/starter.js +1 -0
- package/dist/src/store.d.ts +34 -0
- package/dist/src/store.js +12 -0
- package/dist/src/subagents.d.ts +14 -0
- package/dist/src/subagents.js +8 -0
- package/package.json +1 -1
- package/src/delegate-tools.ts +175 -0
- package/src/egress.ts +24 -3
- package/src/frontmatter-fields.ts +2 -1
- package/src/issues.ts +4 -1
- package/src/oauth-presets.ts +7 -0
- package/src/operator.ts +52 -0
- package/src/platform.ts +5 -0
- package/src/run-container.ts +21 -5
- package/src/runner.ts +349 -10
- package/src/scan.ts +19 -1
- package/src/starter.ts +1 -0
- package/src/store.ts +38 -0
- package/src/subagents.ts +19 -0
package/dist/src/store.d.ts
CHANGED
|
@@ -311,6 +311,8 @@ export interface AgentInfo {
|
|
|
311
311
|
/** Colleagues this agent may DELEGATE to (`subagents:`) — each runs with
|
|
312
312
|
* its own context and its own tools, no wider than this agent's. */
|
|
313
313
|
subagents: string[];
|
|
314
|
+
/** `sandbox: own` — delegated to, it runs each job in its own sandbox. */
|
|
315
|
+
sandbox?: "own";
|
|
314
316
|
/** The `skills:` allowlist, or null when the field is absent — which is
|
|
315
317
|
* not the same thing: absent inherits every skill in scope, an empty
|
|
316
318
|
* list withholds all of them. */
|
|
@@ -1068,6 +1070,16 @@ export interface StepAttempt {
|
|
|
1068
1070
|
export declare function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs" | "turns">;
|
|
1069
1071
|
export { type TokenUsage, type PromptLayerRecord, tokensOf, sumTokens, cacheHitRate, measuredHitRate, runTokens, promptLayersChanged, promptCacheNote } from "./tokens.ts";
|
|
1070
1072
|
import { type TokenUsage, type PromptLayerRecord } from "./tokens.ts";
|
|
1073
|
+
/** One job a step handed to an isolated sub-agent. `id` is the job's own
|
|
1074
|
+
* record's `delegation` — not an index, which moves when records are
|
|
1075
|
+
* spliced in before it. */
|
|
1076
|
+
export interface StepDelegation {
|
|
1077
|
+
id: string;
|
|
1078
|
+
agent: string;
|
|
1079
|
+
status: "running" | "completed" | "failed" | "skipped";
|
|
1080
|
+
costUsd?: number | null;
|
|
1081
|
+
ms?: number | null;
|
|
1082
|
+
}
|
|
1071
1083
|
/** The marker rescueStep puts in a rescue's instruction; how a rescue record
|
|
1072
1084
|
* written before `rescueOf` existed is still recognised. */
|
|
1073
1085
|
export declare const RESCUE_MARKER = "<rescue>\nYou are taking this step over:";
|
|
@@ -1076,6 +1088,15 @@ export declare function isRescueRecord(step: {
|
|
|
1076
1088
|
rescueOf?: string;
|
|
1077
1089
|
instruction?: string | null;
|
|
1078
1090
|
}): boolean;
|
|
1091
|
+
/** Is this record put in by the runner rather than by the flow — an on-fail
|
|
1092
|
+
* rescue, or an isolated sub-agent's run (`subagentOf`)? Neither is launched
|
|
1093
|
+
* with a group, handed on as a group's output, reset by a loop, or copied by
|
|
1094
|
+
* a re-run as work. */
|
|
1095
|
+
export declare function isSplicedRecord(step: {
|
|
1096
|
+
rescueOf?: string;
|
|
1097
|
+
subagentOf?: string;
|
|
1098
|
+
instruction?: string | null;
|
|
1099
|
+
}): boolean;
|
|
1079
1100
|
export interface StepRecord {
|
|
1080
1101
|
agent: string;
|
|
1081
1102
|
instruction: string;
|
|
@@ -1242,6 +1263,19 @@ export interface StepRecord {
|
|
|
1242
1263
|
* steps). Records written before this field are recognised by their
|
|
1243
1264
|
* instruction's `<rescue>` block instead — see isRescueRecord. */
|
|
1244
1265
|
rescueOf?: string;
|
|
1266
|
+
/** Set on an isolated sub-agent's run (`sandbox: own`, delegate-tools.ts):
|
|
1267
|
+
* the agent of the step that delegated to it. It ran in its own sandbox
|
|
1268
|
+
* inside that step; only its reply went back. Like a rescue it is not a
|
|
1269
|
+
* step of the flow — see isSplicedRecord. */
|
|
1270
|
+
subagentOf?: string;
|
|
1271
|
+
/** On an isolated sub-agent's run: its job's id, as the delegating step's
|
|
1272
|
+
* `delegations[].id` names it. */
|
|
1273
|
+
delegation?: string;
|
|
1274
|
+
/** On a step that delegated to isolated sub-agents: each job, in order. */
|
|
1275
|
+
delegations?: StepDelegation[];
|
|
1276
|
+
/** On an isolated sub-agent's run: the workspace paths its sandbox's
|
|
1277
|
+
* changes were applied to — what its delegating step is told it wrote. */
|
|
1278
|
+
wrote?: string[];
|
|
1245
1279
|
/**
|
|
1246
1280
|
* Sandbox seconds this step rented, and how many of them were cold start.
|
|
1247
1281
|
* Set only on the isolated path — an in-process step rents nothing, so
|
package/dist/src/store.js
CHANGED
|
@@ -1163,6 +1163,7 @@ export function listAgents(tenant, workspace) {
|
|
|
1163
1163
|
retiredTools: retiredToolNames(data),
|
|
1164
1164
|
consults: refNames(data.agents),
|
|
1165
1165
|
subagents: refNames(data.subagents),
|
|
1166
|
+
...(data.sandbox === "own" ? { sandbox: "own" } : {}),
|
|
1166
1167
|
skills: data.skills === undefined ? null : refNames(data.skills),
|
|
1167
1168
|
secrets: Array.isArray(data.secrets) ? data.secrets.map(String) : [],
|
|
1168
1169
|
budget: runBudget(data.budget),
|
|
@@ -2662,6 +2663,13 @@ export const RESCUE_MARKER = "<rescue>\nYou are taking this step over:";
|
|
|
2662
2663
|
export function isRescueRecord(step) {
|
|
2663
2664
|
return !!step.rescueOf || (step.instruction ?? "").includes(RESCUE_MARKER);
|
|
2664
2665
|
}
|
|
2666
|
+
/** Is this record put in by the runner rather than by the flow — an on-fail
|
|
2667
|
+
* rescue, or an isolated sub-agent's run (`subagentOf`)? Neither is launched
|
|
2668
|
+
* with a group, handed on as a group's output, reset by a loop, or copied by
|
|
2669
|
+
* a re-run as work. */
|
|
2670
|
+
export function isSplicedRecord(step) {
|
|
2671
|
+
return !!step.subagentOf || isRescueRecord(step);
|
|
2672
|
+
}
|
|
2665
2673
|
// ---------------------------------------------------------------- captured calls
|
|
2666
2674
|
//
|
|
2667
2675
|
// What each tool call took and gave (FOL-54 step 4), beside the run under
|
|
@@ -3218,6 +3226,10 @@ export function runVerdict(run) {
|
|
|
3218
3226
|
}
|
|
3219
3227
|
export function runSummary(run) {
|
|
3220
3228
|
for (let i = run.steps.length - 1; i >= 0; i--) {
|
|
3229
|
+
// A sandbox: own job is appended after every step of the flow; its reply
|
|
3230
|
+
// went to the step that asked, which speaks for the run.
|
|
3231
|
+
if (run.steps[i].subagentOf)
|
|
3232
|
+
continue;
|
|
3221
3233
|
const line = stepHeadline(run.steps[i]);
|
|
3222
3234
|
if (line)
|
|
3223
3235
|
return line;
|
package/dist/src/subagents.d.ts
CHANGED
|
@@ -10,6 +10,20 @@ export interface SubagentSpec {
|
|
|
10
10
|
* the step's own) or "inherit". */
|
|
11
11
|
model: string;
|
|
12
12
|
}
|
|
13
|
+
/** An isolated sub-agent (`sandbox: own` on its own agent.md): what the
|
|
14
|
+
* parent's sandbox needs to offer it as a tool. Everything else — its tools,
|
|
15
|
+
* secrets, network, size — is resolved on the worker when the job arrives,
|
|
16
|
+
* from its own file, and never enters the parent's sandbox. */
|
|
17
|
+
export interface DelegateSpec {
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
}
|
|
21
|
+
/** Does this agent run in its own sandbox when delegated to? */
|
|
22
|
+
export declare function ownSandbox(front: {
|
|
23
|
+
sandbox?: unknown;
|
|
24
|
+
}): boolean;
|
|
25
|
+
/** The tool the parent calls to hand an isolated sub-agent a job. */
|
|
26
|
+
export declare function delegateToolName(agent: string): string;
|
|
13
27
|
/** The SDK's delegation tool, under both names it has had. Never granted to a
|
|
14
28
|
* sub-agent: that is what makes the depth one. */
|
|
15
29
|
export declare const DELEGATE_TOOLS: readonly ["Agent", "Task"];
|
package/dist/src/subagents.js
CHANGED
|
@@ -27,6 +27,14 @@ import fs from "node:fs";
|
|
|
27
27
|
import path from "node:path";
|
|
28
28
|
import { refNames } from "./refs.js";
|
|
29
29
|
import { TOOL_MAP } from "./tool-names.js";
|
|
30
|
+
/** Does this agent run in its own sandbox when delegated to? */
|
|
31
|
+
export function ownSandbox(front) {
|
|
32
|
+
return front.sandbox === "own";
|
|
33
|
+
}
|
|
34
|
+
/** The tool the parent calls to hand an isolated sub-agent a job. */
|
|
35
|
+
export function delegateToolName(agent) {
|
|
36
|
+
return `delegate_${agent.replaceAll("-", "_")}`;
|
|
37
|
+
}
|
|
30
38
|
/** The SDK's delegation tool, under both names it has had. Never granted to a
|
|
31
39
|
* sub-agent: that is what makes the depth one. */
|
|
32
40
|
export const DELEGATE_TOOLS = ["Agent", "Task"];
|
package/package.json
CHANGED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Isolated sub-agents: a colleague that does a job in ITS OWN sandbox.
|
|
2
|
+
//
|
|
3
|
+
// subagents: [reader] # on the delegating agent, unchanged
|
|
4
|
+
// sandbox: own # on reader's own agent.md
|
|
5
|
+
//
|
|
6
|
+
// A sub-agent delegated to the ordinary way (subagents.ts) runs inside the
|
|
7
|
+
// parent's SDK session: the parent's pod, secrets, lease and network, its
|
|
8
|
+
// tools cut to the parent's. That protects the parent's context and nothing
|
|
9
|
+
// else — an injected page read by the sub-agent is read in a sandbox the
|
|
10
|
+
// parent's secrets are in.
|
|
11
|
+
//
|
|
12
|
+
// An agent marked `sandbox: own` is instead offered to the parent as a tool,
|
|
13
|
+
// `delegate_<name>(job)`. The call leaves the parent's sandbox through the
|
|
14
|
+
// one door it has (the egress proxy, at its lease — operator.ts), and the
|
|
15
|
+
// worker runs the job as a step of its own: its own pod, its own `tools:`,
|
|
16
|
+
// `secrets:` and `network:` from its own file, NOT cut to the parent's — the
|
|
17
|
+
// parent may hold less. The parent waits and gets the reply text, nothing
|
|
18
|
+
// else. What the sub-agent wrote reaches the workspace when it finishes, so
|
|
19
|
+
// the next step sees it; the reply names the paths.
|
|
20
|
+
//
|
|
21
|
+
// Depth one, as for every sub-agent: the job's own `subagents:` are not
|
|
22
|
+
// offered to it. Its record sits in the run after the parent's, marked
|
|
23
|
+
// `subagentOf` (store.ts), with its own cost and trace.
|
|
24
|
+
//
|
|
25
|
+
// With no channel (a container step on an install without a proxy) the tools
|
|
26
|
+
// are not granted at all; the runner says so on the step.
|
|
27
|
+
|
|
28
|
+
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
import type { DelegationResult, OperatorChannel } from "./operator.ts";
|
|
31
|
+
import type { EventExtra } from "./step-exec.ts";
|
|
32
|
+
import { delegateToolName, type DelegateSpec } from "./subagents.ts";
|
|
33
|
+
|
|
34
|
+
export const DELEGATE_SERVER = "foldrun_delegate";
|
|
35
|
+
/** A job handed over is at most this long — the sub-agent's prompt. */
|
|
36
|
+
export const JOB_MAX = 20_000;
|
|
37
|
+
/** What comes back is cut here: the reply is the parent's context. */
|
|
38
|
+
export const REPLY_MAX = 40_000;
|
|
39
|
+
|
|
40
|
+
type Emit = (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
|
|
41
|
+
|
|
42
|
+
/** The granted names, the same on every path. */
|
|
43
|
+
export function delegateToolNames(specs: DelegateSpec[]): string[] {
|
|
44
|
+
return specs.map((s) => `mcp__${DELEGATE_SERVER}__${delegateToolName(s.name)}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The reply as the parent reads it: the sub-agent's words, then what it changed. */
|
|
48
|
+
export function replyText(name: string, r: DelegationResult): string {
|
|
49
|
+
const body = r.result.trim() || "(no reply)";
|
|
50
|
+
const cut = body.length > REPLY_MAX ? `${body.slice(0, REPLY_MAX)}\n…(cut at ${REPLY_MAX} characters)` : body;
|
|
51
|
+
const wrote = r.wrote?.length
|
|
52
|
+
? `\n\nFiles ${name} changed (in the workspace from the next step on — not in your sandbox):\n${r.wrote.slice(0, 200).map((w) => `- ${w}`).join("\n")}`
|
|
53
|
+
: "";
|
|
54
|
+
const head = r.status === "completed" ? "" : `${name} did not finish (${r.status}). What it said:\n`;
|
|
55
|
+
return head + cut + wrote;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildDelegateTools(opts: {
|
|
59
|
+
specs: DelegateSpec[];
|
|
60
|
+
channel: OperatorChannel | null;
|
|
61
|
+
emit: Emit;
|
|
62
|
+
/** The most one job may run; the step's own timeout bounds it anyway. */
|
|
63
|
+
timeoutSec?: number;
|
|
64
|
+
now?: () => number;
|
|
65
|
+
}) {
|
|
66
|
+
const now = opts.now ?? Date.now;
|
|
67
|
+
const channel = opts.channel;
|
|
68
|
+
if (!opts.specs.length || !channel?.delegate || !channel.delegation) {
|
|
69
|
+
return { server: null, toolNames: [] as string[], calls: {} as Record<string, (job: string) => Promise<unknown>> };
|
|
70
|
+
}
|
|
71
|
+
const text = (t: string, isError = false) => ({ content: [{ type: "text" as const, text: t }], ...(isError ? { isError: true } : {}) });
|
|
72
|
+
const run = async (s: DelegateSpec, rawJob: string) => {
|
|
73
|
+
const job = String(rawJob ?? "").trim().slice(0, JOB_MAX);
|
|
74
|
+
if (!job) return text(`${delegateToolName(s.name)} needs the job, written out in full.`, true);
|
|
75
|
+
let id: string;
|
|
76
|
+
try {
|
|
77
|
+
({ id } = await channel.delegate!(s.name, job));
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return text(`Could not hand ${s.name} the job: ${err instanceof Error ? err.message : String(err)}`, true);
|
|
80
|
+
}
|
|
81
|
+
opts.emit("info", `delegating to ${s.name} (own sandbox): ${job.split("\n")[0].slice(0, 160)}`, { subagent: s.name });
|
|
82
|
+
const deadline = opts.timeoutSec ? now() + opts.timeoutSec * 1000 : Infinity;
|
|
83
|
+
while (now() < deadline) {
|
|
84
|
+
let got: DelegationResult | null = null;
|
|
85
|
+
try {
|
|
86
|
+
got = await channel.delegation!(id, Math.min(deadline - now(), 25_000));
|
|
87
|
+
} catch {
|
|
88
|
+
// A dropped long-poll is retried; the job goes on meanwhile.
|
|
89
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (got) {
|
|
93
|
+
opts.emit(got.status === "completed" ? "info" : "error", `${s.name} ${got.status}${got.wrote?.length ? ` — changed ${got.wrote.length} file(s)` : ""}`, { subagent: s.name });
|
|
94
|
+
return text(replyText(s.name, got), got.status !== "completed");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
await channel.cancelDelegation?.(id);
|
|
99
|
+
} catch {
|
|
100
|
+
/* the worker ends it with the step */
|
|
101
|
+
}
|
|
102
|
+
return text(`${s.name} did not finish in time; its job was stopped.`, true);
|
|
103
|
+
};
|
|
104
|
+
const tools = opts.specs.map((s) =>
|
|
105
|
+
tool(
|
|
106
|
+
delegateToolName(s.name),
|
|
107
|
+
`Hand ${s.name} a whole job: ${s.description} It works in its own sandbox with its own tools and returns its reply — ` +
|
|
108
|
+
`it has not read your conversation, so write the job out in full: what to do, what to return, where to put files.`,
|
|
109
|
+
{ job: z.string().describe("The complete, self-contained job") },
|
|
110
|
+
async ({ job }) => run(s, job),
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
return {
|
|
114
|
+
server: createSdkMcpServer({ name: DELEGATE_SERVER, version: "1.0.0", tools }),
|
|
115
|
+
toolNames: delegateToolNames(opts.specs),
|
|
116
|
+
/** Each tool's own function, by agent name, for tests. */
|
|
117
|
+
calls: Object.fromEntries(opts.specs.map((s) => [s.name, (job: string) => run(s, job)])),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The channel for a step that runs in this process (`foldrun run`): no
|
|
122
|
+
* proxy between it and the drive loop, so a job is handed over directly. */
|
|
123
|
+
export function localDelegateChannel(
|
|
124
|
+
run: (agent: string, job: string, cancelled: () => boolean) => Promise<DelegationResult>,
|
|
125
|
+
): OperatorChannel {
|
|
126
|
+
const jobs = new Map<string, { done: Promise<DelegationResult>; result?: DelegationResult; cancelled: boolean }>();
|
|
127
|
+
let n = 0;
|
|
128
|
+
const none = async () => {
|
|
129
|
+
throw new Error("nobody can answer here");
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
ask: none,
|
|
133
|
+
poll: async () => null,
|
|
134
|
+
inbox: async () => [],
|
|
135
|
+
async delegate(agent, job) {
|
|
136
|
+
const id = `local-${++n}`;
|
|
137
|
+
const entry: { done: Promise<DelegationResult>; result?: DelegationResult; cancelled: boolean } = {
|
|
138
|
+
cancelled: false,
|
|
139
|
+
done: Promise.resolve(),
|
|
140
|
+
} as never;
|
|
141
|
+
entry.done = run(agent, job, () => entry.cancelled)
|
|
142
|
+
.catch((err): DelegationResult => ({ status: "failed", result: err instanceof Error ? err.message : String(err) }))
|
|
143
|
+
.then((r) => (entry.result = r));
|
|
144
|
+
jobs.set(id, entry);
|
|
145
|
+
return { id };
|
|
146
|
+
},
|
|
147
|
+
async delegation(id, maxMs) {
|
|
148
|
+
const entry = jobs.get(id);
|
|
149
|
+
if (!entry) return { status: "skipped", result: "no such job" };
|
|
150
|
+
if (entry.result) return entry.result;
|
|
151
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
152
|
+
const r = await Promise.race([
|
|
153
|
+
entry.done,
|
|
154
|
+
new Promise<null>((res) => (timer = setTimeout(() => res(null), Math.max(0, maxMs)))),
|
|
155
|
+
]);
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
return r;
|
|
158
|
+
},
|
|
159
|
+
async cancelDelegation(id) {
|
|
160
|
+
const entry = jobs.get(id);
|
|
161
|
+
if (entry) entry.cancelled = true;
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The line the delegating step's prompt carries, so the model knows who
|
|
167
|
+
* works where. */
|
|
168
|
+
export function delegatePromptLine(specs: DelegateSpec[]): string {
|
|
169
|
+
return (
|
|
170
|
+
`<isolated-subagents>\nThese colleagues work in their own sandbox, with their own tools; call the tool to hand one a job ` +
|
|
171
|
+
`and you get back its reply and the paths of any files it changed:\n` +
|
|
172
|
+
specs.map((s) => `- ${delegateToolName(s.name)}: ${s.description}`).join("\n") +
|
|
173
|
+
`\n</isolated-subagents>`
|
|
174
|
+
);
|
|
175
|
+
}
|
package/src/egress.ts
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
// platform's (it needs a long-lived process and a Service); the pod side is
|
|
44
44
|
// here in core, because it is what the runner image runs.
|
|
45
45
|
|
|
46
|
-
import type { PeerInfo } from "./operator.ts";
|
|
46
|
+
import type { DelegationResult, PeerInfo } from "./operator.ts";
|
|
47
47
|
import { trimSlashes } from "./paths.ts";
|
|
48
48
|
import type { TestEffect } from "./test-mode.ts";
|
|
49
49
|
import type { NetworkLevel } from "./network.ts";
|
|
@@ -171,8 +171,11 @@ export interface EgressHooks {
|
|
|
171
171
|
/** `step` — the step's index in run.steps; keys that step's operator inbox.
|
|
172
172
|
* `agent` and `peers` — this step's agent and the other steps of its
|
|
173
173
|
* parallel group (tools: [peers]): the proxy serves the roster and
|
|
174
|
-
* addresses a colleague's message to the right inbox.
|
|
175
|
-
|
|
174
|
+
* addresses a colleague's message to the right inbox.
|
|
175
|
+
* `delegates` — the `sandbox: own` sub-agents this step may hand a job
|
|
176
|
+
* to over the delegate door (delegate-tools.ts); the proxy refuses any
|
|
177
|
+
* other name. */
|
|
178
|
+
lease(args: { tenant: string; runId: string; grant: EgressGrant; test?: boolean; step?: number; agent?: string; peers?: PeerInfo[]; who?: LeaseWho; delegates?: string[] }): Promise<EgressLease | null>;
|
|
176
179
|
/** The run is still being driven: renew its leases' heartbeat. Optional —
|
|
177
180
|
* an install without leases has nothing to renew. */
|
|
178
181
|
renew?(runId: string): Promise<number>;
|
|
@@ -181,6 +184,24 @@ export interface EgressHooks {
|
|
|
181
184
|
* between retries, or waiting for a slot, holds none and is not done.
|
|
182
185
|
* Optional: a platform without it keeps every sibling reachable. */
|
|
183
186
|
stepEnded?(args: { tenant: string; runId: string; step: number }): Promise<void>;
|
|
187
|
+
/** Serve the jobs a step posts to the delegate door while it runs
|
|
188
|
+
* (`sandbox: own` sub-agents): each is handed to `run`, whose result the
|
|
189
|
+
* door then answers the step's long-poll with. `close()` stops serving
|
|
190
|
+
* and settles anything unfinished as skipped. Optional: a platform
|
|
191
|
+
* without it offers no delegate tools, and the runner says so. */
|
|
192
|
+
delegations?(
|
|
193
|
+
args: { tenant: string; runId: string; step: number; resuming?: boolean },
|
|
194
|
+
run: (job: DelegationJob) => Promise<DelegationResult>,
|
|
195
|
+
): { close(): Promise<void> };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** One job posted to the delegate door. */
|
|
199
|
+
export interface DelegationJob {
|
|
200
|
+
id: string;
|
|
201
|
+
agent: string;
|
|
202
|
+
job: string;
|
|
203
|
+
/** Set when the step cancelled it (the delegate tool timed out). */
|
|
204
|
+
cancelled?: () => boolean;
|
|
184
205
|
}
|
|
185
206
|
|
|
186
207
|
/** Strip a header set of the proxy's own and hop-by-hop headers. */
|
|
@@ -89,6 +89,7 @@ const AGENT: FieldSpec[] = [
|
|
|
89
89
|
{ key: "mcpServers", type: "object", help: "MCP servers, name → {command | url, args, env, headers}" },
|
|
90
90
|
{ key: "agents", type: ["list", "string"], help: "colleagues it may consult" },
|
|
91
91
|
{ key: "subagents", type: ["list", "string"], help: "colleagues it may delegate to" },
|
|
92
|
+
{ key: "sandbox", type: "string", values: ["own", "parent"], help: "when delegated to as a sub-agent: own = its own sandbox, tools, secrets and network; parent (default) = inside the delegating step's" },
|
|
92
93
|
{ key: "secrets", type: ["list", "string"], help: "keys it spends itself" },
|
|
93
94
|
{ key: "budget", type: ["number", "string"], help: "USD per run" },
|
|
94
95
|
{ key: "limits", type: "object", validated: true, help: "per-step call limits" },
|
|
@@ -240,7 +241,7 @@ const SAID_WHAT_IT_IS: FieldSpec[] = [
|
|
|
240
241
|
const JOB_FIELDS: Partial<Record<SchemaKind, Partial<Record<Job, readonly string[]>>>> = {
|
|
241
242
|
agent: {
|
|
242
243
|
governance: ["owner", "budget"],
|
|
243
|
-
guardrail: ["limits", "network", "hooks", "disallowedTools", "permissionMode", "secrets"],
|
|
244
|
+
guardrail: ["limits", "network", "hooks", "disallowedTools", "permissionMode", "secrets", "sandbox"],
|
|
244
245
|
},
|
|
245
246
|
flow: {
|
|
246
247
|
governance: ["owner", "approvers", "approve_within", "budget", "priority"],
|
package/src/issues.ts
CHANGED
|
@@ -134,7 +134,10 @@ const PLATFORM_CAUSES: (PlatformCause & { test: RegExp })[] = [
|
|
|
134
134
|
key: "model-cap",
|
|
135
135
|
label: "The model usage cap is reached",
|
|
136
136
|
fix: "runs resume when the cap resets; nothing to change in the desks.",
|
|
137
|
-
|
|
137
|
+
// Also the runner's own sentence for a spent subscription window
|
|
138
|
+
// (refusal.ts explainRefusal): on 23 Oct 2026 "the 7-day window is 100%
|
|
139
|
+
// used" filed five desks as their own failures.
|
|
140
|
+
test: /weekly (usage )?limit|usage limit (reached|exceeded)|reached your (weekly|usage) limit|window is (100% used|spent)/i,
|
|
138
141
|
},
|
|
139
142
|
{
|
|
140
143
|
// The provider out of capacity is not the account's cap: it passes in
|
package/src/oauth-presets.ts
CHANGED
|
@@ -19,6 +19,12 @@ export interface OAuthPreset {
|
|
|
19
19
|
hint: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* @deprecated foldrun is a runner and keeps no list of providers: the
|
|
24
|
+
* dashboard and CLI take the provider's own URLs and an `authorize_extra`
|
|
25
|
+
* map (`--authorize-param`) instead. Kept for apps saved from a preset and
|
|
26
|
+
* the CLI's deprecated `--provider`; to be removed in a major.
|
|
27
|
+
*/
|
|
22
28
|
export const OAUTH_PRESETS: Record<string, OAuthPreset> = {
|
|
23
29
|
google: {
|
|
24
30
|
authorize_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
@@ -51,4 +57,5 @@ export const OAUTH_PRESETS: Record<string, OAuthPreset> = {
|
|
|
51
57
|
},
|
|
52
58
|
};
|
|
53
59
|
|
|
60
|
+
/** @deprecated see OAUTH_PRESETS. */
|
|
54
61
|
export const OAUTH_PRESET_NAMES = Object.keys(OAUTH_PRESETS);
|
package/src/operator.ts
CHANGED
|
@@ -99,6 +99,24 @@ export interface OperatorChannel {
|
|
|
99
99
|
/** Leave `text` in the inbox of the sibling named `to` (or of every
|
|
100
100
|
* sibling, `"all"`). Resolves with the agents it reached. */
|
|
101
101
|
tell?(to: string, text: string): Promise<{ to: string[] }>;
|
|
102
|
+
/** `sandbox: own` sub-agents (delegate-tools.ts): hand `agent` a job. The
|
|
103
|
+
* worker runs it in its own sandbox; only the reply comes back. Optional:
|
|
104
|
+
* an older proxy has no delegate door. */
|
|
105
|
+
delegate?(agent: string, job: string): Promise<{ id: string }>;
|
|
106
|
+
/** Waits up to `maxMs` for that job to finish; null while it still runs. */
|
|
107
|
+
delegation?(id: string, maxMs: number): Promise<DelegationResult | null>;
|
|
108
|
+
/** The parent stopped waiting (its step ended): stop the job. Best effort. */
|
|
109
|
+
cancelDelegation?(id: string): Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** How an isolated sub-agent's job ended, as the delegating step hears it. */
|
|
113
|
+
export interface DelegationResult {
|
|
114
|
+
status: "completed" | "failed" | "skipped";
|
|
115
|
+
/** Its reply — all the parent sees of its work. */
|
|
116
|
+
result: string;
|
|
117
|
+
/** Workspace paths it changed. They reach the workspace when it ends, so
|
|
118
|
+
* the next step sees them; the delegating step's own sandbox does not. */
|
|
119
|
+
wrote?: string[];
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
export const QUESTION_MAX = 2_000;
|
|
@@ -175,6 +193,40 @@ export function httpChannel(egress: string | undefined | null, fetchImpl: typeof
|
|
|
175
193
|
);
|
|
176
194
|
return { to: Array.isArray(body.to) ? (body.to as string[]) : [] };
|
|
177
195
|
},
|
|
196
|
+
async delegate(agent, job) {
|
|
197
|
+
const body = await json(
|
|
198
|
+
await fetchImpl(`${ep.base}/delegate/${ep.token}`, {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: { "content-type": "application/json" },
|
|
201
|
+
body: JSON.stringify({ agent, job }),
|
|
202
|
+
signal: AbortSignal.timeout(15_000),
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
return { id: String(body.id) };
|
|
206
|
+
},
|
|
207
|
+
async delegation(id, maxMs) {
|
|
208
|
+
const wait = Math.max(0, Math.min(maxMs, POLL_MS));
|
|
209
|
+
const body = await json(
|
|
210
|
+
await fetchImpl(`${ep.base}/delegate/${ep.token}/${encodeURIComponent(id)}?wait=${wait}`, {
|
|
211
|
+
signal: AbortSignal.timeout(wait + 15_000),
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
const status = body.status;
|
|
215
|
+
if (status !== "completed" && status !== "failed" && status !== "skipped") return null;
|
|
216
|
+
return {
|
|
217
|
+
status,
|
|
218
|
+
result: typeof body.result === "string" ? body.result : "",
|
|
219
|
+
wrote: Array.isArray(body.wrote) ? (body.wrote as unknown[]).filter((w): w is string => typeof w === "string") : [],
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
async cancelDelegation(id) {
|
|
223
|
+
await json(
|
|
224
|
+
await fetchImpl(`${ep.base}/delegate/${ep.token}/${encodeURIComponent(id)}`, {
|
|
225
|
+
method: "DELETE",
|
|
226
|
+
signal: AbortSignal.timeout(5_000),
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
},
|
|
178
230
|
};
|
|
179
231
|
}
|
|
180
232
|
|
package/src/platform.ts
CHANGED
|
@@ -107,6 +107,11 @@ export interface PlatformHooks {
|
|
|
107
107
|
isolation: Record<string, IsolatedStepRunner>;
|
|
108
108
|
/** Destroy whatever sandboxes a stopped run still has. Default: nothing to destroy. */
|
|
109
109
|
killRunSandboxes(runId: string): void;
|
|
110
|
+
/** Destroy ONE step's sandbox, by the ref on its record — a sandbox: own
|
|
111
|
+
* job orphaned by a restart while its parent re-attaches, where killing
|
|
112
|
+
* the whole run's would take the parent too. Optional: an executor that
|
|
113
|
+
* cannot re-attach never needs it (the whole run's are killed instead). */
|
|
114
|
+
killSandbox?(sandbox: { kind: string; ref: string }): void;
|
|
110
115
|
/** Publish storage/public/ as share links after a run. Default: no links. */
|
|
111
116
|
syncPublicShares(tenant: string, workspace: string): Promise<{ added: string[] }>;
|
|
112
117
|
/** This account's own data key, or null to use the install key. */
|
package/src/run-container.ts
CHANGED
|
@@ -35,7 +35,7 @@ import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKE
|
|
|
35
35
|
import { egressCaSh, type NetworkLevel } from "./network.ts";
|
|
36
36
|
import type { ScriptSpec } from "./script-tools.ts";
|
|
37
37
|
import type { ConsultSpec } from "./agent-tools.ts";
|
|
38
|
-
import type { SubagentSpec } from "./subagents.ts";
|
|
38
|
+
import type { DelegateSpec, SubagentSpec } from "./subagents.ts";
|
|
39
39
|
import { isOperatorEvent, type OperatorEvent } from "./operator.ts";
|
|
40
40
|
import type { SearchRoot, RunDigest } from "./context-tools.ts";
|
|
41
41
|
import type { TranslatorSpec } from "./translator.ts";
|
|
@@ -66,6 +66,9 @@ export interface ContainerStepInput {
|
|
|
66
66
|
consults: ConsultSpec[];
|
|
67
67
|
/** `subagents:` — built host-side (runner.ts), plain JSON. */
|
|
68
68
|
subagents?: SubagentSpec[];
|
|
69
|
+
/** `sandbox: own` sub-agents — offered as delegate_<name> tools; their
|
|
70
|
+
* jobs run on the worker in sandboxes of their own (delegate-tools.ts). */
|
|
71
|
+
delegates?: DelegateSpec[];
|
|
69
72
|
timeoutSec?: number;
|
|
70
73
|
/** The step's spend ceiling and the model's per-token price — see
|
|
71
74
|
* step-exec.ts. Values, so the driver can enforce them in the pod. */
|
|
@@ -214,6 +217,10 @@ export interface ContainerStepOutcome {
|
|
|
214
217
|
/** The pod's reconnects and, when it was lost, what the step had written —
|
|
215
218
|
* see ExecOutcome.browserPod. */
|
|
216
219
|
browserPod?: BrowserPodOutcome;
|
|
220
|
+
/** Workspace paths the step's changes were applied to on the host — what
|
|
221
|
+
* applyContainerChanges returned. An isolated sub-agent's reply names
|
|
222
|
+
* them to the step that delegated (delegate-tools.ts). */
|
|
223
|
+
applied?: string[];
|
|
217
224
|
}
|
|
218
225
|
|
|
219
226
|
/**
|
|
@@ -626,6 +633,7 @@ try {
|
|
|
626
633
|
const { buildConsultTools } = await import("@foldrun/core/agent-tools");
|
|
627
634
|
const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import("@foldrun/core/context-tools");
|
|
628
635
|
const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import("@foldrun/core/operator");
|
|
636
|
+
const { buildDelegateTools } = await import("@foldrun/core/delegate-tools");
|
|
629
637
|
const { startTranslator } = await import("@foldrun/core/translator");
|
|
630
638
|
const { prepareRuntime } = await import("@foldrun/core/runtime");
|
|
631
639
|
const { materializeFileSecrets } = await import("@foldrun/core/secret-files");
|
|
@@ -725,6 +733,9 @@ try {
|
|
|
725
733
|
const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };
|
|
726
734
|
const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };
|
|
727
735
|
const inbox = channel ? inboxReader(channel, emit) : undefined;
|
|
736
|
+
// sandbox: own sub-agents: a tool each, whose job runs in its own sandbox
|
|
737
|
+
// on the worker, reached over the same proxy door.
|
|
738
|
+
const delegate = input.delegates?.length ? buildDelegateTools({ specs: input.delegates, channel, emit, timeoutSec: input.timeoutSec }) : { server: null };
|
|
728
739
|
|
|
729
740
|
// Keyless cloud identities (FOLDRUN_IDENTITIES): each token file kept
|
|
730
741
|
// fresh through the proxy's /identity door, which mints only what this
|
|
@@ -776,6 +787,7 @@ try {
|
|
|
776
787
|
...(desks.server ? { foldrun_desks: desks.server } : {}),
|
|
777
788
|
...(ask.server ? { foldrun_ask: ask.server } : {}),
|
|
778
789
|
...(peers.server ? { foldrun_peers: peers.server } : {}),
|
|
790
|
+
...(delegate.server ? { foldrun_delegate: delegate.server } : {}),
|
|
779
791
|
...(install.server ? { foldrun_install: install.server } : {}),
|
|
780
792
|
...input.mcpServers,
|
|
781
793
|
},
|
|
@@ -892,8 +904,11 @@ export const RETRY_SH = `retry() { what=$1; shift; n=1; until "$@"; do if [ "$n"
|
|
|
892
904
|
* and make one browse call per engine on the full image:
|
|
893
905
|
* docker buildx imagetools inspect node:22-trixie-slim --format '{{json .Manifest}}' | jq -r .digest
|
|
894
906
|
* The digest names the multi-arch index, so the one pin serves amd64 and
|
|
895
|
-
* arm64 alike.
|
|
896
|
-
|
|
907
|
+
* arm64 alike.
|
|
908
|
+
*
|
|
909
|
+
* 23 Oct 2026: Node 22.23.4 (the 21 Oct security release: Brotli decoder
|
|
910
|
+
* RCE, TLS hostname bypasses); the pin before carried 22.23.3. */
|
|
911
|
+
export const NODE_BASE = "node:22-trixie-slim@sha256:8eb619e402eaede1748a3c8173bffab391592812e85136452f781741c7242ddf";
|
|
897
912
|
|
|
898
913
|
// Two images from one file, as build targets.
|
|
899
914
|
//
|
|
@@ -1717,8 +1732,9 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
|
|
|
1717
1732
|
const wsOut = path.join(staging, "out");
|
|
1718
1733
|
fs.mkdirSync(wsOut);
|
|
1719
1734
|
const back = spawnSync(cli(), ["cp", `${containerId}:/workspace/.`, wsOut], { encoding: "utf8" });
|
|
1735
|
+
let applied: string[] = [];
|
|
1720
1736
|
if (back.status === 0) {
|
|
1721
|
-
applyContainerChanges(args.workspaceRoot, wsOut, wsIn, (m) => args.emit("error", m), args.divert);
|
|
1737
|
+
applied = applyContainerChanges(args.workspaceRoot, wsOut, wsIn, (m) => args.emit("error", m), args.divert);
|
|
1722
1738
|
} else {
|
|
1723
1739
|
args.emit("error", `copy-out failed — the step's file changes were lost:\n${back.stderr.slice(0, 500)}`);
|
|
1724
1740
|
}
|
|
@@ -1732,7 +1748,7 @@ export async function runStepInContainer(args: RunInContainerArgs): Promise<Cont
|
|
|
1732
1748
|
phases,
|
|
1733
1749
|
};
|
|
1734
1750
|
args.emit("info", timingLine(timing));
|
|
1735
|
-
return { ...outcome, timing };
|
|
1751
|
+
return { ...outcome, timing, applied };
|
|
1736
1752
|
} finally {
|
|
1737
1753
|
if (containerId) spawnSync(cli(), ["rm", "-f", containerId], { stdio: "ignore" });
|
|
1738
1754
|
fs.rmSync(staging, { recursive: true, force: true });
|