@foldrun/core 0.7.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/dist/src/completions.js +1 -0
- package/dist/src/deploy.js +5 -0
- package/dist/src/egress.d.ts +21 -0
- package/dist/src/egress.js +8 -0
- package/dist/src/hooks.d.ts +114 -0
- package/dist/src/hooks.js +370 -0
- package/dist/src/kinds.d.ts +1 -1
- package/dist/src/kinds.js +26 -0
- package/dist/src/library.d.ts +1 -1
- package/dist/src/library.js +17 -1
- package/dist/src/mail.js +1 -1
- package/dist/src/network.d.ts +120 -0
- package/dist/src/network.js +360 -0
- package/dist/src/notify.d.ts +8 -0
- package/dist/src/notify.js +10 -1
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +21 -1
- package/dist/src/run-container.d.ts +13 -0
- package/dist/src/run-container.js +8 -1
- package/dist/src/runner.d.ts +1 -0
- package/dist/src/runner.js +144 -16
- package/dist/src/secrets.d.ts +20 -0
- package/dist/src/secrets.js +65 -1
- package/dist/src/starter.js +9 -1
- package/dist/src/step-exec.d.ts +10 -0
- package/dist/src/step-exec.js +113 -2
- package/dist/src/store.d.ts +23 -1
- package/dist/src/store.js +33 -1
- package/dist/src/tool-loop.d.ts +45 -0
- package/dist/src/tool-loop.js +133 -0
- package/package.json +19 -1
- package/src/completions.ts +1 -0
- package/src/deploy.ts +4 -0
- package/src/egress.ts +26 -0
- package/src/hooks.ts +406 -0
- package/src/kinds.ts +28 -0
- package/src/library.ts +18 -2
- package/src/mail.ts +1 -1
- package/src/network.ts +390 -0
- package/src/notify.ts +11 -1
- package/src/providers.ts +27 -1
- package/src/run-container.ts +16 -2
- package/src/runner.ts +145 -14
- package/src/secrets.ts +69 -1
- package/src/starter.ts +9 -1
- package/src/step-exec.ts +115 -3
- package/src/store.ts +39 -1
- package/src/tool-loop.ts +176 -0
package/dist/src/step-exec.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// assembles the system prompt and builds MCP servers, then hands this the
|
|
8
8
|
// results. That is what lets the same function run somewhere the vault, the
|
|
9
9
|
// library and the account do not exist.
|
|
10
|
+
import { runHook, runToolCallHooks, matchesTool, hookLine, resolveHooks } from "./hooks.js";
|
|
10
11
|
import fs from "node:fs";
|
|
11
12
|
import os from "node:os";
|
|
12
13
|
import path from "node:path";
|
|
@@ -169,8 +170,49 @@ async function executeStepInner(opts, runQuery) {
|
|
|
169
170
|
// `limits:` — this step's counts. A new step, a retry of one, or a
|
|
170
171
|
// fan-out instance each start from zero.
|
|
171
172
|
const counter = new CallCounter(opts.limits);
|
|
173
|
+
// Hooks (hooks.ts): the person's own code at this dispatch, same on every
|
|
174
|
+
// model. Each runs in the agent's directory with a bare environment.
|
|
175
|
+
let hooks = opts.hooks ?? [];
|
|
176
|
+
if (opts.hookNames?.length) {
|
|
177
|
+
const r = resolveHooks({ workspaceRoot, libraryRoot, names: opts.hookNames });
|
|
178
|
+
const bad = [...r.missing.map((n) => `no hook named ${n} in hooks/ or the library`), ...r.errors];
|
|
179
|
+
if (bad.length) {
|
|
180
|
+
emit("error", `hooks: ${bad.join("; ")} — the step is refused rather than run without its hooks`);
|
|
181
|
+
return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
|
|
182
|
+
}
|
|
183
|
+
hooks = [...hooks, ...r.hooks];
|
|
184
|
+
}
|
|
185
|
+
const hookBase = { ...(opts.hookBase ?? {}) };
|
|
186
|
+
const callHook = (hook, event) => runHook(hook, event, { cwd: agentDir, env: opts.env });
|
|
187
|
+
const record = (r, via) => emit(r.did === "error" || r.did === "deny" ? "error" : "info", hookLine(r), { hook: r, ...(via ? { subagent: via } : {}) });
|
|
188
|
+
// step.start — a gate: context it returns goes in front of the task; a
|
|
189
|
+
// hook that fails refuses the step rather than run it unguarded.
|
|
190
|
+
let prompt = opts.prompt;
|
|
191
|
+
for (const hook of hooks.filter((h) => h.on === "step.start")) {
|
|
192
|
+
const a = await callHook(hook, { ...hookBase, event: "step.start", hook: hook.name, prompt });
|
|
193
|
+
if (!a.ok) {
|
|
194
|
+
record({ hook: hook.name, event: "step.start", did: "error", reason: `${a.failure}, so the step is refused`, ms: a.ms });
|
|
195
|
+
return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
|
|
196
|
+
}
|
|
197
|
+
// Exit 2 is a deny on every event (runHook). On a gate that means the
|
|
198
|
+
// step does not run — it used to come back ok with no context, and the
|
|
199
|
+
// step went ahead as if the hook had agreed.
|
|
200
|
+
if (a.decision === "deny") {
|
|
201
|
+
record({ hook: hook.name, event: "step.start", did: "deny", reason: `${a.reason}, so the step is refused`, ms: a.ms });
|
|
202
|
+
return { status: "failed", result: null, conclusion: null, costUsd: null, usage: null };
|
|
203
|
+
}
|
|
204
|
+
if (a.context) {
|
|
205
|
+
prompt = `${prompt}\n\n${a.context}`;
|
|
206
|
+
record({ hook: hook.name, event: "step.start", did: "context", ms: a.ms });
|
|
207
|
+
}
|
|
208
|
+
else if (a.note) {
|
|
209
|
+
record({ hook: hook.name, event: "step.start", did: "note", reason: a.note, ms: a.ms });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const toolHooks = hooks.filter((h) => h.on === "tool.call");
|
|
213
|
+
const resultHooks = hooks.filter((h) => h.on === "tool.result");
|
|
172
214
|
const q = runQuery({
|
|
173
|
-
prompt
|
|
215
|
+
prompt,
|
|
174
216
|
options: {
|
|
175
217
|
cwd: agentDir,
|
|
176
218
|
abortController: abort,
|
|
@@ -272,6 +314,46 @@ async function executeStepInner(opts, runQuery) {
|
|
|
272
314
|
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: refusal.message } };
|
|
273
315
|
}
|
|
274
316
|
}
|
|
317
|
+
// tool.call hooks — after foldrun's own checks, so a hook never
|
|
318
|
+
// sees a call that was going to be refused anyway, and handed the
|
|
319
|
+
// input as the tool would get it (the workspace/ prefix expanded).
|
|
320
|
+
if (toolHooks.length) {
|
|
321
|
+
const input = (verdict?.updatedInput ?? hookInput.tool_input ?? {});
|
|
322
|
+
// A sub-agent's call says so: a hook that guards the step's own
|
|
323
|
+
// model differently from its delegates can tell them apart.
|
|
324
|
+
const v = await runToolCallHooks(toolHooks, { tool: hookInput.tool_name, input, base: via ? { ...hookBase, subagent: via } : hookBase }, callHook);
|
|
325
|
+
for (const r of v.records)
|
|
326
|
+
record(r, via);
|
|
327
|
+
const refundCall = () => { if (counter.active)
|
|
328
|
+
counter.refund(limitKeysFor(hookInput.tool_name, hookInput.tool_input, opts.toolOwners)); };
|
|
329
|
+
if (v.decision === "deny") {
|
|
330
|
+
refundCall();
|
|
331
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: v.reason } };
|
|
332
|
+
}
|
|
333
|
+
if (v.input) {
|
|
334
|
+
// A rewritten call is answered "allow", and an allow skips
|
|
335
|
+
// canUseTool — where the shell and path confinement live. So
|
|
336
|
+
// the rewrite goes through the same checks here: a hook can
|
|
337
|
+
// narrow what the agent does, never widen it.
|
|
338
|
+
const again = hookInput.tool_name === "Bash"
|
|
339
|
+
? checkBash(String(v.input.command ?? ""))
|
|
340
|
+
: isFilesystemTool(hookInput.tool_name)
|
|
341
|
+
? checkPaths(hookInput.tool_name, v.input, { agentDir, workspaceRoot, libraryRoot })
|
|
342
|
+
: { ok: true };
|
|
343
|
+
const granted = opts.allowed.includes(hookInput.tool_name) || opts.mcpNames.some((n) => hookInput.tool_name.startsWith(`mcp__${n}__`));
|
|
344
|
+
// The sub-agent guard read the ORIGINAL input: a rewrite of a
|
|
345
|
+
// delegation could otherwise swap subagent_type for one this
|
|
346
|
+
// step never declared (or the SDK's general-purpose).
|
|
347
|
+
const delegation = guardSubagent ? guardSubagent({ agentId, agentType, tool: hookInput.tool_name, input: v.input }) : null;
|
|
348
|
+
if (!again.ok || !granted || delegation) {
|
|
349
|
+
refundCall();
|
|
350
|
+
const why = !granted ? `Tool ${hookInput.tool_name} is not enabled for this agent.` : delegation ? `the rewritten call: ${delegation}` : `the rewritten call: ${again.reason}`;
|
|
351
|
+
emit("error", why, via ? { subagent: via } : undefined);
|
|
352
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: why } };
|
|
353
|
+
}
|
|
354
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: again.updatedInput ?? v.input } };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
275
357
|
if (!verdict?.updatedInput)
|
|
276
358
|
return {};
|
|
277
359
|
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: verdict.updatedInput } };
|
|
@@ -281,12 +363,26 @@ async function executeStepInner(opts, runQuery) {
|
|
|
281
363
|
// after its next tool call, as context — never as a change to what it
|
|
282
364
|
// may do. A model that is only writing, calling no tools, hears it at
|
|
283
365
|
// the next call it makes.
|
|
284
|
-
...(opts.inbox
|
|
366
|
+
...(opts.inbox || resultHooks.length
|
|
285
367
|
? {
|
|
286
368
|
PostToolUse: [{
|
|
287
369
|
hooks: [async (hookInput) => {
|
|
288
370
|
if (hookInput.hook_event_name !== "PostToolUse")
|
|
289
371
|
return {};
|
|
372
|
+
// tool.result hooks watch: the call has happened, so one
|
|
373
|
+
// that fails is recorded and the step goes on.
|
|
374
|
+
for (const hook of resultHooks) {
|
|
375
|
+
if (!matchesTool(hook.tool, hookInput.tool_name))
|
|
376
|
+
continue;
|
|
377
|
+
const response = hookInput.tool_response;
|
|
378
|
+
const a = await callHook(hook, { ...hookBase, event: "tool.result", hook: hook.name, tool: { name: hookInput.tool_name, input: hookInput.tool_input ?? {} }, result: response ?? null });
|
|
379
|
+
if (!a.ok)
|
|
380
|
+
record({ hook: hook.name, event: "tool.result", did: "error", tool: hookInput.tool_name, reason: a.failure, ms: a.ms });
|
|
381
|
+
else if (a.note)
|
|
382
|
+
record({ hook: hook.name, event: "tool.result", did: "note", tool: hookInput.tool_name, reason: a.note, ms: a.ms });
|
|
383
|
+
}
|
|
384
|
+
if (!opts.inbox)
|
|
385
|
+
return {};
|
|
290
386
|
// A sub-agent's call: the message is for the step's own
|
|
291
387
|
// model, and draining it here would hand it to a context
|
|
292
388
|
// that ends with the delegation — and lose it.
|
|
@@ -614,6 +710,21 @@ async function executeStepInner(opts, runQuery) {
|
|
|
614
710
|
status = "failed";
|
|
615
711
|
emit("error", "the model stream ended without a result — the model process likely died (out of memory?)");
|
|
616
712
|
}
|
|
713
|
+
// step.end hooks watch the reply, and may rewrite it. One that fails is
|
|
714
|
+
// recorded and the reply stands: the work it describes is done.
|
|
715
|
+
if (status === "completed") {
|
|
716
|
+
for (const hook of hooks.filter((h) => h.on === "step.end")) {
|
|
717
|
+
const a = await callHook(hook, { ...hookBase, event: "step.end", hook: hook.name, reply: texts.join("\n").trim() });
|
|
718
|
+
if (!a.ok)
|
|
719
|
+
record({ hook: hook.name, event: "step.end", did: "error", reason: a.failure, ms: a.ms });
|
|
720
|
+
else if (a.reply !== undefined) {
|
|
721
|
+
texts.splice(0, texts.length, a.reply);
|
|
722
|
+
record({ hook: hook.name, event: "step.end", did: "reply", ms: a.ms });
|
|
723
|
+
}
|
|
724
|
+
else if (a.note)
|
|
725
|
+
record({ hook: hook.name, event: "step.end", did: "note", reason: a.note, ms: a.ms });
|
|
726
|
+
}
|
|
727
|
+
}
|
|
617
728
|
const result = texts.join("\n").trim() || null;
|
|
618
729
|
// A step's reply is not one message: the model narrates ("Now let me read
|
|
619
730
|
// the outputs…"), calls tools, and answers last. Joining those and reading
|
package/dist/src/store.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { OperatorEvent } from "./operator.ts";
|
|
|
12
12
|
* the runtime, and there is nothing to fail to import.
|
|
13
13
|
*/
|
|
14
14
|
export declare function readFrontmatter(file: string): Record<string, unknown>;
|
|
15
|
+
import { type NetworkPolicy } from "./network.ts";
|
|
15
16
|
import { type WireFormat, type AuthShape } from "./providers.ts";
|
|
16
17
|
/**
|
|
17
18
|
* Single-workspace mode. When set, the workspace IS this directory — no
|
|
@@ -20,7 +21,7 @@ import { type WireFormat, type AuthShape } from "./providers.ts";
|
|
|
20
21
|
* core needs to know about which one it's in.
|
|
21
22
|
*/
|
|
22
23
|
export { singleWorkspace };
|
|
23
|
-
export declare const WORKSPACE_DIRS: readonly ["agents", "flows", "evals", "skills", "memory", "knowledge", "tools", "scripts", "state"];
|
|
24
|
+
export declare const WORKSPACE_DIRS: readonly ["agents", "flows", "evals", "skills", "memory", "knowledge", "tools", "scripts", "hooks", "state"];
|
|
24
25
|
export declare const STORAGE_DIR = "storage";
|
|
25
26
|
/** What this directory was called before. Read-only: nothing writes here any
|
|
26
27
|
* more, but installs created before the rename have bytes under it, and a
|
|
@@ -331,6 +332,17 @@ export interface AgentInfo {
|
|
|
331
332
|
/** What is wrong with the `limits:` block's shape; key-vs-grant problems
|
|
332
333
|
* need the tool lists, and `check` adds them (limits.ts limitKeyProblems). */
|
|
333
334
|
limitProblems: string[];
|
|
335
|
+
/** What cannot be read in `network:` (network.ts) — a level that is not
|
|
336
|
+
* one, a site with a scheme or a path. */
|
|
337
|
+
networkProblems: string[];
|
|
338
|
+
/** The sites this agent's steps may reach, resolved the way a run
|
|
339
|
+
* resolves them — its `network:`, the workspace's, the account's, and
|
|
340
|
+
* the default (open when it may fetch or browse the web, else trusted).
|
|
341
|
+
* `implied` here is its `apis:` hosts; a run also adds its MCP servers',
|
|
342
|
+
* its web providers' and its bound secrets' sites. */
|
|
343
|
+
network: NetworkPolicy & {
|
|
344
|
+
readsWeb: boolean;
|
|
345
|
+
};
|
|
334
346
|
/** Tools this agent declares inline rather than in `tools:` — `apis:`,
|
|
335
347
|
* `scripts:` and `mcpServers:` names — which a limit may also name. */
|
|
336
348
|
inlineTools: string[];
|
|
@@ -893,6 +905,16 @@ export interface RunEvent {
|
|
|
893
905
|
* detail), `output: json`, a schema. What the runner reads to decline a
|
|
894
906
|
* retry of an outward step whose tools already ran. */
|
|
895
907
|
check?: boolean;
|
|
908
|
+
/** Set on the line a hook wrote — a deny, a rewrite, context, a failure
|
|
909
|
+
* (hooks.ts). The run page and the Hooks page read these. */
|
|
910
|
+
hook?: {
|
|
911
|
+
hook: string;
|
|
912
|
+
event: string;
|
|
913
|
+
did: string;
|
|
914
|
+
tool?: string;
|
|
915
|
+
reason?: string;
|
|
916
|
+
ms: number;
|
|
917
|
+
};
|
|
896
918
|
}
|
|
897
919
|
/** A question an agent asked a person while its step ran (tools: [ask]). */
|
|
898
920
|
export interface StepQuestion {
|
package/dist/src/store.js
CHANGED
|
@@ -44,7 +44,8 @@ import { refNames } from "./refs.js";
|
|
|
44
44
|
import { parseBudget, budgetProblem } from "./budget.js";
|
|
45
45
|
import { timezoneProblem } from "./clock.js";
|
|
46
46
|
import { parseStepLimits, readLimits } from "./limits.js";
|
|
47
|
-
import {
|
|
47
|
+
import { networkProblems, resolveNetwork } from "./network.js";
|
|
48
|
+
import { webProblems, webWarnings, runnerEngines, webConfig } from "./providers.js";
|
|
48
49
|
import { actionProblems } from "./web-actions.js";
|
|
49
50
|
import { languageProblem } from "./language.js";
|
|
50
51
|
import { localeProblems } from "./locale.js";
|
|
@@ -74,6 +75,7 @@ export const WORKSPACE_DIRS = [
|
|
|
74
75
|
"knowledge",
|
|
75
76
|
"tools",
|
|
76
77
|
"scripts",
|
|
78
|
+
"hooks",
|
|
77
79
|
"state",
|
|
78
80
|
];
|
|
79
81
|
const IN_WORKSPACE_DIR = new RegExp(`^(${WORKSPACE_DIRS.join("|")})/`);
|
|
@@ -1094,6 +1096,17 @@ export function listAgents(tenant, workspace) {
|
|
|
1094
1096
|
const dir = path.join(workspaceDir(tenant, workspace), "agents");
|
|
1095
1097
|
if (!fs.existsSync(dir))
|
|
1096
1098
|
return [];
|
|
1099
|
+
// The two levels above every agent, for the network: cascade.
|
|
1100
|
+
const frontOf = (file) => {
|
|
1101
|
+
try {
|
|
1102
|
+
return readFrontmatter(file);
|
|
1103
|
+
}
|
|
1104
|
+
catch {
|
|
1105
|
+
return {}; // no AGENTS.md at that level, or one that does not parse
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
const accountFront = frontOf(path.join(accountDir(tenant), "AGENTS.md"));
|
|
1109
|
+
const workspaceFront = frontOf(path.join(workspaceDir(tenant, workspace), "AGENTS.md"));
|
|
1097
1110
|
return fs
|
|
1098
1111
|
.readdirSync(dir)
|
|
1099
1112
|
.filter((n) => fs.existsSync(path.join(dir, n, "agent.md")))
|
|
@@ -1101,7 +1114,25 @@ export function listAgents(tenant, workspace) {
|
|
|
1101
1114
|
.map((name) => {
|
|
1102
1115
|
const { data } = matter(fs.readFileSync(path.join(dir, name, "agent.md"), "utf8"));
|
|
1103
1116
|
const tools = refNames(data.tools);
|
|
1117
|
+
const actions = webConfig(data, { ...accountFront, ...workspaceFront }).actions;
|
|
1118
|
+
const readsWeb = tools.includes("web") && (!actions || actions.some((a) => a === "fetch" || a === "browse"));
|
|
1119
|
+
const network = {
|
|
1120
|
+
...resolveNetwork({
|
|
1121
|
+
account: accountFront.network,
|
|
1122
|
+
workspace: workspaceFront.network,
|
|
1123
|
+
agent: data.network,
|
|
1124
|
+
readsWeb,
|
|
1125
|
+
implied: parseApis(data.apis).map((a) => { try {
|
|
1126
|
+
return new URL(a.base).host;
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
return "";
|
|
1130
|
+
} }),
|
|
1131
|
+
}),
|
|
1132
|
+
readsWeb,
|
|
1133
|
+
};
|
|
1104
1134
|
return {
|
|
1135
|
+
network,
|
|
1105
1136
|
name,
|
|
1106
1137
|
description: data.description ?? "",
|
|
1107
1138
|
model: data.model ?? "default",
|
|
@@ -1125,6 +1156,7 @@ export function listAgents(tenant, workspace) {
|
|
|
1125
1156
|
webWarnings: webWarnings(data, { chrome: runnerEngines()?.chrome, chromium: runnerEngines()?.chromium }),
|
|
1126
1157
|
scheduleProblem: agentScheduleProblem(data),
|
|
1127
1158
|
...(() => { const l = readLimits(data.limits); return { limits: l.limits, limitProblems: l.problems }; })(),
|
|
1159
|
+
networkProblems: networkProblems(data.network),
|
|
1128
1160
|
inlineTools: inlineToolNames(data),
|
|
1129
1161
|
};
|
|
1130
1162
|
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** One string argument: what it means, whether it may be left out, how long it may be. */
|
|
2
|
+
export interface LoopArg {
|
|
3
|
+
description?: string;
|
|
4
|
+
optional?: boolean;
|
|
5
|
+
max?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface LoopTool {
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
args: Record<string, LoopArg>;
|
|
11
|
+
/** The answer the model reads. A throw becomes an error result, not a crash. */
|
|
12
|
+
run: (args: Record<string, string | undefined>) => Promise<string> | string;
|
|
13
|
+
}
|
|
14
|
+
export interface LoopUsage {
|
|
15
|
+
/** Everything sent, cache reads and writes included. */
|
|
16
|
+
inputTokens: number;
|
|
17
|
+
outputTokens: number;
|
|
18
|
+
costUsd: number;
|
|
19
|
+
}
|
|
20
|
+
export interface LoopResult {
|
|
21
|
+
/** The last turn's text — the answer, not the thinking aloud between tool calls. */
|
|
22
|
+
text: string;
|
|
23
|
+
/** What the SDK reports the query cost; 0 when it never reported (killed). */
|
|
24
|
+
costUsd: number;
|
|
25
|
+
usage: LoopUsage | null;
|
|
26
|
+
/** Why it ended, when it did not end well: max turns, budget, wall clock. */
|
|
27
|
+
stopped: string | null;
|
|
28
|
+
}
|
|
29
|
+
export interface LoopOptions {
|
|
30
|
+
system: string;
|
|
31
|
+
prompt: string;
|
|
32
|
+
tools: LoopTool[];
|
|
33
|
+
model: string;
|
|
34
|
+
/** The model credential and anything else the SDK process needs. */
|
|
35
|
+
env: Record<string, string | undefined>;
|
|
36
|
+
maxTurns: number;
|
|
37
|
+
maxBudgetUsd: number;
|
|
38
|
+
maxOutputTokens: number;
|
|
39
|
+
wallSeconds: number;
|
|
40
|
+
/** The reply as it is written, for a live view. */
|
|
41
|
+
onText?: (delta: string) => void;
|
|
42
|
+
}
|
|
43
|
+
/** The Agent SDK's name for one of our tools. */
|
|
44
|
+
export declare const loopToolName: (name: string) => string;
|
|
45
|
+
export declare function runToolLoop(o: LoopOptions): Promise<LoopResult>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// A model with tools that live in this process, and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// What the platform's assistant needs (and what the eval judge and the flow
|
|
4
|
+
// draft are a toolless special case of): one Agent SDK query whose ONLY
|
|
5
|
+
// tools are the ones the caller passes — no built-in tool, no file system,
|
|
6
|
+
// no shell, no sub-agent, no MCP server the machine happens to have — run in
|
|
7
|
+
// a throwaway directory, inside a turn, dollar and wall-clock ceiling.
|
|
8
|
+
//
|
|
9
|
+
// The tools run here, in the caller's process, so whatever they read they
|
|
10
|
+
// read with the caller's own scoping; the model only ever sees what a tool
|
|
11
|
+
// returns. That is why this needs no sandbox pod: nothing the model asks for
|
|
12
|
+
// is executed as code.
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { query, createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
18
|
+
import { hostSafeEnv } from "./host-env.js";
|
|
19
|
+
const SERVER = "foldrun_tools";
|
|
20
|
+
/** The Agent SDK's name for one of our tools. */
|
|
21
|
+
export const loopToolName = (name) => `mcp__${SERVER}__${name}`;
|
|
22
|
+
function schemaOf(args) {
|
|
23
|
+
return Object.fromEntries(Object.entries(args).map(([name, a]) => {
|
|
24
|
+
let s = z.string().min(a.optional ? 0 : 1);
|
|
25
|
+
if (a.max)
|
|
26
|
+
s = s.max(a.max);
|
|
27
|
+
if (a.description)
|
|
28
|
+
s = s.describe(a.description);
|
|
29
|
+
return [name, a.optional ? s.optional() : s];
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
export async function runToolLoop(o) {
|
|
33
|
+
const server = createSdkMcpServer({
|
|
34
|
+
name: SERVER,
|
|
35
|
+
version: "1.0.0",
|
|
36
|
+
tools: o.tools.map((t) => tool(t.name, t.description, schemaOf(t.args), async (args) => {
|
|
37
|
+
try {
|
|
38
|
+
const plain = Object.fromEntries(Object.entries(args).map(([k, v]) => [k, v === undefined ? undefined : String(v)]));
|
|
39
|
+
return { content: [{ type: "text", text: await t.run(plain) }] };
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
return { content: [{ type: "text", text: `error: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
43
|
+
}
|
|
44
|
+
})),
|
|
45
|
+
});
|
|
46
|
+
// Homeless on purpose: there are no file tools, and a throwaway cwd means
|
|
47
|
+
// even a confused call has no tree to touch.
|
|
48
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-loop-"));
|
|
49
|
+
const abort = new AbortController();
|
|
50
|
+
let killed = false;
|
|
51
|
+
const watchdog = setTimeout(() => {
|
|
52
|
+
killed = true;
|
|
53
|
+
abort.abort();
|
|
54
|
+
}, o.wallSeconds * 1000);
|
|
55
|
+
watchdog.unref?.();
|
|
56
|
+
let text = "";
|
|
57
|
+
let costUsd = 0;
|
|
58
|
+
let usage = null;
|
|
59
|
+
let stopped = null;
|
|
60
|
+
try {
|
|
61
|
+
const q = query({
|
|
62
|
+
prompt: o.prompt,
|
|
63
|
+
options: {
|
|
64
|
+
model: o.model,
|
|
65
|
+
systemPrompt: o.system,
|
|
66
|
+
// No built-in tool at all (no Task, so no sub-agents either) — only
|
|
67
|
+
// ours, each pre-approved; anything else is refused unasked.
|
|
68
|
+
tools: [],
|
|
69
|
+
mcpServers: { [SERVER]: server },
|
|
70
|
+
strictMcpConfig: true,
|
|
71
|
+
allowedTools: o.tools.map((t) => loopToolName(t.name)),
|
|
72
|
+
permissionMode: "dontAsk",
|
|
73
|
+
settingSources: [],
|
|
74
|
+
persistSession: false,
|
|
75
|
+
maxTurns: o.maxTurns,
|
|
76
|
+
maxBudgetUsd: o.maxBudgetUsd,
|
|
77
|
+
includePartialMessages: true,
|
|
78
|
+
cwd: scratch,
|
|
79
|
+
abortController: abort,
|
|
80
|
+
env: { ...hostSafeEnv(), ...o.env, CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(o.maxOutputTokens) },
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
let turn = "";
|
|
84
|
+
for await (const message of q) {
|
|
85
|
+
if (message.type === "stream_event") {
|
|
86
|
+
const e = message.event;
|
|
87
|
+
if (e.type === "message_start")
|
|
88
|
+
turn = "";
|
|
89
|
+
if (e.type === "content_block_delta" && e.delta.type === "text_delta") {
|
|
90
|
+
turn += e.delta.text;
|
|
91
|
+
o.onText?.(e.delta.text);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (message.type === "assistant") {
|
|
95
|
+
const said = message.message.content
|
|
96
|
+
.filter((b) => b.type === "text")
|
|
97
|
+
.map((b) => b.text)
|
|
98
|
+
.join("");
|
|
99
|
+
if (said.trim())
|
|
100
|
+
text = said;
|
|
101
|
+
}
|
|
102
|
+
else if (message.type === "result") {
|
|
103
|
+
costUsd = message.total_cost_usd ?? 0;
|
|
104
|
+
const models = Object.values(message.modelUsage ?? {});
|
|
105
|
+
usage = {
|
|
106
|
+
inputTokens: models.reduce((n, m) => n + (m.inputTokens ?? 0) + (m.cacheReadInputTokens ?? 0) + (m.cacheCreationInputTokens ?? 0), 0),
|
|
107
|
+
outputTokens: models.reduce((n, m) => n + (m.outputTokens ?? 0), 0),
|
|
108
|
+
costUsd,
|
|
109
|
+
};
|
|
110
|
+
if (message.subtype === "success") {
|
|
111
|
+
if (message.result?.trim())
|
|
112
|
+
text = message.result;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
stopped = message.subtype;
|
|
116
|
+
if (!text)
|
|
117
|
+
text = turn;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
if (!killed)
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
clearTimeout(watchdog);
|
|
128
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
if (killed)
|
|
131
|
+
stopped = `ran past ${o.wallSeconds}s`;
|
|
132
|
+
return { text, costUsd, usage, stopped };
|
|
133
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foldrun/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Agents are just folders. The format, the runner, the checks.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -15,6 +15,24 @@
|
|
|
15
15
|
"default": "./dist/src/*.js"
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
|
+
"foldrun": {
|
|
19
|
+
"publicApi": [
|
|
20
|
+
".",
|
|
21
|
+
"./git-hook",
|
|
22
|
+
"./agents-md",
|
|
23
|
+
"./browser-pod",
|
|
24
|
+
"./flow-patterns",
|
|
25
|
+
"./history",
|
|
26
|
+
"./hooks",
|
|
27
|
+
"./kinds",
|
|
28
|
+
"./layout",
|
|
29
|
+
"./limits",
|
|
30
|
+
"./network",
|
|
31
|
+
"./providers",
|
|
32
|
+
"./subagents",
|
|
33
|
+
"./tool-names"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
18
36
|
"main": "./dist/index.js",
|
|
19
37
|
"types": "./dist/index.d.ts",
|
|
20
38
|
"files": [
|
package/src/completions.ts
CHANGED
|
@@ -147,6 +147,7 @@ const FIELDS: Record<string, Completion[]> = {
|
|
|
147
147
|
{ label: "disallowedTools", insert: "disallowedTools:\n - " },
|
|
148
148
|
{ label: "subagents", insert: "subagents:\n - ", hint: "colleagues it may delegate a job to — own context, own tools, no wider than its own" },
|
|
149
149
|
{ label: "limits", insert: "limits:\n web.search: 40\n calls: 300", hint: "per step: a call past a limit is refused, never run" },
|
|
150
|
+
{ label: "network", insert: "network:\n - api.example.com", hint: "sites the sandbox may reach: open | trusted | none, or a list added to the trusted list" },
|
|
150
151
|
{
|
|
151
152
|
label: "provider",
|
|
152
153
|
insert: "provider:\n name: \n token: ${PROVIDER_TOKEN}",
|
package/src/deploy.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { conformanceIssues } from "./okf.ts";
|
|
|
40
40
|
import { timezoneProblem } from "./clock.ts";
|
|
41
41
|
import { webProblems, webWarnings, runnerEngines } from "./providers.ts";
|
|
42
42
|
import { readLimits } from "./limits.ts";
|
|
43
|
+
import { networkProblems } from "./network.ts";
|
|
43
44
|
import { actionProblems } from "./web-actions.ts";
|
|
44
45
|
import { languageProblem } from "./language.ts";
|
|
45
46
|
import { localeProblems } from "./locale.ts";
|
|
@@ -326,6 +327,9 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
|
326
327
|
// `limits:` that cannot be read — a count of 0, a word, web.<not an
|
|
327
328
|
// action> — would run uncapped where the author meant a cap.
|
|
328
329
|
for (const w of readLimits(front.limits).problems) at(f.path, w);
|
|
330
|
+
// `network:` that cannot be read would leave the step on the level
|
|
331
|
+
// above it — wider or narrower than the author wrote.
|
|
332
|
+
for (const w of networkProblems(front.network)) at(f.path, w);
|
|
329
333
|
const lang = languageProblem(front.language);
|
|
330
334
|
if (lang) at(f.path, lang);
|
|
331
335
|
for (const w of localeProblems(front)) at(f.path, w);
|
package/src/egress.ts
CHANGED
|
@@ -31,12 +31,21 @@
|
|
|
31
31
|
// `FOLDRUN_EGRESS` instead, and sends through the proxy like everything
|
|
32
32
|
// else.
|
|
33
33
|
//
|
|
34
|
+
// A secret BOUND to sites in the vault (`foldrun secrets set NAME --site
|
|
35
|
+
// api.example.com`) is never materialised, whatever the step grants: the
|
|
36
|
+
// pod's programs all send through the proxy's forward door (HTTPS_PROXY),
|
|
37
|
+
// and for a bound site the door terminates TLS with the platform's egress CA
|
|
38
|
+
// — which the sandbox trusts — and fills `${NAME}` in the URL and headers on
|
|
39
|
+
// the way out, the way Anthropic's cloud sandboxes attach a network secret.
|
|
40
|
+
// Every other site is a tunnel the proxy does not read.
|
|
41
|
+
//
|
|
34
42
|
// This file is the vocabulary both sides share. The server is the
|
|
35
43
|
// platform's (it needs a long-lived process and a Service); the pod side is
|
|
36
44
|
// here in core, because it is what the runner image runs.
|
|
37
45
|
|
|
38
46
|
import { trimSlashes } from "./paths.ts";
|
|
39
47
|
import type { TestEffect } from "./test-mode.ts";
|
|
48
|
+
import type { NetworkLevel } from "./network.ts";
|
|
40
49
|
|
|
41
50
|
/** `${NAME}` — the shape a secret reference takes everywhere in foldrun. */
|
|
42
51
|
export const PLACEHOLDER = /\$\{([A-Z][A-Z0-9_]*)\}/g;
|
|
@@ -99,11 +108,28 @@ export interface EgressGrant {
|
|
|
99
108
|
* that has to put a rate-limit reset into words — and a reset time is
|
|
100
109
|
* only useful in the calendar the step works to. See refusal.ts. */
|
|
101
110
|
timezone?: string;
|
|
111
|
+
/** Which sites the step may reach through the proxy's forward door
|
|
112
|
+
* (network.ts). Absent on a grant from a runner that predates it: the
|
|
113
|
+
* forward door then allows only the hosts the secrets were granted to. */
|
|
114
|
+
network?: { level: NetworkLevel; hosts: string[] };
|
|
115
|
+
/** Refuse a site off the list (true), or allow it and name it in the
|
|
116
|
+
* trace (false: report only). Set by the platform when it leases. */
|
|
117
|
+
enforce?: boolean;
|
|
102
118
|
}
|
|
103
119
|
|
|
104
120
|
export interface EgressLease {
|
|
105
121
|
/** The base the pod sends through: `http://…/e/<token>`. */
|
|
106
122
|
url: string;
|
|
123
|
+
/** The forward-proxy address for HTTP_PROXY / HTTPS_PROXY —
|
|
124
|
+
* `http://foldrun:<token>@host:port` — when the platform's proxy has the
|
|
125
|
+
* forward door. Absent on an older proxy: programs then go direct. */
|
|
126
|
+
proxy?: string;
|
|
127
|
+
/** The PEM of the CA the forward door signs with for a host a secret is
|
|
128
|
+
* bound to, so the sandbox can trust exactly those interceptions. */
|
|
129
|
+
ca?: string;
|
|
130
|
+
/** Whether a site off the step's list is refused (true) or only named
|
|
131
|
+
* in the trace (false). */
|
|
132
|
+
enforce?: boolean;
|
|
107
133
|
/** The grant is finished being built — publish it. The runner keeps
|
|
108
134
|
* adding grants after the lease exists (the model key's host needs the
|
|
109
135
|
* lease URL first), so a store that is not this process's memory has
|