@foldrun/core 0.2.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/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- package/templates/hello/tools/wordcount/tool.md +22 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Agent-as-tool: consult a colleague without ending your turn.
|
|
2
|
+
//
|
|
3
|
+
// agents: [researcher] # in agent.md frontmatter
|
|
4
|
+
//
|
|
5
|
+
// gives the agent one tool per named colleague — consult_researcher(question)
|
|
6
|
+
// — that runs that agent's persona as a one-shot, toolless model call and
|
|
7
|
+
// returns its answer inline. "Toolless" is the design, not a shortcut: a
|
|
8
|
+
// consult is asking a specialist what they think, and a consultant who could
|
|
9
|
+
// edit files or call APIs mid-question would be the supervisor pattern the
|
|
10
|
+
// spec rejects, wearing a different hat. Depth is structurally one — the
|
|
11
|
+
// callee is built without an agents server of its own.
|
|
12
|
+
//
|
|
13
|
+
// The flow file still owns the orchestration: who may be consulted is
|
|
14
|
+
// declared per agent, cost lands on the consulting step, and nothing about
|
|
15
|
+
// what runs next is decided by a model.
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import matter from "gray-matter";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
23
|
+
import { resolveModel, resolveEffort, type Effort } from "./store.ts";
|
|
24
|
+
import { executeStep } from "./step-exec.ts";
|
|
25
|
+
import { refNames } from "./refs.ts";
|
|
26
|
+
|
|
27
|
+
export interface ConsultSpec {
|
|
28
|
+
name: string;
|
|
29
|
+
systemPrompt: string;
|
|
30
|
+
model: string;
|
|
31
|
+
/** A colleague answers at its own declared effort, not the caller's — the
|
|
32
|
+
* point of consulting one is that it is configured differently. */
|
|
33
|
+
effort: Effort | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read the consultable colleagues' personas, host-side. Names that don't
|
|
39
|
+
* resolve to an agent are reported, not silently dropped.
|
|
40
|
+
*/
|
|
41
|
+
export function gatherConsults(
|
|
42
|
+
workspaceRoot: string,
|
|
43
|
+
names: unknown,
|
|
44
|
+
): { consults: ConsultSpec[]; missing: string[] } {
|
|
45
|
+
const list = refNames(names);
|
|
46
|
+
const consults: ConsultSpec[] = [];
|
|
47
|
+
const missing: string[] = [];
|
|
48
|
+
for (const name of list) {
|
|
49
|
+
const file = path.join(workspaceRoot, "agents", name, "agent.md");
|
|
50
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name) || !fs.existsSync(file)) {
|
|
51
|
+
missing.push(name);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const parsed = matter(fs.readFileSync(file, "utf8"));
|
|
55
|
+
consults.push({
|
|
56
|
+
name,
|
|
57
|
+
systemPrompt:
|
|
58
|
+
`${parsed.content.trim()}\n\n` +
|
|
59
|
+
`You are being consulted by another agent mid-task. Answer the question ` +
|
|
60
|
+
`directly and completely in your reply — you have no tools and no files; ` +
|
|
61
|
+
`everything you know is in this prompt and the question.`,
|
|
62
|
+
model: resolveModel((parsed.data as { model?: string }).model),
|
|
63
|
+
effort: resolveEffort((parsed.data as { effort?: string }).effort),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return { consults, missing };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ConsultToolResult {
|
|
70
|
+
/** Passed to the SDK as an in-process MCP server (the agent just sees tools). */
|
|
71
|
+
server: ReturnType<typeof createSdkMcpServer> | null;
|
|
72
|
+
toolNames: string[];
|
|
73
|
+
/** Model spend by the consults so far — the caller adds it to its step. */
|
|
74
|
+
drainCost: () => number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The MCP server carrying one consult_<name> tool per colleague. Works in
|
|
79
|
+
* both homes — the server process and the runner container — because it
|
|
80
|
+
* takes values, not stores.
|
|
81
|
+
*/
|
|
82
|
+
export function buildConsultTools(
|
|
83
|
+
consults: ConsultSpec[],
|
|
84
|
+
env: Record<string, string | undefined>,
|
|
85
|
+
emit: (type: "info" | "error", text: string) => void,
|
|
86
|
+
): ConsultToolResult {
|
|
87
|
+
if (consults.length === 0) return { server: null, toolNames: [], drainCost: () => 0 };
|
|
88
|
+
let cost = 0;
|
|
89
|
+
|
|
90
|
+
const tools = consults.map((c) =>
|
|
91
|
+
tool(
|
|
92
|
+
`consult_${c.name.replaceAll("-", "_")}`,
|
|
93
|
+
`Ask ${c.name} — a colleague agent — one question and get their answer inline. ` +
|
|
94
|
+
`They see only your question, not your files or conversation; include everything they need in it.`,
|
|
95
|
+
{ question: z.string().describe("The complete, self-contained question") },
|
|
96
|
+
async (args) => {
|
|
97
|
+
const started = Date.now();
|
|
98
|
+
// Toolless and homeless on purpose: the consult reads and writes
|
|
99
|
+
// nothing — cwd is a throwaway so even a confused model call has no
|
|
100
|
+
// tree to touch (and no tools to touch it with).
|
|
101
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-consult-"));
|
|
102
|
+
try {
|
|
103
|
+
const outcome = await executeStep({
|
|
104
|
+
agentDir: scratch,
|
|
105
|
+
workspaceRoot: scratch,
|
|
106
|
+
libraryRoot: scratch,
|
|
107
|
+
prompt: args.question,
|
|
108
|
+
model: c.model,
|
|
109
|
+
effort: c.effort,
|
|
110
|
+
systemPrompt: c.systemPrompt,
|
|
111
|
+
allowed: [],
|
|
112
|
+
mcpNames: [],
|
|
113
|
+
mcpServers: {},
|
|
114
|
+
env,
|
|
115
|
+
emit: () => {}, // the consult's inner monologue stays its own
|
|
116
|
+
});
|
|
117
|
+
cost += outcome.costUsd ?? 0;
|
|
118
|
+
emit(
|
|
119
|
+
"info",
|
|
120
|
+
`consult ${c.name} → ${outcome.status} ($${(outcome.costUsd ?? 0).toFixed(4)}, ${Date.now() - started}ms)`,
|
|
121
|
+
);
|
|
122
|
+
return {
|
|
123
|
+
content: [{ type: "text" as const, text: outcome.result ?? "(no answer)" }],
|
|
124
|
+
isError: outcome.status === "failed",
|
|
125
|
+
};
|
|
126
|
+
} finally {
|
|
127
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
server: createSdkMcpServer({ name: "foldrun_agents", version: "1.0.0", tools }),
|
|
135
|
+
toolNames: consults.map((c) => `mcp__foldrun_agents__consult_${c.name.replaceAll("-", "_")}`),
|
|
136
|
+
drainCost: () => cost,
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/agents-md.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Editing AGENTS.md frontmatter one key at a time, in place.
|
|
2
|
+
//
|
|
3
|
+
// AGENTS.md is a file people write by hand — comments, spacing, key order
|
|
4
|
+
// are theirs. A settings form that re-serialised the whole frontmatter
|
|
5
|
+
// would erase all of that on the first Save. So a key is replaced
|
|
6
|
+
// textually: its block (the `key:` line and the indented lines under it)
|
|
7
|
+
// is cut out, and the new block is appended where it stood. Everything
|
|
8
|
+
// else in the file is untouched. Null removes the key.
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import matter from "gray-matter";
|
|
13
|
+
|
|
14
|
+
const FRONT = /^(---\r?\n)([\s\S]*?)(\r?\n---)(\r?\n|$)/;
|
|
15
|
+
|
|
16
|
+
/** The frontmatter of a directory's AGENTS.md (or project.md), or {}. */
|
|
17
|
+
export function readAgentsMdKeys(dir: string): Record<string, unknown> {
|
|
18
|
+
for (const name of ["AGENTS.md", "project.md"]) {
|
|
19
|
+
const file = path.join(dir, name);
|
|
20
|
+
if (!fs.existsSync(file)) continue;
|
|
21
|
+
try {
|
|
22
|
+
return (matter(fs.readFileSync(file, "utf8")).data ?? {}) as Record<string, unknown>;
|
|
23
|
+
} catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One key as YAML lines, the way gray-matter writes frontmatter — the same
|
|
31
|
+
* serialiser that already reads these files, so nothing new to disagree. */
|
|
32
|
+
function dumpKey(key: string, value: unknown): string[] {
|
|
33
|
+
const block = matter.stringify("", { [key]: value });
|
|
34
|
+
const m = block.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
35
|
+
return (m ? m[1] : `${key}: ${String(value)}`).trimEnd().split("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The text of `content` with each key in `patch` replaced (or removed for
|
|
39
|
+
* null), everything else byte for byte as it was. A file with no
|
|
40
|
+
* frontmatter gets one. */
|
|
41
|
+
export function patchFrontmatter(content: string, patch: Record<string, unknown | null>): string {
|
|
42
|
+
const m = content.match(FRONT);
|
|
43
|
+
let open = "---\n";
|
|
44
|
+
let body = "";
|
|
45
|
+
let close = "\n---";
|
|
46
|
+
let rest = content;
|
|
47
|
+
if (m) {
|
|
48
|
+
[, open, body, close] = m;
|
|
49
|
+
rest = content.slice(m[0].length - (m[4] ?? "").length);
|
|
50
|
+
} else {
|
|
51
|
+
rest = content.startsWith("\n") ? content : `\n${content}`;
|
|
52
|
+
}
|
|
53
|
+
let lines = body === "" ? [] : body.split("\n");
|
|
54
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
55
|
+
const at = lines.findIndex((l) => new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`).test(l));
|
|
56
|
+
let end = at;
|
|
57
|
+
if (at !== -1) {
|
|
58
|
+
end = at + 1;
|
|
59
|
+
// The block continues while lines are indented or blank-inside-block.
|
|
60
|
+
while (end < lines.length && (/^[ \t]/.test(lines[end]) || lines[end].trim() === "")) end++;
|
|
61
|
+
// A blank line at the block's foot belongs to what follows.
|
|
62
|
+
while (end > at + 1 && lines[end - 1].trim() === "") end--;
|
|
63
|
+
}
|
|
64
|
+
const replacement = value === null || value === undefined ? [] : dumpKey(key, value);
|
|
65
|
+
if (at === -1) lines = [...lines, ...replacement];
|
|
66
|
+
else lines = [...lines.slice(0, at), ...replacement, ...lines.slice(end)];
|
|
67
|
+
}
|
|
68
|
+
const front = lines.length ? `${open}${lines.join("\n")}${close}` : "";
|
|
69
|
+
if (!front) return rest.replace(/^(\r?\n)+/, "");
|
|
70
|
+
return `${front}${m ? m[4] ?? "\n" : "\n"}${rest.replace(/^\r?\n/, m ? "" : "")}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Apply a frontmatter patch to a directory's AGENTS.md, creating the file
|
|
74
|
+
* when the directory has none. Returns the new content. */
|
|
75
|
+
export function setAgentsMdKeys(dir: string, patch: Record<string, unknown | null>): string {
|
|
76
|
+
const file = path.join(dir, "AGENTS.md");
|
|
77
|
+
const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
78
|
+
const after = patchFrontmatter(before, patch);
|
|
79
|
+
fs.writeFileSync(file, after);
|
|
80
|
+
return after;
|
|
81
|
+
}
|
package/src/api-tools.ts
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// Turns an agent's `apis:` declarations into real tools it can call.
|
|
2
|
+
//
|
|
3
|
+
// One generic tool per API, named `call_<api>`: the agent supplies a method,
|
|
4
|
+
// path, optional query and body. When the API declares an `openapi:`
|
|
5
|
+
// document, each of its operations becomes a typed tool of its own,
|
|
6
|
+
// `<api>_<operationId>`, whose arguments are the parameters the document
|
|
7
|
+
// declares — path parameters filled into the template by the platform,
|
|
8
|
+
// query and header parameters routed to where they belong.
|
|
9
|
+
//
|
|
10
|
+
// Either way the platform resolves ${SECRET} placeholders from the encrypted
|
|
11
|
+
// store, enforces the declared base URL and method allowlist, and returns
|
|
12
|
+
// status + body. Credentials never enter the agent's context — it sees the
|
|
13
|
+
// tool, not the token. A `rate:` on the API is a token bucket per step:
|
|
14
|
+
// a call over the limit waits for its turn rather than failing.
|
|
15
|
+
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
import { EGRESS_ENV, viaEgress } from "./egress.ts";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { createSdkMcpServer, tool, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk";
|
|
20
|
+
import type { ApiSpec } from "./store.ts";
|
|
21
|
+
import type { OperationSpec } from "./openapi.ts";
|
|
22
|
+
import { resolveSecrets, materializeSecrets } from "./secrets.ts";
|
|
23
|
+
|
|
24
|
+
const MAX_BODY_CHARS = 20_000;
|
|
25
|
+
/** MCP tool names are capped; ours also carry the server prefix on the wire. */
|
|
26
|
+
const MAX_TOOL_NAME = 64;
|
|
27
|
+
/** How many typed tools the prompt lists by name before summarising. */
|
|
28
|
+
const PROMPT_LIST_CAP = 40;
|
|
29
|
+
/** A wait shorter than this is not worth a word in the log. */
|
|
30
|
+
const LOG_WAIT_MS = 1000;
|
|
31
|
+
|
|
32
|
+
function substitute(value: string, env: Record<string, string>) {
|
|
33
|
+
return value.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name: string) => env[name] ?? whole);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Which secret names does this API's config reference?
|
|
37
|
+
export function secretsUsedByApi(api: ApiSpec): string[] {
|
|
38
|
+
const found = new Set<string>();
|
|
39
|
+
for (const value of [...Object.values(api.headers), ...Object.values(api.query)]) {
|
|
40
|
+
for (const m of value.matchAll(/\$\{([A-Z][A-Z0-9_]*)\}/g)) found.add(m[1]);
|
|
41
|
+
}
|
|
42
|
+
return [...found];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A token bucket: `count` tokens, refilled at `perSec`. `take()` resolves
|
|
47
|
+
* when a token is available, in arrival order — a caller never learns "no",
|
|
48
|
+
* only "not yet". Lives as long as the tool set does, which is one step.
|
|
49
|
+
*/
|
|
50
|
+
export class TokenBucket {
|
|
51
|
+
private readonly count: number;
|
|
52
|
+
private readonly perSec: number;
|
|
53
|
+
private tokens: number;
|
|
54
|
+
private last = Date.now();
|
|
55
|
+
private chain: Promise<void> = Promise.resolve();
|
|
56
|
+
|
|
57
|
+
constructor(count: number, perSec: number) {
|
|
58
|
+
this.count = count;
|
|
59
|
+
this.perSec = perSec;
|
|
60
|
+
this.tokens = count;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private refill() {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
this.tokens = Math.min(this.count, this.tokens + ((now - this.last) / 1000) * this.perSec);
|
|
66
|
+
this.last = now;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Resolves with the milliseconds waited. */
|
|
70
|
+
take(): Promise<number> {
|
|
71
|
+
const started = Date.now();
|
|
72
|
+
const turn = this.chain.then(async () => {
|
|
73
|
+
for (;;) {
|
|
74
|
+
this.refill();
|
|
75
|
+
if (this.tokens >= 1) {
|
|
76
|
+
this.tokens -= 1;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const wait = Math.ceil(((1 - this.tokens) / this.perSec) * 1000);
|
|
80
|
+
await new Promise((r) => setTimeout(r, Math.max(1, wait)));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
// The queue must survive one caller's failure (there is none today, but
|
|
84
|
+
// a rejected link would freeze every later call behind it).
|
|
85
|
+
this.chain = turn.catch(() => {});
|
|
86
|
+
return turn.then(() => Date.now() - started);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `<api>_<operation>`, held under the MCP cap: a long name keeps its head
|
|
91
|
+
* and gains a hash tail, so two long names never collapse into one. */
|
|
92
|
+
export function typedToolName(apiName: string, opId: string): string {
|
|
93
|
+
const full = `${apiName}_${opId}`;
|
|
94
|
+
if (full.length <= MAX_TOOL_NAME) return full;
|
|
95
|
+
const hash = crypto.createHash("sha256").update(full).digest("hex").slice(0, 8);
|
|
96
|
+
return `${full.slice(0, MAX_TOOL_NAME - hash.length - 1)}_${hash}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ApiToolResult {
|
|
100
|
+
/** Passed to the SDK as an in-process MCP server (the agent just sees tools). */
|
|
101
|
+
server: ReturnType<typeof createSdkMcpServer> | null;
|
|
102
|
+
/** Tool names to allow, e.g. mcp__foldrun_apis__call_google_ads. */
|
|
103
|
+
toolNames: string[];
|
|
104
|
+
/** Secrets referenced but not set — surfaced as run warnings. */
|
|
105
|
+
missingSecrets: string[];
|
|
106
|
+
/** Human-readable lines describing each API, appended to the system prompt. */
|
|
107
|
+
promptLines: string[];
|
|
108
|
+
/** Called after the run to collect a log of every request made. */
|
|
109
|
+
drainLog: () => string[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface Request {
|
|
113
|
+
method: string;
|
|
114
|
+
/** Path after the base URL, already templated. */
|
|
115
|
+
path: string;
|
|
116
|
+
query: Record<string, string>;
|
|
117
|
+
headers: Record<string, string>;
|
|
118
|
+
body?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type ToolReply = { content: { type: "text"; text: string }[]; isError: boolean };
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The one request path both tool shapes share: rate limit, secrets made
|
|
125
|
+
* live, base-URL confinement, the API's own headers and query, the timeout,
|
|
126
|
+
* the truncation, the log line. Typed and generic tools differ only in how
|
|
127
|
+
* they arrive at a Request.
|
|
128
|
+
*/
|
|
129
|
+
async function performRequest(
|
|
130
|
+
api: ApiSpec,
|
|
131
|
+
env: Record<string, string>,
|
|
132
|
+
bucket: TokenBucket | null,
|
|
133
|
+
log: string[],
|
|
134
|
+
req: Request,
|
|
135
|
+
): Promise<ToolReply> {
|
|
136
|
+
const started = Date.now();
|
|
137
|
+
try {
|
|
138
|
+
const waited = bucket ? await bucket.take() : 0;
|
|
139
|
+
const waitNote = waited >= LOG_WAIT_MS ? ` [waited ${(waited / 1000).toFixed(1)}s for rate limit]` : "";
|
|
140
|
+
// Per call, not per build: an oauth2 secret's recipe becomes a
|
|
141
|
+
// live access token here, so a step that runs for an hour keeps
|
|
142
|
+
// getting fresh tokens (the exchange is cached until near expiry)
|
|
143
|
+
// — and a recipe can never end up inside a header.
|
|
144
|
+
const live = await materializeSecrets(env);
|
|
145
|
+
const path = req.path.startsWith("/") ? req.path : `/${req.path}`;
|
|
146
|
+
const url = new URL(api.base + path);
|
|
147
|
+
// Base-URL confinement: a path can't escape the declared host.
|
|
148
|
+
if (!url.href.startsWith(api.base)) {
|
|
149
|
+
throw new Error(`path escapes the declared base URL (${api.base})`);
|
|
150
|
+
}
|
|
151
|
+
for (const [k, v] of Object.entries(api.query)) {
|
|
152
|
+
url.searchParams.set(k, substitute(v, live));
|
|
153
|
+
}
|
|
154
|
+
for (const [k, v] of Object.entries(req.query)) url.searchParams.set(k, v);
|
|
155
|
+
|
|
156
|
+
const headers: Record<string, string> = {};
|
|
157
|
+
for (const [k, v] of Object.entries(api.headers)) headers[k] = substitute(v, live);
|
|
158
|
+
for (const [k, v] of Object.entries(req.headers)) headers[k] = v;
|
|
159
|
+
if (req.body && !headers["content-type"] && !headers["Content-Type"]) {
|
|
160
|
+
headers["content-type"] = "application/json";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Through the egress proxy when this sandbox was given one: the header
|
|
164
|
+
// still says ${NAME}, and the worker fills it for this host. Without
|
|
165
|
+
// one, straight to the API with the value the env held — the CLI, tests,
|
|
166
|
+
// and an install with no proxy.
|
|
167
|
+
const res = await fetch(viaEgress(env[EGRESS_ENV], url.href), {
|
|
168
|
+
method: req.method,
|
|
169
|
+
headers,
|
|
170
|
+
body: req.body,
|
|
171
|
+
// The tool.md's `timeout:` is the only clock; none set, none applied.
|
|
172
|
+
...(api.timeout ? { signal: AbortSignal.timeout(api.timeout * 1000) } : {}),
|
|
173
|
+
});
|
|
174
|
+
const text = await res.text();
|
|
175
|
+
const truncated =
|
|
176
|
+
text.length > MAX_BODY_CHARS
|
|
177
|
+
? `${text.slice(0, MAX_BODY_CHARS)}\n…[truncated ${text.length - MAX_BODY_CHARS} chars]`
|
|
178
|
+
: text;
|
|
179
|
+
|
|
180
|
+
log.push(
|
|
181
|
+
`${req.method} ${url.pathname}${url.search ? "?…" : ""} → ${res.status} (${Date.now() - started}ms)${waitNote}`,
|
|
182
|
+
);
|
|
183
|
+
return {
|
|
184
|
+
content: [{ type: "text" as const, text: `HTTP ${res.status} ${res.statusText}\n\n${truncated}` }],
|
|
185
|
+
isError: !res.ok,
|
|
186
|
+
};
|
|
187
|
+
} catch (err) {
|
|
188
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
189
|
+
log.push(`${req.method} ${req.path} → error: ${message}`);
|
|
190
|
+
return { content: [{ type: "text" as const, text: `Request failed: ${message}` }], isError: true };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const rateSentence = (api: ApiSpec) =>
|
|
195
|
+
api.rate
|
|
196
|
+
? ` Rate limit: ${api.rate.count} call${api.rate.count === 1 ? "" : "s"} per ` +
|
|
197
|
+
`${api.rate.perSec === api.rate.count ? "second" : `${Math.round(api.rate.count / api.rate.perSec)}s`}, ` +
|
|
198
|
+
`shared by every call this step makes to ${api.name}; a call over the limit waits its turn.`
|
|
199
|
+
: "";
|
|
200
|
+
|
|
201
|
+
/** Zod for one declared parameter, typed as the document says. */
|
|
202
|
+
function paramSchema(p: OperationSpec["params"][number]) {
|
|
203
|
+
const base =
|
|
204
|
+
p.type === "integer" ? z.number().int() : p.type === "number" ? z.number() : p.type === "boolean" ? z.boolean() : z.string();
|
|
205
|
+
const described = base.describe(p.description || `${p.in} parameter ${p.name}`);
|
|
206
|
+
return p.required ? described : described.optional();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Argument keys must be identifiers the model can write; parameter names
|
|
210
|
+
* need not be (`filter[status]`). Map one to the other, keeping a
|
|
211
|
+
* collision between locations apart with a suffix. */
|
|
212
|
+
function argKeys(op: OperationSpec): Map<string, OperationSpec["params"][number]> {
|
|
213
|
+
const out = new Map<string, OperationSpec["params"][number]>();
|
|
214
|
+
for (const p of op.params) {
|
|
215
|
+
let key = p.name.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || p.in;
|
|
216
|
+
if (/^\d/.test(key)) key = `_${key}`;
|
|
217
|
+
if (out.has(key)) key = `${key}_${p.in}`;
|
|
218
|
+
let n = 2;
|
|
219
|
+
while (out.has(key)) key = `${key}_${n++}`;
|
|
220
|
+
out.set(key, p);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function typedTool(
|
|
226
|
+
api: ApiSpec,
|
|
227
|
+
op: OperationSpec,
|
|
228
|
+
env: Record<string, string>,
|
|
229
|
+
bucket: TokenBucket | null,
|
|
230
|
+
log: string[],
|
|
231
|
+
) {
|
|
232
|
+
const keys = argKeys(op);
|
|
233
|
+
const shape: Record<string, z.ZodTypeAny> = {};
|
|
234
|
+
for (const [key, p] of keys) shape[key] = paramSchema(p);
|
|
235
|
+
if (op.hasBody) {
|
|
236
|
+
shape.body = z
|
|
237
|
+
.string()
|
|
238
|
+
.optional()
|
|
239
|
+
.describe(`Request body as a JSON string. ${op.bodyDescription}`.trim());
|
|
240
|
+
}
|
|
241
|
+
const description =
|
|
242
|
+
`${op.summary} — ${op.method} ${op.path} on the ${api.name} API. ` +
|
|
243
|
+
`Authentication is added automatically — never ask the user for credentials.` +
|
|
244
|
+
rateSentence(api);
|
|
245
|
+
|
|
246
|
+
return tool(typedToolName(api.name, op.id), description, shape, async (args) => {
|
|
247
|
+
const values = args as Record<string, unknown>;
|
|
248
|
+
const query: Record<string, string> = {};
|
|
249
|
+
const headers: Record<string, string> = {};
|
|
250
|
+
const pathValues: Record<string, string> = {};
|
|
251
|
+
for (const [key, p] of keys) {
|
|
252
|
+
const v = values[key];
|
|
253
|
+
if (v === undefined || v === null) continue;
|
|
254
|
+
const s = String(v);
|
|
255
|
+
if (p.in === "path") pathValues[p.name] = s;
|
|
256
|
+
else if (p.in === "query") query[p.name] = s;
|
|
257
|
+
else headers[p.name] = s;
|
|
258
|
+
}
|
|
259
|
+
let missing: string | null = null;
|
|
260
|
+
const path = op.path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
261
|
+
if (!(name in pathValues)) {
|
|
262
|
+
missing ??= name;
|
|
263
|
+
return "";
|
|
264
|
+
}
|
|
265
|
+
return encodeURIComponent(pathValues[name]);
|
|
266
|
+
});
|
|
267
|
+
if (missing) {
|
|
268
|
+
log.push(`${op.method} ${op.path} → error: path parameter ${missing} not supplied`);
|
|
269
|
+
return {
|
|
270
|
+
content: [{ type: "text" as const, text: `Request failed: path parameter "${missing}" is required` }],
|
|
271
|
+
isError: true,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return performRequest(api, env, bucket, log, {
|
|
275
|
+
method: op.method,
|
|
276
|
+
path,
|
|
277
|
+
query,
|
|
278
|
+
headers,
|
|
279
|
+
body: typeof values.body === "string" ? values.body : undefined,
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function genericTool(api: ApiSpec, env: Record<string, string>, bucket: TokenBucket | null, log: string[]) {
|
|
285
|
+
const typed = api.resolvedOperations?.length
|
|
286
|
+
? ` Prefer the typed ${api.name}_* tools; this one is the escape hatch for operations they do not cover.`
|
|
287
|
+
: "";
|
|
288
|
+
return tool(
|
|
289
|
+
`call_${api.name}`,
|
|
290
|
+
`Call the ${api.name} API. ${api.description} Base URL: ${api.base}. ` +
|
|
291
|
+
`Allowed methods: ${api.methods.join(", ")}. Provide only the path after the base URL. ` +
|
|
292
|
+
`Authentication is added automatically — never ask the user for credentials.` +
|
|
293
|
+
typed +
|
|
294
|
+
rateSentence(api),
|
|
295
|
+
{
|
|
296
|
+
method: z.enum(api.methods as [string, ...string[]]).describe("HTTP method"),
|
|
297
|
+
path: z.string().describe("Path appended to the base URL, e.g. /customers/123/campaigns"),
|
|
298
|
+
query: z.record(z.string(), z.string()).optional().describe("Query parameters"),
|
|
299
|
+
body: z.string().optional().describe("Request body — JSON string for JSON APIs"),
|
|
300
|
+
},
|
|
301
|
+
(args) =>
|
|
302
|
+
performRequest(api, env, bucket, log, {
|
|
303
|
+
method: args.method,
|
|
304
|
+
path: args.path,
|
|
305
|
+
query: args.query ?? {},
|
|
306
|
+
headers: {},
|
|
307
|
+
body: args.body,
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function buildApiTools(
|
|
313
|
+
tenant: string,
|
|
314
|
+
apis: ApiSpec[],
|
|
315
|
+
workspace?: string,
|
|
316
|
+
// The runner container passes this: secrets were resolved before the specs
|
|
317
|
+
// crossed the boundary, and there is no vault in there to ask.
|
|
318
|
+
resolved?: { env: Record<string, string | undefined>; missing: string[] },
|
|
319
|
+
): ApiToolResult {
|
|
320
|
+
if (apis.length === 0) {
|
|
321
|
+
return { server: null, toolNames: [], missingSecrets: [], promptLines: [], drainLog: () => [] };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const neededSecrets = [...new Set(apis.flatMap(secretsUsedByApi))];
|
|
325
|
+
const { env, missing } = resolved
|
|
326
|
+
? {
|
|
327
|
+
env: Object.fromEntries(
|
|
328
|
+
Object.entries(resolved.env).filter(
|
|
329
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
330
|
+
),
|
|
331
|
+
),
|
|
332
|
+
missing: resolved.missing,
|
|
333
|
+
}
|
|
334
|
+
: resolveSecrets(tenant, neededSecrets, workspace);
|
|
335
|
+
const log: string[] = [];
|
|
336
|
+
|
|
337
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the SDK types its own list this way
|
|
338
|
+
const tools: SdkMcpToolDefinition<any>[] = [];
|
|
339
|
+
const toolNames: string[] = [];
|
|
340
|
+
const promptLines: string[] = [];
|
|
341
|
+
|
|
342
|
+
for (const api of apis) {
|
|
343
|
+
// One bucket per API per step: the limit is the vendor's, the scope is
|
|
344
|
+
// this process. Two steps on two workers each get their own.
|
|
345
|
+
const bucket = api.rate ? new TokenBucket(api.rate.count, api.rate.perSec) : null;
|
|
346
|
+
const ops = api.resolvedOperations ?? [];
|
|
347
|
+
// An explicit `operations:` list means "only these": no escape hatch.
|
|
348
|
+
const withGeneric = !(api.operations && ops.length);
|
|
349
|
+
|
|
350
|
+
const typedNames: string[] = [];
|
|
351
|
+
for (const op of ops) {
|
|
352
|
+
tools.push(typedTool(api, op, env, bucket, log));
|
|
353
|
+
typedNames.push(typedToolName(api.name, op.id));
|
|
354
|
+
}
|
|
355
|
+
if (withGeneric) tools.push(genericTool(api, env, bucket, log));
|
|
356
|
+
|
|
357
|
+
const names = [...typedNames, ...(withGeneric ? [`call_${api.name}`] : [])];
|
|
358
|
+
toolNames.push(...names.map((n) => `mcp__foldrun_apis__${n}`));
|
|
359
|
+
|
|
360
|
+
const rateLine = api.rate ? ` Rate limit ${api.rate.count}/${Math.round(api.rate.count / api.rate.perSec)}s — over it, calls wait.` : "";
|
|
361
|
+
if (ops.length) {
|
|
362
|
+
const listed = ops.slice(0, PROMPT_LIST_CAP).map((op) => ` - \`${typedToolName(api.name, op.id)}\` — ${op.summary}`);
|
|
363
|
+
const more = ops.length > PROMPT_LIST_CAP ? [` - …and ${ops.length - PROMPT_LIST_CAP} more`] : [];
|
|
364
|
+
promptLines.push(
|
|
365
|
+
`- **${api.name}** — ${api.description} Base URL \`${api.base}\`. ` +
|
|
366
|
+
`${ops.length} typed tool${ops.length === 1 ? "" : "s"}` +
|
|
367
|
+
(withGeneric ? `, plus \`call_${api.name}\` for anything they do not cover` : "") +
|
|
368
|
+
`. Credentials are injected for you.${rateLine}`,
|
|
369
|
+
...listed,
|
|
370
|
+
...more,
|
|
371
|
+
);
|
|
372
|
+
} else {
|
|
373
|
+
promptLines.push(
|
|
374
|
+
`- **${api.name}** — call with the \`call_${api.name}\` tool. ${api.description} ` +
|
|
375
|
+
`Base URL \`${api.base}\`; allowed methods ${api.methods.join(", ")}. Credentials are injected for you.${rateLine}`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const server = createSdkMcpServer({ name: "foldrun_apis", version: "1.0.0", tools });
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
server,
|
|
384
|
+
toolNames,
|
|
385
|
+
missingSecrets: missing,
|
|
386
|
+
promptLines,
|
|
387
|
+
drainLog: () => log.splice(0, log.length),
|
|
388
|
+
};
|
|
389
|
+
}
|