@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.
Files changed (49) hide show
  1. package/README.md +14 -1
  2. package/dist/src/completions.js +1 -0
  3. package/dist/src/deploy.js +5 -0
  4. package/dist/src/egress.d.ts +21 -0
  5. package/dist/src/egress.js +8 -0
  6. package/dist/src/hooks.d.ts +114 -0
  7. package/dist/src/hooks.js +370 -0
  8. package/dist/src/kinds.d.ts +1 -1
  9. package/dist/src/kinds.js +26 -0
  10. package/dist/src/library.d.ts +1 -1
  11. package/dist/src/library.js +17 -1
  12. package/dist/src/mail.js +1 -1
  13. package/dist/src/network.d.ts +120 -0
  14. package/dist/src/network.js +360 -0
  15. package/dist/src/notify.d.ts +8 -0
  16. package/dist/src/notify.js +10 -1
  17. package/dist/src/providers.d.ts +5 -0
  18. package/dist/src/providers.js +21 -1
  19. package/dist/src/run-container.d.ts +13 -0
  20. package/dist/src/run-container.js +8 -1
  21. package/dist/src/runner.d.ts +1 -0
  22. package/dist/src/runner.js +144 -16
  23. package/dist/src/secrets.d.ts +20 -0
  24. package/dist/src/secrets.js +65 -1
  25. package/dist/src/starter.js +9 -1
  26. package/dist/src/step-exec.d.ts +10 -0
  27. package/dist/src/step-exec.js +113 -2
  28. package/dist/src/store.d.ts +23 -1
  29. package/dist/src/store.js +33 -1
  30. package/dist/src/tool-loop.d.ts +45 -0
  31. package/dist/src/tool-loop.js +133 -0
  32. package/package.json +19 -1
  33. package/src/completions.ts +1 -0
  34. package/src/deploy.ts +4 -0
  35. package/src/egress.ts +26 -0
  36. package/src/hooks.ts +406 -0
  37. package/src/kinds.ts +28 -0
  38. package/src/library.ts +18 -2
  39. package/src/mail.ts +1 -1
  40. package/src/network.ts +390 -0
  41. package/src/notify.ts +11 -1
  42. package/src/providers.ts +27 -1
  43. package/src/run-container.ts +16 -2
  44. package/src/runner.ts +145 -14
  45. package/src/secrets.ts +69 -1
  46. package/src/starter.ts +9 -1
  47. package/src/step-exec.ts +115 -3
  48. package/src/store.ts +39 -1
  49. 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 { webProblems, webWarnings, runnerEngines } from "./providers.ts";
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]). */
@@ -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
+ }