@foldrun/core 0.7.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/dist/src/completions.js +1 -0
- package/dist/src/deploy.js +5 -0
- package/dist/src/egress.d.ts +21 -0
- package/dist/src/egress.js +8 -0
- package/dist/src/hooks.d.ts +114 -0
- package/dist/src/hooks.js +370 -0
- package/dist/src/kinds.d.ts +1 -1
- package/dist/src/kinds.js +26 -0
- package/dist/src/library.d.ts +1 -1
- package/dist/src/library.js +17 -1
- package/dist/src/mail.js +1 -1
- package/dist/src/network.d.ts +120 -0
- package/dist/src/network.js +360 -0
- package/dist/src/notify.d.ts +8 -0
- package/dist/src/notify.js +10 -1
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +21 -1
- package/dist/src/run-container.d.ts +13 -0
- package/dist/src/run-container.js +8 -1
- package/dist/src/runner.d.ts +1 -0
- package/dist/src/runner.js +144 -16
- package/dist/src/secrets.d.ts +20 -0
- package/dist/src/secrets.js +65 -1
- package/dist/src/starter.js +9 -1
- package/dist/src/step-exec.d.ts +10 -0
- package/dist/src/step-exec.js +113 -2
- package/dist/src/store.d.ts +23 -1
- package/dist/src/store.js +33 -1
- package/dist/src/tool-loop.d.ts +45 -0
- package/dist/src/tool-loop.js +133 -0
- package/package.json +19 -1
- package/src/completions.ts +1 -0
- package/src/deploy.ts +4 -0
- package/src/egress.ts +26 -0
- package/src/hooks.ts +406 -0
- package/src/kinds.ts +28 -0
- package/src/library.ts +18 -2
- package/src/mail.ts +1 -1
- package/src/network.ts +390 -0
- package/src/notify.ts +11 -1
- package/src/providers.ts +27 -1
- package/src/run-container.ts +16 -2
- package/src/runner.ts +145 -14
- package/src/secrets.ts +69 -1
- package/src/starter.ts +9 -1
- package/src/step-exec.ts +115 -3
- package/src/store.ts +39 -1
- package/src/tool-loop.ts +176 -0
package/src/store.ts
CHANGED
|
@@ -49,7 +49,8 @@ import { refNames } from "./refs.ts";
|
|
|
49
49
|
import { parseBudget, budgetProblem } from "./budget.ts";
|
|
50
50
|
import { timezoneProblem } from "./clock.ts";
|
|
51
51
|
import { parseStepLimits, readLimits } from "./limits.ts";
|
|
52
|
-
import {
|
|
52
|
+
import { networkProblems, resolveNetwork, type NetworkPolicy } from "./network.ts";
|
|
53
|
+
import { webProblems, webWarnings, runnerEngines, webConfig } from "./providers.ts";
|
|
53
54
|
import { actionProblems } from "./web-actions.ts";
|
|
54
55
|
import { languageProblem } from "./language.ts";
|
|
55
56
|
import { localeProblems } from "./locale.ts";
|
|
@@ -84,6 +85,7 @@ export const WORKSPACE_DIRS = [
|
|
|
84
85
|
"knowledge",
|
|
85
86
|
"tools",
|
|
86
87
|
"scripts",
|
|
88
|
+
"hooks",
|
|
87
89
|
"state",
|
|
88
90
|
] as const;
|
|
89
91
|
|
|
@@ -994,6 +996,15 @@ export interface AgentInfo {
|
|
|
994
996
|
/** What is wrong with the `limits:` block's shape; key-vs-grant problems
|
|
995
997
|
* need the tool lists, and `check` adds them (limits.ts limitKeyProblems). */
|
|
996
998
|
limitProblems: string[];
|
|
999
|
+
/** What cannot be read in `network:` (network.ts) — a level that is not
|
|
1000
|
+
* one, a site with a scheme or a path. */
|
|
1001
|
+
networkProblems: string[];
|
|
1002
|
+
/** The sites this agent's steps may reach, resolved the way a run
|
|
1003
|
+
* resolves them — its `network:`, the workspace's, the account's, and
|
|
1004
|
+
* the default (open when it may fetch or browse the web, else trusted).
|
|
1005
|
+
* `implied` here is its `apis:` hosts; a run also adds its MCP servers',
|
|
1006
|
+
* its web providers' and its bound secrets' sites. */
|
|
1007
|
+
network: NetworkPolicy & { readsWeb: boolean };
|
|
997
1008
|
/** Tools this agent declares inline rather than in `tools:` — `apis:`,
|
|
998
1009
|
* `scripts:` and `mcpServers:` names — which a limit may also name. */
|
|
999
1010
|
inlineTools: string[];
|
|
@@ -1535,6 +1546,16 @@ function inlineToolNames(data: Record<string, unknown>): string[] {
|
|
|
1535
1546
|
export function listAgents(tenant: string, workspace: string): AgentInfo[] {
|
|
1536
1547
|
const dir = path.join(workspaceDir(tenant, workspace), "agents");
|
|
1537
1548
|
if (!fs.existsSync(dir)) return [];
|
|
1549
|
+
// The two levels above every agent, for the network: cascade.
|
|
1550
|
+
const frontOf = (file: string): Record<string, unknown> => {
|
|
1551
|
+
try {
|
|
1552
|
+
return readFrontmatter(file);
|
|
1553
|
+
} catch {
|
|
1554
|
+
return {}; // no AGENTS.md at that level, or one that does not parse
|
|
1555
|
+
}
|
|
1556
|
+
};
|
|
1557
|
+
const accountFront = frontOf(path.join(accountDir(tenant), "AGENTS.md"));
|
|
1558
|
+
const workspaceFront = frontOf(path.join(workspaceDir(tenant, workspace), "AGENTS.md"));
|
|
1538
1559
|
return fs
|
|
1539
1560
|
.readdirSync(dir)
|
|
1540
1561
|
.filter((n) => fs.existsSync(path.join(dir, n, "agent.md")))
|
|
@@ -1542,7 +1563,20 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
|
|
|
1542
1563
|
.map((name) => {
|
|
1543
1564
|
const { data } = matter(fs.readFileSync(path.join(dir, name, "agent.md"), "utf8"));
|
|
1544
1565
|
const tools = refNames(data.tools);
|
|
1566
|
+
const actions = webConfig(data, { ...accountFront, ...workspaceFront }).actions;
|
|
1567
|
+
const readsWeb = tools.includes("web") && (!actions || actions.some((a) => a === "fetch" || a === "browse"));
|
|
1568
|
+
const network = {
|
|
1569
|
+
...resolveNetwork({
|
|
1570
|
+
account: accountFront.network,
|
|
1571
|
+
workspace: workspaceFront.network,
|
|
1572
|
+
agent: data.network,
|
|
1573
|
+
readsWeb,
|
|
1574
|
+
implied: parseApis(data.apis).map((a) => { try { return new URL(a.base).host; } catch { return ""; } }),
|
|
1575
|
+
}),
|
|
1576
|
+
readsWeb,
|
|
1577
|
+
};
|
|
1545
1578
|
return {
|
|
1579
|
+
network,
|
|
1546
1580
|
name,
|
|
1547
1581
|
description: data.description ?? "",
|
|
1548
1582
|
model: data.model ?? "default",
|
|
@@ -1566,6 +1600,7 @@ export function listAgents(tenant: string, workspace: string): AgentInfo[] {
|
|
|
1566
1600
|
webWarnings: webWarnings(data, { chrome: runnerEngines()?.chrome, chromium: runnerEngines()?.chromium }),
|
|
1567
1601
|
scheduleProblem: agentScheduleProblem(data),
|
|
1568
1602
|
...(() => { const l = readLimits(data.limits); return { limits: l.limits, limitProblems: l.problems }; })(),
|
|
1603
|
+
networkProblems: networkProblems(data.network),
|
|
1569
1604
|
inlineTools: inlineToolNames(data),
|
|
1570
1605
|
};
|
|
1571
1606
|
});
|
|
@@ -3024,6 +3059,9 @@ export interface RunEvent {
|
|
|
3024
3059
|
* detail), `output: json`, a schema. What the runner reads to decline a
|
|
3025
3060
|
* retry of an outward step whose tools already ran. */
|
|
3026
3061
|
check?: boolean;
|
|
3062
|
+
/** Set on the line a hook wrote — a deny, a rewrite, context, a failure
|
|
3063
|
+
* (hooks.ts). The run page and the Hooks page read these. */
|
|
3064
|
+
hook?: { hook: string; event: string; did: string; tool?: string; reason?: string; ms: number };
|
|
3027
3065
|
}
|
|
3028
3066
|
|
|
3029
3067
|
/** A question an agent asked a person while its step ran (tools: [ask]). */
|
package/src/tool-loop.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { query, createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
19
|
+
import { hostSafeEnv } from "./host-env.ts";
|
|
20
|
+
|
|
21
|
+
/** One string argument: what it means, whether it may be left out, how long it may be. */
|
|
22
|
+
export interface LoopArg {
|
|
23
|
+
description?: string;
|
|
24
|
+
optional?: boolean;
|
|
25
|
+
max?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LoopTool {
|
|
29
|
+
name: string;
|
|
30
|
+
description: string;
|
|
31
|
+
args: Record<string, LoopArg>;
|
|
32
|
+
/** The answer the model reads. A throw becomes an error result, not a crash. */
|
|
33
|
+
run: (args: Record<string, string | undefined>) => Promise<string> | string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LoopUsage {
|
|
37
|
+
/** Everything sent, cache reads and writes included. */
|
|
38
|
+
inputTokens: number;
|
|
39
|
+
outputTokens: number;
|
|
40
|
+
costUsd: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LoopResult {
|
|
44
|
+
/** The last turn's text — the answer, not the thinking aloud between tool calls. */
|
|
45
|
+
text: string;
|
|
46
|
+
/** What the SDK reports the query cost; 0 when it never reported (killed). */
|
|
47
|
+
costUsd: number;
|
|
48
|
+
usage: LoopUsage | null;
|
|
49
|
+
/** Why it ended, when it did not end well: max turns, budget, wall clock. */
|
|
50
|
+
stopped: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface LoopOptions {
|
|
54
|
+
system: string;
|
|
55
|
+
prompt: string;
|
|
56
|
+
tools: LoopTool[];
|
|
57
|
+
model: string;
|
|
58
|
+
/** The model credential and anything else the SDK process needs. */
|
|
59
|
+
env: Record<string, string | undefined>;
|
|
60
|
+
maxTurns: number;
|
|
61
|
+
maxBudgetUsd: number;
|
|
62
|
+
maxOutputTokens: number;
|
|
63
|
+
wallSeconds: number;
|
|
64
|
+
/** The reply as it is written, for a live view. */
|
|
65
|
+
onText?: (delta: string) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const SERVER = "foldrun_tools";
|
|
69
|
+
|
|
70
|
+
/** The Agent SDK's name for one of our tools. */
|
|
71
|
+
export const loopToolName = (name: string) => `mcp__${SERVER}__${name}`;
|
|
72
|
+
|
|
73
|
+
function schemaOf(args: Record<string, LoopArg>): z.ZodRawShape {
|
|
74
|
+
return Object.fromEntries(
|
|
75
|
+
Object.entries(args).map(([name, a]) => {
|
|
76
|
+
let s = z.string().min(a.optional ? 0 : 1);
|
|
77
|
+
if (a.max) s = s.max(a.max);
|
|
78
|
+
if (a.description) s = s.describe(a.description);
|
|
79
|
+
return [name, a.optional ? s.optional() : s];
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function runToolLoop(o: LoopOptions): Promise<LoopResult> {
|
|
85
|
+
const server = createSdkMcpServer({
|
|
86
|
+
name: SERVER,
|
|
87
|
+
version: "1.0.0",
|
|
88
|
+
tools: o.tools.map((t) =>
|
|
89
|
+
tool(t.name, t.description, schemaOf(t.args), async (args: Record<string, unknown>) => {
|
|
90
|
+
try {
|
|
91
|
+
const plain = Object.fromEntries(Object.entries(args).map(([k, v]) => [k, v === undefined ? undefined : String(v)]));
|
|
92
|
+
return { content: [{ type: "text" as const, text: await t.run(plain) }] };
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return { content: [{ type: "text" as const, text: `error: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
95
|
+
}
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
});
|
|
99
|
+
// Homeless on purpose: there are no file tools, and a throwaway cwd means
|
|
100
|
+
// even a confused call has no tree to touch.
|
|
101
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-loop-"));
|
|
102
|
+
const abort = new AbortController();
|
|
103
|
+
let killed = false;
|
|
104
|
+
const watchdog = setTimeout(() => {
|
|
105
|
+
killed = true;
|
|
106
|
+
abort.abort();
|
|
107
|
+
}, o.wallSeconds * 1000);
|
|
108
|
+
watchdog.unref?.();
|
|
109
|
+
|
|
110
|
+
let text = "";
|
|
111
|
+
let costUsd = 0;
|
|
112
|
+
let usage: LoopUsage | null = null;
|
|
113
|
+
let stopped: string | null = null;
|
|
114
|
+
try {
|
|
115
|
+
const q = query({
|
|
116
|
+
prompt: o.prompt,
|
|
117
|
+
options: {
|
|
118
|
+
model: o.model,
|
|
119
|
+
systemPrompt: o.system,
|
|
120
|
+
// No built-in tool at all (no Task, so no sub-agents either) — only
|
|
121
|
+
// ours, each pre-approved; anything else is refused unasked.
|
|
122
|
+
tools: [],
|
|
123
|
+
mcpServers: { [SERVER]: server },
|
|
124
|
+
strictMcpConfig: true,
|
|
125
|
+
allowedTools: o.tools.map((t) => loopToolName(t.name)),
|
|
126
|
+
permissionMode: "dontAsk",
|
|
127
|
+
settingSources: [],
|
|
128
|
+
persistSession: false,
|
|
129
|
+
maxTurns: o.maxTurns,
|
|
130
|
+
maxBudgetUsd: o.maxBudgetUsd,
|
|
131
|
+
includePartialMessages: true,
|
|
132
|
+
cwd: scratch,
|
|
133
|
+
abortController: abort,
|
|
134
|
+
env: { ...hostSafeEnv(), ...o.env, CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(o.maxOutputTokens) },
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
let turn = "";
|
|
138
|
+
for await (const message of q) {
|
|
139
|
+
if (message.type === "stream_event") {
|
|
140
|
+
const e = message.event;
|
|
141
|
+
if (e.type === "message_start") turn = "";
|
|
142
|
+
if (e.type === "content_block_delta" && e.delta.type === "text_delta") {
|
|
143
|
+
turn += e.delta.text;
|
|
144
|
+
o.onText?.(e.delta.text);
|
|
145
|
+
}
|
|
146
|
+
} else if (message.type === "assistant") {
|
|
147
|
+
const said = message.message.content
|
|
148
|
+
.filter((b) => b.type === "text")
|
|
149
|
+
.map((b) => (b as { text: string }).text)
|
|
150
|
+
.join("");
|
|
151
|
+
if (said.trim()) text = said;
|
|
152
|
+
} else if (message.type === "result") {
|
|
153
|
+
costUsd = message.total_cost_usd ?? 0;
|
|
154
|
+
const models = Object.values(message.modelUsage ?? {});
|
|
155
|
+
usage = {
|
|
156
|
+
inputTokens: models.reduce((n, m) => n + (m.inputTokens ?? 0) + (m.cacheReadInputTokens ?? 0) + (m.cacheCreationInputTokens ?? 0), 0),
|
|
157
|
+
outputTokens: models.reduce((n, m) => n + (m.outputTokens ?? 0), 0),
|
|
158
|
+
costUsd,
|
|
159
|
+
};
|
|
160
|
+
if (message.subtype === "success") {
|
|
161
|
+
if (message.result?.trim()) text = message.result;
|
|
162
|
+
} else {
|
|
163
|
+
stopped = message.subtype;
|
|
164
|
+
if (!text) text = turn;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (!killed) throw err;
|
|
170
|
+
} finally {
|
|
171
|
+
clearTimeout(watchdog);
|
|
172
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
if (killed) stopped = `ran past ${o.wallSeconds}s`;
|
|
175
|
+
return { text, costUsd, usage, stopped };
|
|
176
|
+
}
|