@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,335 @@
|
|
|
1
|
+
// Call a tool once, now, and say what happened.
|
|
2
|
+
//
|
|
3
|
+
// Without this, the first time anyone finds out a tool is misconfigured is
|
|
4
|
+
// mid-flow, inside an agent's turn, as a tool error the model paraphrases into
|
|
5
|
+
// something vague. A wrong `base:`, a secret that was never set, a script path
|
|
6
|
+
// with a typo — all of them look identical from the outside: the agent just
|
|
7
|
+
// doesn't use the tool and says something plausible instead.
|
|
8
|
+
//
|
|
9
|
+
// So each transport gets a real exercise, not a syntax check:
|
|
10
|
+
//
|
|
11
|
+
// http an actual request to the declared base URL
|
|
12
|
+
// script actually runs it, with the workspace's secrets in the environment
|
|
13
|
+
// mcp actually starts the server and completes an MCP handshake
|
|
14
|
+
//
|
|
15
|
+
// Nothing here returns a secret value. Missing secrets are reported by *name*,
|
|
16
|
+
// because "which one didn't you set" is the useful half and the value is not.
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { resolveSecrets } from "./secrets.js";
|
|
21
|
+
import { listAgents, workspaceDir } from "./store.js";
|
|
22
|
+
import { libraryDir } from "./library.js";
|
|
23
|
+
import { secretsUsedByApi } from "./api-tools.js";
|
|
24
|
+
import { commandFor, resolveRunPath } from "./script-tools.js";
|
|
25
|
+
import { parseRuntime, prepareRuntime } from "./runtime.js";
|
|
26
|
+
const MAX_DETAIL = 4000;
|
|
27
|
+
const TIMEOUT_MS = 15_000;
|
|
28
|
+
// A tool test runs tenant-authored code directly on the host — real runs are
|
|
29
|
+
// containerized, this is not — so it must NOT inherit the dashboard's full
|
|
30
|
+
// process.env. That env holds FOLDRUN_SECRET_KEY (the global install key that
|
|
31
|
+
// signs every tenant's webhook tokens and git-push secrets), plus the S3/DB/
|
|
32
|
+
// Stripe/GitHub credentials: handing them to a `console.log(process.env)`
|
|
33
|
+
// script tool is cross-tenant compromise. The child instead starts from a
|
|
34
|
+
// minimal host base — only enough for an interpreter to be found and run —
|
|
35
|
+
// and gets the tool's own declared secrets layered on top by the callers.
|
|
36
|
+
const HOST_BASE_ENV_KEYS = [
|
|
37
|
+
"PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LANGUAGE",
|
|
38
|
+
"LC_ALL", "LC_CTYPE", "TZ", "TERM", "SHELL", "USER", "LOGNAME",
|
|
39
|
+
"NODE_ENV", // not a secret; some interpreters/tools read it.
|
|
40
|
+
// Windows equivalents, harmless elsewhere.
|
|
41
|
+
"SYSTEMROOT", "COMSPEC", "PATHEXT", "WINDIR",
|
|
42
|
+
];
|
|
43
|
+
function hostSafeBaseEnv() {
|
|
44
|
+
const base = {};
|
|
45
|
+
for (const k of HOST_BASE_ENV_KEYS) {
|
|
46
|
+
const v = process.env[k];
|
|
47
|
+
if (v !== undefined)
|
|
48
|
+
base[k] = v;
|
|
49
|
+
}
|
|
50
|
+
return base;
|
|
51
|
+
}
|
|
52
|
+
const clip = (s) => s.length > MAX_DETAIL ? `${s.slice(0, MAX_DETAIL)}\n… truncated` : s;
|
|
53
|
+
/** `${NAME}` → value, for headers and query. Same rule the runtime uses. */
|
|
54
|
+
const substitute = (v, env) => v.replace(/\$\{([A-Z0-9_]+)\}/g, (_, k) => env[k] ?? "");
|
|
55
|
+
export async function testTool(tenant, workspace, def, input = {}) {
|
|
56
|
+
const probePath = input.path ?? "";
|
|
57
|
+
const started = Date.now();
|
|
58
|
+
const done = (r) => ({ ...r, ms: Date.now() - started });
|
|
59
|
+
if (def.kind === "http") {
|
|
60
|
+
const api = def.spec;
|
|
61
|
+
const needed = secretsUsedByApi(api);
|
|
62
|
+
const { env, missing } = resolveSecrets(tenant, needed, workspace);
|
|
63
|
+
try {
|
|
64
|
+
const rel = probePath.startsWith("/") || probePath === "" ? probePath : `/${probePath}`;
|
|
65
|
+
const url = new URL(api.base + rel);
|
|
66
|
+
// Same confinement the agent gets: a probe can't reach another host.
|
|
67
|
+
if (!url.href.startsWith(api.base)) {
|
|
68
|
+
return done({
|
|
69
|
+
ok: false, transport: "http", missingSecrets: missing,
|
|
70
|
+
summary: "that path escapes the declared base URL",
|
|
71
|
+
detail: `base: ${api.base}\npath: ${probePath}`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
for (const [k, v] of Object.entries(api.query))
|
|
75
|
+
url.searchParams.set(k, substitute(v, env));
|
|
76
|
+
const headers = {};
|
|
77
|
+
for (const [k, v] of Object.entries(api.headers))
|
|
78
|
+
headers[k] = substitute(v, env);
|
|
79
|
+
const method = api.methods.includes("GET") ? "GET" : api.methods[0];
|
|
80
|
+
const res = await fetch(url, {
|
|
81
|
+
method,
|
|
82
|
+
headers,
|
|
83
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
84
|
+
});
|
|
85
|
+
const body = await res.text();
|
|
86
|
+
// An unset secret means the tool cannot authenticate, so it is not
|
|
87
|
+
// working — whatever a permissive endpoint chose to return. Reporting
|
|
88
|
+
// ok on a request that went out with no credentials is how you ship a
|
|
89
|
+
// tool that 401s the first time it meets the real API.
|
|
90
|
+
return done({
|
|
91
|
+
ok: res.ok && missing.length === 0,
|
|
92
|
+
transport: "http",
|
|
93
|
+
missingSecrets: missing,
|
|
94
|
+
summary: missing.length > 0
|
|
95
|
+
? `${method} ${url.pathname} → ${res.status}, but sent without ${missing.join(", ")}`
|
|
96
|
+
: `${method} ${url.pathname} → ${res.status} ${res.statusText}`,
|
|
97
|
+
detail: clip(body),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
return done({
|
|
102
|
+
ok: false, transport: "http", missingSecrets: missing,
|
|
103
|
+
summary: err instanceof Error ? err.message : String(err),
|
|
104
|
+
// A missing secret usually surfaces as a 401 rather than an error, so
|
|
105
|
+
// say it here too — it is the likeliest cause of both.
|
|
106
|
+
detail: missing.length ? `unset secrets: ${missing.join(", ")}` : "",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (def.kind === "script") {
|
|
111
|
+
const run = String(def.spec.run ?? "");
|
|
112
|
+
// A run's working directory is the calling AGENT's folder, not the
|
|
113
|
+
// workspace root — and scripts rely on that. `path.resolve(cwd, "..",
|
|
114
|
+
// "..", "state", f)` is the ordinary way to reach workspace state, and
|
|
115
|
+
// testing one directory too high sent it to <data>/<tenant>/state: a
|
|
116
|
+
// real file read from the wrong place, reported as an empty result
|
|
117
|
+
// rather than an error. The tester now stands where a run stands.
|
|
118
|
+
//
|
|
119
|
+
// Which agent: one that granted this tool, so the test is the call that
|
|
120
|
+
// would actually happen; any agent otherwise, since only the DEPTH
|
|
121
|
+
// changes what a relative path resolves to.
|
|
122
|
+
const dir = workspaceDir(tenant, workspace);
|
|
123
|
+
const agents = listAgents(tenant, workspace);
|
|
124
|
+
const caller = agents.find((a) => a.ownTools.includes(def.name)) ?? agents[0] ?? null;
|
|
125
|
+
// With no agents at all there is nothing at that depth to stand in; the
|
|
126
|
+
// workspace root is wrong but it exists, and the note says so rather
|
|
127
|
+
// than failing a test over a workspace that cannot run anything yet.
|
|
128
|
+
const cwd = caller ? path.join(dir, "agents", caller.name) : dir;
|
|
129
|
+
const file = resolveRunPath(path.join(dir, "agents", "_probe"), run, libraryDir(tenant, "scripts"));
|
|
130
|
+
if (!fs.existsSync(file)) {
|
|
131
|
+
return done({
|
|
132
|
+
ok: false, transport: "script", missingSecrets: [],
|
|
133
|
+
summary: `no such script: ${run}`,
|
|
134
|
+
detail: `looked for ${file}\n\nThis is the commonest way to get a tool that silently never loads.`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const names = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
|
|
138
|
+
const { env, missing } = resolveSecrets(tenant, names, workspace);
|
|
139
|
+
// The runtime's own choice of interpreter, not a second table beside it.
|
|
140
|
+
const interpreter = typeof def.spec.interpreter === "string" ? def.spec.interpreter : undefined;
|
|
141
|
+
// Declared args become long flags, exactly as the runtime passes them.
|
|
142
|
+
const declared = def.spec.args && typeof def.spec.args === "object"
|
|
143
|
+
? Object.keys(def.spec.args)
|
|
144
|
+
: [];
|
|
145
|
+
const flags = [];
|
|
146
|
+
for (const [k, v] of Object.entries(input.args ?? {})) {
|
|
147
|
+
if (v !== "" && /^[a-z][a-z0-9_]*$/i.test(k))
|
|
148
|
+
flags.push(`--${k}`, v);
|
|
149
|
+
}
|
|
150
|
+
// The tool's own dependencies, built (or reused) the way a run builds
|
|
151
|
+
// them. Without this a tool declaring `runtime: { packages: [requests] }`
|
|
152
|
+
// ran fine in a flow and failed the Test button with "no module named
|
|
153
|
+
// requests" — the one place a developer goes to find out if it works.
|
|
154
|
+
const toolRuntime = prepareRuntime(tenant, parseRuntime(def.spec.runtime));
|
|
155
|
+
if (toolRuntime.error) {
|
|
156
|
+
return done({
|
|
157
|
+
ok: false, transport: "script", missingSecrets: missing,
|
|
158
|
+
summary: "its runtime could not be built",
|
|
159
|
+
detail: [toolRuntime.error, ...toolRuntime.log].join("\n"),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const { cmd, args } = commandFor({ interpreter, run: run, name: "", description: "", args: {} }, file, toolRuntime.interpreters);
|
|
163
|
+
const { code, out } = await runOnce(cmd, [...args, ...flags], cwd, {
|
|
164
|
+
...hostSafeBaseEnv(),
|
|
165
|
+
...toolRuntime.env,
|
|
166
|
+
...env,
|
|
167
|
+
});
|
|
168
|
+
// A script that needs arguments and got none has not failed — it has not
|
|
169
|
+
// been tested. Saying "exited 2" would send someone looking for a bug.
|
|
170
|
+
const untested = code !== 0 && declared.length > 0 && flags.length === 0;
|
|
171
|
+
return done({
|
|
172
|
+
ok: code === 0,
|
|
173
|
+
transport: "script",
|
|
174
|
+
missingSecrets: missing,
|
|
175
|
+
summary: untested
|
|
176
|
+
? `needs ${declared.map((a) => `--${a}`).join(", ")} — fill them in and test again`
|
|
177
|
+
: code === 0 ? "exited 0" : `exited ${code ?? "with an error"}`,
|
|
178
|
+
detail: clip((caller
|
|
179
|
+
? `ran from agents/${caller.name}/, where a run runs\n\n`
|
|
180
|
+
: `ran from the workspace root — there is no agent to stand in for, so a script that reaches ../../ will look one level too high\n\n`) +
|
|
181
|
+
(out || "(no output)")),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
// ---- mcp: start the server and complete a handshake ----
|
|
185
|
+
const spec = def.spec;
|
|
186
|
+
if (spec.url) {
|
|
187
|
+
// A remote server: the honest check we can make without a full client is
|
|
188
|
+
// that the endpoint answers.
|
|
189
|
+
try {
|
|
190
|
+
const res = await fetch(spec.url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
191
|
+
return done({
|
|
192
|
+
ok: res.ok,
|
|
193
|
+
transport: "mcp",
|
|
194
|
+
missingSecrets: [],
|
|
195
|
+
summary: `${spec.url} → ${res.status} ${res.statusText}`,
|
|
196
|
+
detail: "Endpoint reachable. This does not verify the MCP handshake — only a local (command) server is started and spoken to.",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
return done({
|
|
201
|
+
ok: false, transport: "mcp", missingSecrets: [],
|
|
202
|
+
summary: err instanceof Error ? err.message : String(err),
|
|
203
|
+
detail: "",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!spec.command) {
|
|
208
|
+
return done({
|
|
209
|
+
ok: false, transport: "mcp", missingSecrets: [],
|
|
210
|
+
summary: "no command: or url: — this tool cannot load",
|
|
211
|
+
detail: "",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const named = Object.entries(spec.env ?? {})
|
|
215
|
+
.map(([, v]) => String(v).match(/^\$\{([A-Z0-9_]+)\}$/)?.[1])
|
|
216
|
+
.filter((n) => Boolean(n));
|
|
217
|
+
const { env, missing } = resolveSecrets(tenant, named, workspace);
|
|
218
|
+
const childEnv = { ...hostSafeBaseEnv() };
|
|
219
|
+
for (const [k, v] of Object.entries(spec.env ?? {}))
|
|
220
|
+
childEnv[k] = substitute(String(v), env);
|
|
221
|
+
const handshake = await mcpHandshake(spec.command, spec.args ?? [], childEnv);
|
|
222
|
+
return done({
|
|
223
|
+
ok: handshake.ok,
|
|
224
|
+
transport: "mcp",
|
|
225
|
+
missingSecrets: missing,
|
|
226
|
+
summary: handshake.summary,
|
|
227
|
+
detail: clip(handshake.detail),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/** Run a command to completion, capturing both streams together. */
|
|
231
|
+
function runOnce(cmd, args, cwd, env) {
|
|
232
|
+
return new Promise((resolve) => {
|
|
233
|
+
let out = "";
|
|
234
|
+
let child;
|
|
235
|
+
try {
|
|
236
|
+
child = spawn(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
resolve({ code: null, out: err instanceof Error ? err.message : String(err) });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
|
|
243
|
+
child.stdout?.on("data", (d) => (out += d));
|
|
244
|
+
child.stderr?.on("data", (d) => (out += d));
|
|
245
|
+
child.on("error", (err) => {
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
resolve({ code: null, out: `${out}${err.message}` });
|
|
248
|
+
});
|
|
249
|
+
child.on("close", (code) => {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
resolve({ code, out });
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Start a stdio MCP server and complete `initialize` + `tools/list`.
|
|
257
|
+
*
|
|
258
|
+
* This is the whole point of testing an MCP tool: "the binary exists" tells you
|
|
259
|
+
* nothing, and the failure people actually hit is a server that starts and then
|
|
260
|
+
* exposes nothing useful.
|
|
261
|
+
*/
|
|
262
|
+
function mcpHandshake(command, args, env) {
|
|
263
|
+
return new Promise((resolve) => {
|
|
264
|
+
let child;
|
|
265
|
+
try {
|
|
266
|
+
child = spawn(command, args, { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
resolve({ ok: false, summary: `could not start ${command}`, detail: String(err), });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
let buf = "";
|
|
273
|
+
let stderr = "";
|
|
274
|
+
let settled = false;
|
|
275
|
+
const finish = (r) => {
|
|
276
|
+
if (settled)
|
|
277
|
+
return;
|
|
278
|
+
settled = true;
|
|
279
|
+
clearTimeout(timer);
|
|
280
|
+
child.kill("SIGKILL");
|
|
281
|
+
resolve(r);
|
|
282
|
+
};
|
|
283
|
+
const timer = setTimeout(() => finish({
|
|
284
|
+
ok: false,
|
|
285
|
+
summary: "the server did not answer in time",
|
|
286
|
+
detail: stderr || "No response to initialize within 15s.",
|
|
287
|
+
}), TIMEOUT_MS);
|
|
288
|
+
const send = (msg) => child.stdin?.write(`${JSON.stringify(msg)}\n`);
|
|
289
|
+
child.on("error", (err) => finish({ ok: false, summary: `could not start ${command}`, detail: err.message }));
|
|
290
|
+
child.stderr?.on("data", (d) => (stderr += d));
|
|
291
|
+
child.stdout?.on("data", (d) => {
|
|
292
|
+
buf += d;
|
|
293
|
+
// One JSON-RPC message per line.
|
|
294
|
+
for (const line of buf.split("\n").slice(0, -1)) {
|
|
295
|
+
if (!line.trim())
|
|
296
|
+
continue;
|
|
297
|
+
let msg;
|
|
298
|
+
try {
|
|
299
|
+
msg = JSON.parse(line);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (msg.error) {
|
|
305
|
+
finish({ ok: false, summary: msg.error.message ?? "the server returned an error", detail: line });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (msg.id === 1) {
|
|
309
|
+
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
310
|
+
send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
|
|
311
|
+
}
|
|
312
|
+
else if (msg.id === 2) {
|
|
313
|
+
const tools = msg.result?.tools ?? [];
|
|
314
|
+
finish({
|
|
315
|
+
ok: true,
|
|
316
|
+
summary: `connected — ${tools.length} tool${tools.length === 1 ? "" : "s"}`,
|
|
317
|
+
detail: tools.map((t) => `• ${t.name}`).join("\n") || "The server exposes no tools.",
|
|
318
|
+
});
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
buf = buf.slice(buf.lastIndexOf("\n") + 1);
|
|
323
|
+
});
|
|
324
|
+
send({
|
|
325
|
+
jsonrpc: "2.0",
|
|
326
|
+
id: 1,
|
|
327
|
+
method: "initialize",
|
|
328
|
+
params: {
|
|
329
|
+
protocolVersion: "2024-11-05",
|
|
330
|
+
capabilities: {},
|
|
331
|
+
clientInfo: { name: "foldrun-tool-test", version: "0.1.0" },
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
type Json = Record<string, unknown>;
|
|
2
|
+
export type TranslatorWire = "chat" | "responses";
|
|
3
|
+
export interface TranslatorSpec {
|
|
4
|
+
/** The provider's base, e.g. https://api.openai.com/v1 — `/chat/completions`
|
|
5
|
+
* or `/responses` is appended, by `wire`. */
|
|
6
|
+
upstreamBase: string;
|
|
7
|
+
/** Which OpenAI shape the endpoint speaks. Default chat. */
|
|
8
|
+
wire?: TranslatorWire;
|
|
9
|
+
/** Already resolved. Sent as `Authorization: Bearer`. */
|
|
10
|
+
upstreamKey: string;
|
|
11
|
+
/** Extra headers the provider wants, already resolved. */
|
|
12
|
+
headers?: Record<string, string>;
|
|
13
|
+
maxTokensParam?: "max_tokens" | "max_completion_tokens";
|
|
14
|
+
/** Map Anthropic `thinking:` to OpenAI `reasoning_effort`; off drops it. */
|
|
15
|
+
reasoningEffort?: boolean;
|
|
16
|
+
/** The provider block's `params:` — merged into the outgoing body after
|
|
17
|
+
* the translation, so a file can set what this format does not model. */
|
|
18
|
+
params?: Record<string, unknown>;
|
|
19
|
+
/** How the endpoint is named in the trace. */
|
|
20
|
+
label?: string;
|
|
21
|
+
/** The step's egress lease, when the sandbox must not hold the key: the
|
|
22
|
+
* upstream request goes through it, and `upstreamKey` is a placeholder
|
|
23
|
+
* the proxy fills. */
|
|
24
|
+
egress?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface RunningTranslator {
|
|
27
|
+
port: number;
|
|
28
|
+
/** The key the SDK must present — random per translator, so nothing but
|
|
29
|
+
* this step's SDK process can use the localhost door. */
|
|
30
|
+
key: string;
|
|
31
|
+
baseUrl: string;
|
|
32
|
+
/** The env that points the SDK here. Overrides whatever provider env the
|
|
33
|
+
* caller assembled: the translator is now the endpoint. */
|
|
34
|
+
env: Record<string, string>;
|
|
35
|
+
/** One line per request, drained by the caller into the run trace. */
|
|
36
|
+
drainLog: () => string[];
|
|
37
|
+
close: () => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
interface Dropped {
|
|
40
|
+
add(what: string): void;
|
|
41
|
+
lines(): string[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Whether a model is one OpenAI's own endpoint refuses a reasoning-effort
|
|
45
|
+
* parameter for ("Unsupported parameter: 'reasoning.effort'" on gpt-4o-mini).
|
|
46
|
+
* A gateway in front of it ignored the field, which is how the preset's
|
|
47
|
+
* reasoningEffort shipped untested against the origin. The families that do
|
|
48
|
+
* not reason are the ones named — anything else (o-series, gpt-5, grok,
|
|
49
|
+
* gemini, deepseek-r, qwen thinking) keeps getting the knob, because on a
|
|
50
|
+
* gateway the id alone cannot say and an ignored field costs nothing.
|
|
51
|
+
*/
|
|
52
|
+
export declare function modelRejectsReasoning(model: string): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* An Anthropic Messages request as a Chat Completions request. Pure.
|
|
55
|
+
*
|
|
56
|
+
* Message order matters to the other side: a tool result must directly
|
|
57
|
+
* follow the assistant turn that called the tool. Anthropic puts results
|
|
58
|
+
* inside the next user message, so those blocks become `tool` messages
|
|
59
|
+
* first and whatever else the user said follows as a user message.
|
|
60
|
+
*/
|
|
61
|
+
export declare function toChatCompletions(req: Json, opts: {
|
|
62
|
+
maxTokensParam?: "max_tokens" | "max_completion_tokens";
|
|
63
|
+
reasoningEffort?: boolean;
|
|
64
|
+
stream: boolean;
|
|
65
|
+
/** Merged in last, so the file wins over the translation. */
|
|
66
|
+
params?: Record<string, unknown>;
|
|
67
|
+
}, drop?: Dropped): Json;
|
|
68
|
+
/**
|
|
69
|
+
* An Anthropic Messages request as a Responses request. Pure.
|
|
70
|
+
*
|
|
71
|
+
* The Responses shape is flatter than Chat Completions: the system prompt
|
|
72
|
+
* is `instructions`, a tool call and its result are top-level items rather
|
|
73
|
+
* than an assistant field and a `tool` role, and a document is an input
|
|
74
|
+
* file the model reads itself. Order is preserved as written: a
|
|
75
|
+
* function_call item, then its function_call_output, then whatever else the
|
|
76
|
+
* user said.
|
|
77
|
+
*/
|
|
78
|
+
export declare function toResponses(req: Json, opts: {
|
|
79
|
+
reasoningEffort?: boolean;
|
|
80
|
+
stream: boolean;
|
|
81
|
+
params?: Record<string, unknown>;
|
|
82
|
+
}, drop?: Dropped): Json;
|
|
83
|
+
/** A finished Chat Completion as an Anthropic message. Pure. */
|
|
84
|
+
export declare function fromChatCompletion(res: Json, requestedModel: string): Json;
|
|
85
|
+
/**
|
|
86
|
+
* The streaming half: Chat-Completions chunks in, Anthropic SSE events out.
|
|
87
|
+
* A state machine because the two streams disagree about structure — one
|
|
88
|
+
* is a flat sequence of deltas, the other is blocks with explicit starts and
|
|
89
|
+
* stops — and a tool call's arguments arrive as fragments that the other
|
|
90
|
+
* side wants as `input_json_delta`s inside one open block.
|
|
91
|
+
*/
|
|
92
|
+
export declare class StreamTranslator {
|
|
93
|
+
private started;
|
|
94
|
+
private nextIndex;
|
|
95
|
+
private textIndex;
|
|
96
|
+
/** Chat-Completions tool index → Anthropic block index. */
|
|
97
|
+
private tools;
|
|
98
|
+
private finish;
|
|
99
|
+
private usage;
|
|
100
|
+
private id;
|
|
101
|
+
private readonly model;
|
|
102
|
+
constructor(model: string);
|
|
103
|
+
private event;
|
|
104
|
+
private start;
|
|
105
|
+
private closeText;
|
|
106
|
+
/** One parsed chunk in, zero or more SSE events out. */
|
|
107
|
+
feed(chunk: Json): string[];
|
|
108
|
+
/** The stream ended: close what is open, say why, and stop. */
|
|
109
|
+
finishStream(): string[];
|
|
110
|
+
}
|
|
111
|
+
/** A finished Response as an Anthropic message. Pure. */
|
|
112
|
+
export declare function fromResponses(res: Json, requestedModel: string): Json;
|
|
113
|
+
/**
|
|
114
|
+
* The Responses stream as Anthropic SSE. Simpler than the Chat-Completions
|
|
115
|
+
* machine because the other side already speaks in items: an output item
|
|
116
|
+
* is added, its text or arguments arrive as deltas, it is done. Each item
|
|
117
|
+
* becomes one Anthropic block; `response.completed` carries the usage and
|
|
118
|
+
* the reason.
|
|
119
|
+
*/
|
|
120
|
+
export declare class ResponsesStreamTranslator {
|
|
121
|
+
private started;
|
|
122
|
+
private nextIndex;
|
|
123
|
+
/** Responses output_index → Anthropic block index and kind. */
|
|
124
|
+
private blocks;
|
|
125
|
+
private usage;
|
|
126
|
+
private incomplete;
|
|
127
|
+
private sawTool;
|
|
128
|
+
private id;
|
|
129
|
+
private readonly model;
|
|
130
|
+
constructor(model: string);
|
|
131
|
+
private event;
|
|
132
|
+
private start;
|
|
133
|
+
/** One parsed event in, zero or more SSE events out. */
|
|
134
|
+
feed(ev: Json): string[];
|
|
135
|
+
/** The stream ended: close what is still open, say why, and stop. */
|
|
136
|
+
finishStream(): string[];
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Start the translator for one step. Binds an ephemeral port on loopback,
|
|
140
|
+
* mints the key the SDK will present, and returns the env that points the
|
|
141
|
+
* SDK at it. Close it when the step is over.
|
|
142
|
+
*/
|
|
143
|
+
export declare function startTranslator(spec: TranslatorSpec): Promise<RunningTranslator>;
|
|
144
|
+
/**
|
|
145
|
+
* What a provider block needs from the translator, or null when the block
|
|
146
|
+
* speaks Anthropic and needs none. Values only — this crosses into the run
|
|
147
|
+
* container as JSON.
|
|
148
|
+
*/
|
|
149
|
+
export declare function translatorSpecFor(spec: {
|
|
150
|
+
format: string;
|
|
151
|
+
baseUrl: string;
|
|
152
|
+
token: string;
|
|
153
|
+
headers: Record<string, string>;
|
|
154
|
+
params?: Record<string, unknown>;
|
|
155
|
+
name?: string | null;
|
|
156
|
+
maxTokensParam?: "max_tokens" | "max_completion_tokens";
|
|
157
|
+
reasoningEffort?: boolean;
|
|
158
|
+
}): TranslatorSpec | null;
|
|
159
|
+
export {};
|