Unified · Split
| @@ -0,0 +1,720 @@ | ||
| 1 | +#!/usr/bin/env bun | |
| 2 | + | |
| 3 | +import { mkdir, writeFile } from "node:fs/promises"; | |
| 4 | +import { fileURLToPath } from "node:url"; | |
| 5 | + | |
| 6 | +interface ApisGuruMatch { | |
| 7 | + id: string; | |
| 8 | + name: string; | |
| 9 | + providerName: string; | |
| 10 | + specUrl: string; | |
| 11 | + originUrl: string; | |
| 12 | + openapiVer: string; | |
| 13 | +} | |
| 14 | + | |
| 15 | +interface ToolkitRecord { | |
| 16 | + slug: string; | |
| 17 | + docsUrl: string; | |
| 18 | + toolkitUrl: string; | |
| 19 | + name: string; | |
| 20 | + category: string; | |
| 21 | + auth: string; | |
| 22 | + toolsCount: number; | |
| 23 | + triggersCount: number; | |
| 24 | + composioSlug: string; | |
| 25 | + version: string; | |
| 26 | + status: "ok" | "missing" | "error"; | |
| 27 | + protocolHints: string[]; | |
| 28 | + endpointUrls: string[]; | |
| 29 | + usableOpenApiUrls?: string[]; | |
| 30 | + usableGraphqlUrls?: string[]; | |
| 31 | + matchedApisGuru: ApisGuruMatch | null; | |
| 32 | + error: string; | |
| 33 | +} | |
| 34 | + | |
| 35 | +interface EnrichmentResult { | |
| 36 | + openapiUrls: string[]; | |
| 37 | + graphqlUrls: string[]; | |
| 38 | + inspectedCandidates: number; | |
| 39 | +} | |
| 40 | + | |
| 41 | +const INPUT_PATH = fileURLToPath(new URL("./data/composio-toolkits-endpoints.json", import.meta.url)); | |
| 42 | +const OUTPUT_JSON_PATH = fileURLToPath(new URL("./data/composio-toolkits-endpoints.enriched.json", import.meta.url)); | |
| 43 | +const OUTPUT_CSV_PATH = fileURLToPath(new URL("./data/composio-toolkits-endpoints.enriched.csv", import.meta.url)); | |
| 44 | +const OUTPUT_SUMMARY_PATH = fileURLToPath(new URL("./data/composio-toolkits-summary.enriched.json", import.meta.url)); | |
| 45 | +const OUTPUT_NEEDS_RESEARCH_PATH = fileURLToPath(new URL("./data/composio-toolkits-needs-research.enriched.csv", import.meta.url)); | |
| 46 | +const OUTPUT_DISCOVERIES_PATH = fileURLToPath(new URL("./data/composio-toolkits-discoveries.enriched.csv", import.meta.url)); | |
| 47 | + | |
| 48 | +const concurrency = parseIntArg("--concurrency", 8, 1, 50); | |
| 49 | +const limit = parseIntArg("--limit", 0, 0, 20_000); | |
| 50 | +const timeoutMs = parseIntArg("--timeout-ms", 5000, 1000, 20_000); | |
| 51 | +const maxOpenApiCandidates = parseIntArg("--max-openapi-candidates", 8, 1, 50); | |
| 52 | +const maxGraphqlCandidates = parseIntArg("--max-graphql-candidates", 4, 1, 30); | |
| 53 | +const only = getStringArg("--only"); | |
| 54 | + | |
| 55 | +const IGNORE_HOSTS = new Set([ | |
| 56 | + "composio.dev", | |
| 57 | + "docs.composio.dev", | |
| 58 | + "backend.composio.dev", | |
| 59 | + "platform.composio.dev", | |
| 60 | + "logos.composio.dev", | |
| 61 | + "og.composio.dev", | |
| 62 | + "app.getdecimal.ai", | |
| 63 | + "twitter.com", | |
| 64 | + "x.com", | |
| 65 | + "discord.gg", | |
| 66 | + "github.com", | |
| 67 | + "www.w3.org", | |
| 68 | + "schema.org", | |
| 69 | +]); | |
| 70 | + | |
| 71 | +function getStringArg(flag: string): string { | |
| 72 | + const value = Bun.argv.find((arg) => arg.startsWith(`${flag}=`)); | |
| 73 | + if (!value) return ""; | |
| 74 | + return value.slice(flag.length + 1).trim(); | |
| 75 | +} | |
| 76 | + | |
| 77 | +function parseIntArg(flag: string, fallback: number, min: number, max: number): number { | |
| 78 | + const value = getStringArg(flag); | |
| 79 | + if (!value) return fallback; | |
| 80 | + | |
| 81 | + const parsed = Number(value); | |
| 82 | + if (!Number.isFinite(parsed)) return fallback; | |
| 83 | + | |
| 84 | + return Math.max(min, Math.min(max, Math.floor(parsed))); | |
| 85 | +} | |
| 86 | + | |
| 87 | +function csvEscape(value: string): string { | |
| 88 | + if (/[",\n]/.test(value)) { | |
| 89 | + return `"${value.replace(/"/g, '""')}"`; | |
| 90 | + } | |
| 91 | + return value; | |
| 92 | +} | |
| 93 | + | |
| 94 | +function cleanUrl(raw: string): string { | |
| 95 | + return raw | |
| 96 | + .trim() | |
| 97 | + .replace(/[),.;'"`]+$/g, "") | |
| 98 | + .replace(/\\+$/g, "") | |
| 99 | + .replace(/&/g, "&"); | |
| 100 | +} | |
| 101 | + | |
| 102 | +function extractUrls(markdown: string): string[] { | |
| 103 | + const urls = new Set<string>(); | |
| 104 | + | |
| 105 | + for (const match of markdown.matchAll(/https?:\/\/[^\s)\]>\"]+/g)) { | |
| 106 | + const candidate = cleanUrl(match[0]); | |
| 107 | + if (!candidate) continue; | |
| 108 | + | |
| 109 | + try { | |
| 110 | + new URL(candidate); | |
| 111 | + } catch { | |
| 112 | + continue; | |
| 113 | + } | |
| 114 | + | |
| 115 | + urls.add(candidate); | |
| 116 | + } | |
| 117 | + | |
| 118 | + return [...urls].sort(); | |
| 119 | +} | |
| 120 | + | |
| 121 | +function rootDomainFromHost(host: string): string { | |
| 122 | + const normalized = host.toLowerCase().replace(/^www\./, ""); | |
| 123 | + const parts = normalized.split(".").filter(Boolean); | |
| 124 | + if (parts.length <= 2) return normalized; | |
| 125 | + | |
| 126 | + const secondLevel = parts[parts.length - 2]; | |
| 127 | + const topLevel = parts[parts.length - 1]; | |
| 128 | + | |
| 129 | + if (["co", "com", "org", "net", "gov", "edu"].includes(secondLevel) && topLevel.length <= 2 && parts.length >= 3) { | |
| 130 | + return parts.slice(-3).join("."); | |
| 131 | + } | |
| 132 | + | |
| 133 | + return parts.slice(-2).join("."); | |
| 134 | +} | |
| 135 | + | |
| 136 | +function collectCandidateHosts(urls: string[]): string[] { | |
| 137 | + const hosts = new Set<string>(); | |
| 138 | + | |
| 139 | + for (const rawUrl of urls) { | |
| 140 | + let parsed: URL; | |
| 141 | + try { | |
| 142 | + parsed = new URL(rawUrl); | |
| 143 | + } catch { | |
| 144 | + continue; | |
| 145 | + } | |
| 146 | + | |
| 147 | + const host = parsed.hostname.toLowerCase(); | |
| 148 | + if (IGNORE_HOSTS.has(host)) continue; | |
| 149 | + if (host.endsWith("composio.dev")) continue; | |
| 150 | + | |
| 151 | + hosts.add(host); | |
| 152 | + | |
| 153 | + if (/^(www|api|docs|developer|developers|graphql)\./.test(host)) { | |
| 154 | + const stripped = host.replace(/^(www|api|docs|developer|developers|graphql)\./, ""); | |
| 155 | + if (stripped && !IGNORE_HOSTS.has(stripped) && !stripped.endsWith("composio.dev")) { | |
| 156 | + hosts.add(stripped); | |
| 157 | + } | |
| 158 | + } | |
| 159 | + | |
| 160 | + const root = rootDomainFromHost(host); | |
| 161 | + if (root && !IGNORE_HOSTS.has(root) && !root.endsWith("composio.dev")) { | |
| 162 | + hosts.add(root); | |
| 163 | + } | |
| 164 | + } | |
| 165 | + | |
| 166 | + return [...hosts].sort(); | |
| 167 | +} | |
| 168 | + | |
| 169 | +function buildOpenApiCandidates(hosts: string[], hintUrls: string[]): string[] { | |
| 170 | + const candidates = new Set<string>(); | |
| 171 | + | |
| 172 | + for (const hintUrl of hintUrls) { | |
| 173 | + const lower = hintUrl.toLowerCase(); | |
| 174 | + if (/openapi|swagger|v3\/api-docs|api-docs/.test(lower)) { | |
| 175 | + candidates.add(hintUrl); | |
| 176 | + } | |
| 177 | + } | |
| 178 | + | |
| 179 | + const specPaths = [ | |
| 180 | + "/openapi.json", | |
| 181 | + "/swagger.json", | |
| 182 | + "/v3/api-docs", | |
| 183 | + "/api-docs", | |
| 184 | + ]; | |
| 185 | + | |
| 186 | + for (const host of hosts) { | |
| 187 | + const hostVariants = new Set<string>([host]); | |
| 188 | + | |
| 189 | + if (!host.startsWith("api.")) { | |
| 190 | + hostVariants.add(`api.${host}`); | |
| 191 | + } | |
| 192 | + | |
| 193 | + for (const candidateHost of hostVariants) { | |
| 194 | + for (const path of specPaths) { | |
| 195 | + candidates.add(`https://${candidateHost}${path}`); | |
| 196 | + } | |
| 197 | + } | |
| 198 | + } | |
| 199 | + | |
| 200 | + return [...candidates].sort(); | |
| 201 | +} | |
| 202 | + | |
| 203 | +function buildGraphqlCandidates(hosts: string[], hintUrls: string[]): string[] { | |
| 204 | + const candidates = new Set<string>(); | |
| 205 | + | |
| 206 | + for (const hintUrl of hintUrls) { | |
| 207 | + const lower = hintUrl.toLowerCase(); | |
| 208 | + if (/\/graphql($|[/?#])/.test(lower) || /graphql\./.test(lower)) { | |
| 209 | + candidates.add(hintUrl); | |
| 210 | + } | |
| 211 | + } | |
| 212 | + | |
| 213 | + const graphqlPaths = [ | |
| 214 | + "/graphql", | |
| 215 | + "/api/graphql", | |
| 216 | + ]; | |
| 217 | + | |
| 218 | + for (const host of hosts) { | |
| 219 | + const hostVariants = new Set<string>([host]); | |
| 220 | + | |
| 221 | + if (!host.startsWith("api.")) { | |
| 222 | + hostVariants.add(`api.${host}`); | |
| 223 | + } | |
| 224 | + | |
| 225 | + if (!host.startsWith("graphql.")) { | |
| 226 | + hostVariants.add(`graphql.${host}`); | |
| 227 | + } | |
| 228 | + | |
| 229 | + for (const candidateHost of hostVariants) { | |
| 230 | + for (const path of graphqlPaths) { | |
| 231 | + candidates.add(`https://${candidateHost}${path}`); | |
| 232 | + } | |
| 233 | + } | |
| 234 | + } | |
| 235 | + | |
| 236 | + return [...candidates].sort(); | |
| 237 | +} | |
| 238 | + | |
| 239 | +const openApiProbeCache = new Map<string, Promise<boolean>>(); | |
| 240 | +const graphqlProbeCache = new Map<string, Promise<boolean>>(); | |
| 241 | + | |
| 242 | +async function fetchWithTimeout(url: string, init: RequestInit = {}): Promise<Response> { | |
| 243 | + const controller = new AbortController(); | |
| 244 | + const timer = setTimeout(() => controller.abort(), timeoutMs); | |
| 245 | + | |
| 246 | + try { | |
| 247 | + return await fetch(url, { | |
| 248 | + ...init, | |
| 249 | + signal: controller.signal, | |
| 250 | + headers: { | |
| 251 | + "user-agent": "composio-toolkit-enricher/1.0", | |
| 252 | + ...(init.headers ?? {}), | |
| 253 | + }, | |
| 254 | + }); | |
| 255 | + } finally { | |
| 256 | + clearTimeout(timer); | |
| 257 | + } | |
| 258 | +} | |
| 259 | + | |
| 260 | +async function probeOpenApi(url: string): Promise<boolean> { | |
| 261 | + if (openApiProbeCache.has(url)) { | |
| 262 | + return openApiProbeCache.get(url)!; | |
| 263 | + } | |
| 264 | + | |
| 265 | + const promise = (async () => { | |
| 266 | + try { | |
| 267 | + const response = await fetchWithTimeout(url, { | |
| 268 | + method: "GET", | |
| 269 | + headers: { | |
| 270 | + "accept": "application/json,application/yaml,text/yaml,text/plain,*/*", | |
| 271 | + }, | |
| 272 | + }); | |
| 273 | + | |
| 274 | + if (!response.ok) { | |
| 275 | + return false; | |
| 276 | + } | |
| 277 | + | |
| 278 | + const contentType = (response.headers.get("content-type") ?? "").toLowerCase(); | |
| 279 | + const body = await response.text(); | |
| 280 | + const trimmed = body.trim(); | |
| 281 | + if (!trimmed) return false; | |
| 282 | + | |
| 283 | + const looksHtml = contentType.includes("text/html") || /^\s*<!doctype html/i.test(trimmed) || /^\s*<html/i.test(trimmed); | |
| 284 | + if (looksHtml) return false; | |
| 285 | + | |
| 286 | + if (/^\s*\{/.test(trimmed)) { | |
| 287 | + try { | |
| 288 | + const parsed = JSON.parse(trimmed) as Record<string, unknown>; | |
| 289 | + if (typeof parsed === "object" && parsed !== null && ("openapi" in parsed || "swagger" in parsed)) { | |
| 290 | + return true; | |
| 291 | + } | |
| 292 | + } catch { | |
| 293 | + return false; | |
| 294 | + } | |
| 295 | + } | |
| 296 | + | |
| 297 | + if (/^\s*openapi\s*:\s*["']?\d/i.test(trimmed) || /^\s*swagger\s*:\s*["']?\d/i.test(trimmed)) { | |
| 298 | + return true; | |
| 299 | + } | |
| 300 | + | |
| 301 | + if (/"openapi"\s*:\s*"\d/i.test(trimmed) || /"swagger"\s*:\s*"\d/i.test(trimmed)) { | |
| 302 | + return true; | |
| 303 | + } | |
| 304 | + | |
| 305 | + return false; | |
| 306 | + } catch { | |
| 307 | + return false; | |
| 308 | + } | |
| 309 | + })(); | |
| 310 | + | |
| 311 | + openApiProbeCache.set(url, promise); | |
| 312 | + return promise; | |
| 313 | +} | |
| 314 | + | |
| 315 | +async function probeGraphql(url: string): Promise<boolean> { | |
| 316 | + if (graphqlProbeCache.has(url)) { | |
| 317 | + return graphqlProbeCache.get(url)!; | |
| 318 | + } | |
| 319 | + | |
| 320 | + const promise = (async () => { | |
| 321 | + let parsedUrl: URL; | |
| 322 | + try { | |
| 323 | + parsedUrl = new URL(url); | |
| 324 | + } catch { | |
| 325 | + return false; | |
| 326 | + } | |
| 327 | + | |
| 328 | + const path = parsedUrl.pathname.toLowerCase(); | |
| 329 | + if (!/\/graphql($|\/)/.test(path)) { | |
| 330 | + return false; | |
| 331 | + } | |
| 332 | + | |
| 333 | + if (/^\/(docs|documentation)\//.test(path)) { | |
| 334 | + return false; | |
| 335 | + } | |
| 336 | + | |
| 337 | + try { | |
| 338 | + const response = await fetchWithTimeout(url, { | |
| 339 | + method: "POST", | |
| 340 | + headers: { | |
| 341 | + "accept": "application/json,*/*", | |
| 342 | + "content-type": "application/json", | |
| 343 | + }, | |
| 344 | + body: JSON.stringify({ query: "query { __typename }" }), | |
| 345 | + }); | |
| 346 | + | |
| 347 | + const contentType = (response.headers.get("content-type") ?? "").toLowerCase(); | |
| 348 | + const text = await response.text(); | |
| 349 | + const lower = text.toLowerCase(); | |
| 350 | + | |
| 351 | + if (contentType.includes("application/json")) { | |
| 352 | + try { | |
| 353 | + const parsed = JSON.parse(text) as { | |
| 354 | + data?: unknown; | |
| 355 | + errors?: unknown; | |
| 356 | + message?: unknown; | |
| 357 | + error?: unknown; | |
| 358 | + }; | |
| 359 | + | |
| 360 | + const hasGraphqlErrorEvidence = (errors: unknown): boolean => { | |
| 361 | + if (!Array.isArray(errors)) return false; | |
| 362 | + | |
| 363 | + for (const item of errors) { | |
| 364 | + if (typeof item === "string" && /graphql|cannot query field|must provide query|string contains invalid|syntax error|introspection/i.test(item)) { | |
| 365 | + return true; | |
| 366 | + } | |
| 367 | + | |
| 368 | + if (!item || typeof item !== "object") continue; | |
| 369 | + | |
| 370 | + const message = typeof (item as { message?: unknown }).message === "string" | |
| 371 | + ? ((item as { message: string }).message) | |
| 372 | + : ""; | |
| 373 | + | |
| 374 | + const extensions = (item as { extensions?: unknown }).extensions; | |
| 375 | + const code = | |
| 376 | + extensions && typeof extensions === "object" && typeof (extensions as { code?: unknown }).code === "string" | |
| 377 | + ? ((extensions as { code: string }).code) | |
| 378 | + : ""; | |
| 379 | + | |
| 380 | + if (/graphql|cannot query field|must provide query|string contains invalid|syntax error|introspection/i.test(message)) { | |
| 381 | + return true; | |
| 382 | + } | |
| 383 | + | |
| 384 | + if (/^graphql_/i.test(code)) { | |
| 385 | + return true; | |
| 386 | + } | |
| 387 | + } | |
| 388 | + | |
| 389 | + return false; | |
| 390 | + }; | |
| 391 | + | |
| 392 | + if (parsed.data !== undefined) { | |
| 393 | + return true; | |
| 394 | + } | |
| 395 | + | |
| 396 | + if (hasGraphqlErrorEvidence(parsed.errors)) { | |
| 397 | + return true; | |
| 398 | + } | |
| 399 | + | |
| 400 | + if (typeof parsed.error === "string" && /graphql|cannot query field|must provide query|string contains invalid|syntax error|introspection/i.test(parsed.error)) { | |
| 401 | + return true; | |
| 402 | + } | |
| 403 | + | |
| 404 | + if (typeof parsed.message === "string" && /graphql|cannot query field|must provide query|string contains invalid|syntax error|introspection/i.test(parsed.message)) { | |
| 405 | + return true; | |
| 406 | + } | |
| 407 | + } catch { | |
| 408 | + return false; | |
| 409 | + } | |
| 410 | + } | |
| 411 | + | |
| 412 | + if ([400, 405].includes(response.status) && /graphql|cannot query field|must provide query|string contains invalid|syntax error|introspection/i.test(lower)) { | |
| 413 | + return true; | |
| 414 | + } | |
| 415 | + | |
| 416 | + return false; | |
| 417 | + } catch { | |
| 418 | + return false; | |
| 419 | + } | |
| 420 | + })(); | |
| 421 | + | |
| 422 | + graphqlProbeCache.set(url, promise); | |
| 423 | + return promise; | |
| 424 | +} | |
| 425 | + | |
| 426 | +async function fetchMarkdown(url: string): Promise<string> { | |
| 427 | + try { | |
| 428 | + const response = await fetchWithTimeout(url, { | |
| 429 | + method: "GET", | |
| 430 | + headers: { | |
| 431 | + "accept": "text/plain,text/markdown,text/html,*/*", | |
| 432 | + }, | |
| 433 | + }); | |
| 434 | + | |
| 435 | + if (!response.ok) return ""; | |
| 436 | + return await response.text(); | |
| 437 | + } catch { | |
| 438 | + return ""; | |
| 439 | + } | |
| 440 | +} | |
| 441 | + | |
| 442 | +async function enrichOne(record: ToolkitRecord): Promise<EnrichmentResult> { | |
| 443 | + const markdown = await fetchMarkdown(record.docsUrl); | |
| 444 | + if (!markdown) { | |
| 445 | + return { | |
| 446 | + openapiUrls: [], | |
| 447 | + graphqlUrls: [], | |
| 448 | + inspectedCandidates: 0, | |
| 449 | + }; | |
| 450 | + } | |
| 451 | + | |
| 452 | + const hintUrls = extractUrls(markdown); | |
| 453 | + const hosts = collectCandidateHosts(hintUrls); | |
| 454 | + | |
| 455 | + const openApiCandidates = buildOpenApiCandidates(hosts, hintUrls).slice(0, maxOpenApiCandidates); | |
| 456 | + const graphqlCandidates = buildGraphqlCandidates(hosts, hintUrls).slice(0, maxGraphqlCandidates); | |
| 457 | + | |
| 458 | + const discoveredOpenApi: string[] = []; | |
| 459 | + const discoveredGraphql: string[] = []; | |
| 460 | + | |
| 461 | + let inspectedCandidates = 0; | |
| 462 | + | |
| 463 | + for (const candidate of openApiCandidates) { | |
| 464 | + inspectedCandidates += 1; | |
| 465 | + if (await probeOpenApi(candidate)) { | |
| 466 | + discoveredOpenApi.push(candidate); | |
| 467 | + break; | |
| 468 | + } | |
| 469 | + } | |
| 470 | + | |
| 471 | + for (const candidate of graphqlCandidates) { | |
| 472 | + inspectedCandidates += 1; | |
| 473 | + if (await probeGraphql(candidate)) { | |
| 474 | + discoveredGraphql.push(candidate); | |
| 475 | + break; | |
| 476 | + } | |
| 477 | + } | |
| 478 | + | |
| 479 | + return { | |
| 480 | + openapiUrls: [...new Set(discoveredOpenApi)].sort(), | |
| 481 | + graphqlUrls: [...new Set(discoveredGraphql)].sort(), | |
| 482 | + inspectedCandidates, | |
| 483 | + }; | |
| 484 | +} | |
| 485 | + | |
| 486 | +async function mapLimit<T, R>( | |
| 487 | + items: T[], | |
| 488 | + maxConcurrency: number, | |
| 489 | + mapper: (item: T, index: number) => Promise<R>, | |
| 490 | +): Promise<R[]> { | |
| 491 | + const results = new Array<R>(items.length); | |
| 492 | + let nextIndex = 0; | |
| 493 | + | |
| 494 | + async function worker(): Promise<void> { | |
| 495 | + while (true) { | |
| 496 | + const index = nextIndex; | |
| 497 | + if (index >= items.length) return; | |
| 498 | + nextIndex += 1; | |
| 499 | + results[index] = await mapper(items[index], index); | |
| 500 | + } | |
| 501 | + } | |
| 502 | + | |
| 503 | + const workers = Array.from({ length: Math.max(1, maxConcurrency) }, () => worker()); | |
| 504 | + await Promise.all(workers); | |
| 505 | + return results; | |
| 506 | +} | |
| 507 | + | |
| 508 | +function toCsv(rows: ToolkitRecord[]): string { | |
| 509 | + const headers = [ | |
| 510 | + "slug", | |
| 511 | + "name", | |
| 512 | + "category", | |
| 513 | + "auth", | |
| 514 | + "toolsCount", | |
| 515 | + "triggersCount", | |
| 516 | + "composioSlug", | |
| 517 | + "version", | |
| 518 | + "status", | |
| 519 | + "protocolHints", | |
| 520 | + "endpointUrls", | |
| 521 | + "usableOpenApiUrls", | |
| 522 | + "usableGraphqlUrls", | |
| 523 | + "apisGuruId", | |
| 524 | + "apisGuruProviderName", | |
| 525 | + "apisGuruSpecUrl", | |
| 526 | + "apisGuruOriginUrl", | |
| 527 | + "apisGuruOpenApiVer", | |
| 528 | + "docsUrl", | |
| 529 | + "toolkitUrl", | |
| 530 | + "error", | |
| 531 | + ]; | |
| 532 | + | |
| 533 | + const lines = [headers.join(",")]; | |
| 534 | + | |
| 535 | + for (const row of rows) { | |
| 536 | + const values = [ | |
| 537 | + row.slug, | |
| 538 | + row.name, | |
| 539 | + row.category, | |
| 540 | + row.auth, | |
| 541 | + String(row.toolsCount), | |
| 542 | + String(row.triggersCount), | |
| 543 | + row.composioSlug, | |
| 544 | + row.version, | |
| 545 | + row.status, | |
| 546 | + row.protocolHints.join(";"), | |
| 547 | + row.endpointUrls.join(";"), | |
| 548 | + (row.usableOpenApiUrls ?? []).join(";"), | |
| 549 | + (row.usableGraphqlUrls ?? []).join(";"), | |
| 550 | + row.matchedApisGuru?.id ?? "", | |
| 551 | + row.matchedApisGuru?.providerName ?? "", | |
| 552 | + row.matchedApisGuru?.specUrl ?? "", | |
| 553 | + row.matchedApisGuru?.originUrl ?? "", | |
| 554 | + row.matchedApisGuru?.openapiVer ?? "", | |
| 555 | + row.docsUrl, | |
| 556 | + row.toolkitUrl, | |
| 557 | + row.error, | |
| 558 | + ]; | |
| 559 | + | |
| 560 | + lines.push(values.map(csvEscape).join(",")); | |
| 561 | + } | |
| 562 | + | |
| 563 | + return `${lines.join("\n")}\n`; | |
| 564 | +} | |
| 565 | + | |
| 566 | +function toNeedsResearchCsv(rows: ToolkitRecord[]): string { | |
| 567 | + const headers = ["slug", "name", "category", "auth", "toolsCount", "docsUrl", "toolkitUrl"]; | |
| 568 | + const lines = [headers.join(",")]; | |
| 569 | + | |
| 570 | + const filtered = rows | |
| 571 | + .filter((row) => row.status === "ok" && (row.usableOpenApiUrls ?? []).length === 0 && (row.usableGraphqlUrls ?? []).length === 0) | |
| 572 | + .sort((a, b) => b.toolsCount - a.toolsCount || a.slug.localeCompare(b.slug)); | |
| 573 | + | |
| 574 | + for (const row of filtered) { | |
| 575 | + lines.push([ | |
| 576 | + row.slug, | |
| 577 | + row.name, | |
| 578 | + row.category, | |
| 579 | + row.auth, | |
| 580 | + String(row.toolsCount), | |
| 581 | + row.docsUrl, | |
| 582 | + row.toolkitUrl, | |
| 583 | + ].map(csvEscape).join(",")); | |
| 584 | + } | |
| 585 | + | |
| 586 | + return `${lines.join("\n")}\n`; | |
| 587 | +} | |
| 588 | + | |
| 589 | +function toDiscoveriesCsv( | |
| 590 | + rows: ToolkitRecord[], | |
| 591 | + previous: Map<string, { openapi: number; graphql: number }>, | |
| 592 | +): string { | |
| 593 | + const headers = ["slug", "name", "newOpenApiUrls", "newGraphqlUrls", "totalOpenApiUrls", "totalGraphqlUrls"]; | |
| 594 | + const lines = [headers.join(",")]; | |
| 595 | + | |
| 596 | + const discovered = rows | |
| 597 | + .map((row) => { | |
| 598 | + const before = previous.get(row.slug) ?? { openapi: 0, graphql: 0 }; | |
| 599 | + const afterOpenApi = (row.usableOpenApiUrls ?? []).length; | |
| 600 | + const afterGraphql = (row.usableGraphqlUrls ?? []).length; | |
| 601 | + | |
| 602 | + return { | |
| 603 | + row, | |
| 604 | + newOpenApi: Math.max(0, afterOpenApi - before.openapi), | |
| 605 | + newGraphql: Math.max(0, afterGraphql - before.graphql), | |
| 606 | + afterOpenApi, | |
| 607 | + afterGraphql, | |
| 608 | + }; | |
| 609 | + }) | |
| 610 | + .filter((item) => item.newOpenApi > 0 || item.newGraphql > 0) | |
| 611 | + .sort((a, b) => (b.newOpenApi + b.newGraphql) - (a.newOpenApi + a.newGraphql) || a.row.slug.localeCompare(b.row.slug)); | |
| 612 | + | |
| 613 | + for (const item of discovered) { | |
| 614 | + lines.push([ | |
| 615 | + item.row.slug, | |
| 616 | + item.row.name, | |
| 617 | + String(item.newOpenApi), | |
| 618 | + String(item.newGraphql), | |
| 619 | + String(item.afterOpenApi), | |
| 620 | + String(item.afterGraphql), | |
| 621 | + ].map(csvEscape).join(",")); | |
| 622 | + } | |
| 623 | + | |
| 624 | + return `${lines.join("\n")}\n`; | |
| 625 | +} | |
| 626 | + | |
| 627 | +async function main(): Promise<void> { | |
| 628 | + const raw = await Bun.file(INPUT_PATH).json() as ToolkitRecord[]; | |
| 629 | + | |
| 630 | + const baseline = new Map<string, { openapi: number; graphql: number }>(); | |
| 631 | + for (const row of raw) { | |
| 632 | + row.usableOpenApiUrls = [...new Set(row.usableOpenApiUrls ?? [])].sort(); | |
| 633 | + row.usableGraphqlUrls = [...new Set(row.usableGraphqlUrls ?? [])].sort(); | |
| 634 | + | |
| 635 | + baseline.set(row.slug, { | |
| 636 | + openapi: row.usableOpenApiUrls.length, | |
| 637 | + graphql: row.usableGraphqlUrls.length, | |
| 638 | + }); | |
| 639 | + } | |
| 640 | + | |
| 641 | + const unresolved = raw.filter((row) => | |
| 642 | + row.status === "ok" && row.usableOpenApiUrls!.length === 0 && row.usableGraphqlUrls!.length === 0, | |
| 643 | + ); | |
| 644 | + | |
| 645 | + const filteredTargets = | |
| 646 | + only.length > 0 | |
| 647 | + ? unresolved.filter((row) => row.slug === only) | |
| 648 | + : (limit > 0 ? unresolved.slice(0, limit) : unresolved); | |
| 649 | + | |
| 650 | + if (filteredTargets.length === 0) { | |
| 651 | + console.log("[enrich] no unresolved rows to process"); | |
| 652 | + return; | |
| 653 | + } | |
| 654 | + | |
| 655 | + console.log(`[enrich] unresolved before run: ${unresolved.length}`); | |
| 656 | + console.log(`[enrich] processing ${filteredTargets.length} toolkit rows`); | |
| 657 | + | |
| 658 | + let completed = 0; | |
| 659 | + let inspected = 0; | |
| 660 | + | |
| 661 | + await mapLimit(filteredTargets, concurrency, async (row) => { | |
| 662 | + const enrichment = await enrichOne(row); | |
| 663 | + | |
| 664 | + inspected += enrichment.inspectedCandidates; | |
| 665 | + | |
| 666 | + if (enrichment.openapiUrls.length > 0) { | |
| 667 | + row.usableOpenApiUrls = [...new Set([...(row.usableOpenApiUrls ?? []), ...enrichment.openapiUrls])].sort(); | |
| 668 | + } | |
| 669 | + | |
| 670 | + if (enrichment.graphqlUrls.length > 0) { | |
| 671 | + row.usableGraphqlUrls = [...new Set([...(row.usableGraphqlUrls ?? []), ...enrichment.graphqlUrls])].sort(); | |
| 672 | + } | |
| 673 | + | |
| 674 | + completed += 1; | |
| 675 | + if (completed % 25 === 0 || completed === filteredTargets.length) { | |
| 676 | + console.log(`[enrich] processed ${completed}/${filteredTargets.length} (inspected=${inspected})`); | |
| 677 | + } | |
| 678 | + }); | |
| 679 | + | |
| 680 | + const outDir = fileURLToPath(new URL("./data/", import.meta.url)); | |
| 681 | + await mkdir(outDir, { recursive: true }); | |
| 682 | + | |
| 683 | + const summary = { | |
| 684 | + generatedAt: new Date().toISOString(), | |
| 685 | + inputPath: INPUT_PATH, | |
| 686 | + targetsProcessed: filteredTargets.length, | |
| 687 | + unresolvedBefore: unresolved.length, | |
| 688 | + unresolvedAfter: raw.filter((row) => row.status === "ok" && row.usableOpenApiUrls!.length === 0 && row.usableGraphqlUrls!.length === 0).length, | |
| 689 | + withUsableOpenApi: raw.filter((row) => (row.usableOpenApiUrls ?? []).length > 0).length, | |
| 690 | + withUsableGraphql: raw.filter((row) => (row.usableGraphqlUrls ?? []).length > 0).length, | |
| 691 | + withAnyUsableSpec: raw.filter((row) => (row.usableOpenApiUrls ?? []).length > 0 || (row.usableGraphqlUrls ?? []).length > 0).length, | |
| 692 | + inspectedCandidates: inspected, | |
| 693 | + args: { | |
| 694 | + concurrency, | |
| 695 | + limit, | |
| 696 | + only, | |
| 697 | + timeoutMs, | |
| 698 | + maxOpenApiCandidates, | |
| 699 | + maxGraphqlCandidates, | |
| 700 | + }, | |
| 701 | + }; | |
| 702 | + | |
| 703 | + await writeFile(OUTPUT_JSON_PATH, `${JSON.stringify(raw, null, 2)}\n`); | |
| 704 | + await writeFile(OUTPUT_CSV_PATH, toCsv(raw)); | |
| 705 | + await writeFile(OUTPUT_SUMMARY_PATH, `${JSON.stringify(summary, null, 2)}\n`); | |
| 706 | + await writeFile(OUTPUT_NEEDS_RESEARCH_PATH, toNeedsResearchCsv(raw)); | |
| 707 | + await writeFile(OUTPUT_DISCOVERIES_PATH, toDiscoveriesCsv(raw, baseline)); | |
| 708 | + | |
| 709 | + console.log("[enrich] done"); | |
| 710 | + console.log(`[enrich] json: ${OUTPUT_JSON_PATH}`); | |
| 711 | + console.log(`[enrich] csv: ${OUTPUT_CSV_PATH}`); | |
| 712 | + console.log(`[enrich] summary: ${OUTPUT_SUMMARY_PATH}`); | |
| 713 | + console.log(`[enrich] needs-research: ${OUTPUT_NEEDS_RESEARCH_PATH}`); | |
| 714 | + console.log(`[enrich] discoveries: ${OUTPUT_DISCOVERIES_PATH}`); | |
| 715 | + console.log( | |
| 716 | + `[enrich] stats: withUsableOpenApi=${summary.withUsableOpenApi}, withUsableGraphql=${summary.withUsableGraphql}, withAnyUsableSpec=${summary.withAnyUsableSpec}, unresolvedAfter=${summary.unresolvedAfter}`, | |
| 717 | + ); | |
| 718 | +} | |
| 719 | + | |
| 720 | +await main(); | |
| @@ -204,8 +204,17 @@ function extractUsableSpecUrls(markdown: string, matchedApisGuru: ApisGuruMatch · 203 unmodified lines | ||
| 204 | 204 | |
| 205 | 205 | for (const url of extractUrls(markdown)) { |
| 206 | 206 | const lower = url.toLowerCase(); |
| 207 | + const parsed = new URL(url); | |
| 208 | + const host = parsed.hostname.toLowerCase(); | |
| 209 | + const path = parsed.pathname.toLowerCase(); | |
| 210 | + | |
| 211 | + const isDocsHost = /^(docs|developer|developers)\./.test(host) || path.includes("/docs/"); | |
| 212 | + const looksLikeGraphqlEndpoint = | |
| 213 | + host.startsWith("graphql.") || | |
| 214 | + /\/graphql($|\/)/.test(path) || | |
| 215 | + /\/api\/graphql($|\/)/.test(path); | |
| 207 | 216 | |
| 208 | − if (/\/graphql($|[/?#])/i.test(url) || /graphql\./i.test(lower)) { | |
| 217 | + if (looksLikeGraphqlEndpoint && !isDocsHost) { | |
| 209 | 218 | graphqlUrls.add(url); |
| 210 | 219 | } |
| 211 | 220 | |
| @@ -7,6 +7,7 @@ · 6 unmodified lines | ||
| 7 | 7 | "start": "bun server.ts", |
| 8 | 8 | "sync": "bun server.ts --sync-only", |
| 9 | 9 | "scrape:composio": "bun composio-scrape/scrape.ts", |
| 10 | + "scrape:composio:enrich": "bun composio-scrape/enrich.ts", | |
| 10 | 11 | "typecheck": "bunx --package @typescript/native-preview tsgo --noEmit -p tsconfig.json", |
| 11 | 12 | "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json", |
| 12 | 13 | "clean": "rm -rf node_modules .turbo" |
| @@ -1,23 +1,29 @@ | ||
| 1 | 1 | import { |
| 2 | + RuntimeAdapterError, | |
| 2 | 3 | RuntimeExecutionPortError, |
| 3 | 4 | type ExecuteRuntimeRun, |
| 4 | −} from "@executor-v2/engine"; | |
| 5 | −import { | |
| 6 | − ToolProviderRegistryService, | |
| 5 | + type ExecuteRuntimeRunInput, | |
| 7 | 6 | makeRuntimeAdapterRegistry, |
| 8 | − makeToolProviderRegistry, | |
| 9 | 7 | type RuntimeExecuteError, |
| 10 | 8 | } from "@executor-v2/engine"; |
| 11 | 9 | import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc"; |
| 12 | −import { type ExecuteRunInput } from "@executor-v2/sdk"; | |
| 13 | 10 | import * as Effect from "effect/Effect"; |
| 14 | 11 | |
| 15 | 12 | const convexRuntimeAdapter = makeLocalInProcessRuntimeAdapter(); |
| 16 | 13 | |
| 17 | 14 | const runtimeAdapters = makeRuntimeAdapterRegistry([convexRuntimeAdapter]); |
| 18 | −const toolProviders = makeToolProviderRegistry([]); | |
| 19 | 15 | |
| 20 | −export const executeRuntimeRunInConvex: ExecuteRuntimeRun = (input: ExecuteRunInput) => | |
| 16 | +const missingToolCallService = (runId: string, toolPath: string): RuntimeAdapterError => | |
| 17 | + new RuntimeAdapterError({ | |
| 18 | + operation: "call_tool", | |
| 19 | + runtimeKind: convexRuntimeAdapter.kind, | |
| 20 | + message: `No runtime tool-call service configured in convex for ${toolPath}`, | |
| 21 | + details: `runId=${runId}`, | |
| 22 | + }); | |
| 23 | + | |
| 24 | +export const executeRuntimeRunInConvex: ExecuteRuntimeRun = ( | |
| 25 | + input: ExecuteRuntimeRunInput, | |
| 26 | +) => | |
| 21 | 27 | Effect.gen(function* () { |
| 22 | 28 | const runtimeAdapter = yield* runtimeAdapters.get(convexRuntimeAdapter.kind).pipe( |
| 23 | 29 | Effect.mapError( |
| @@ -41,12 +47,15 @@ export const executeRuntimeRunInConvex: ExecuteRuntimeRun = (input: ExecuteRunIn · 17 unmodified lines | ||
| 41 | 47 | |
| 42 | 48 | return yield* runtimeAdapter |
| 43 | 49 | .execute({ |
| 50 | + runId: input.runId, | |
| 44 | 51 | code: input.code, |
| 45 | 52 | timeoutMs: input.timeoutMs, |
| 46 | − tools: [], | |
| 53 | + toolCallService: { | |
| 54 | + callTool: (toolInput) => | |
| 55 | + Effect.fail(missingToolCallService(toolInput.runId, toolInput.toolPath)), | |
| 56 | + }, | |
| 47 | 57 | }) |
| 48 | 58 | .pipe( |
| 49 | − Effect.provideService(ToolProviderRegistryService, toolProviders), | |
| 50 | 59 | Effect.mapError( |
| 51 | 60 | (error: RuntimeExecuteError) => |
| 52 | 61 | new RuntimeExecutionPortError({ |
| @@ -11,7 +11,6 @@ import { · 10 unmodified lines | ||
| 11 | 11 | createRuntimeToolCallHandler, |
| 12 | 12 | createUnimplementedRuntimeToolInvoker, |
| 13 | 13 | makeRuntimeAdapterRegistry, |
| 14 | − makeToolProviderRegistry, | |
| 15 | 14 | } from "@executor-v2/engine"; |
| 16 | 15 | import { |
| 17 | 16 | makeLocalSourceStore, |
| @@ -56,7 +55,6 @@ const pmRuntimeAdapters = [ · 38 unmodified lines | ||
| 56 | 55 | ]; |
| 57 | 56 | |
| 58 | 57 | const runtimeAdapters = makeRuntimeAdapterRegistry(pmRuntimeAdapters); |
| 59 | −const toolProviders = makeToolProviderRegistry([]); | |
| 60 | 58 | |
| 61 | 59 | const defaultRuntimeKind = |
| 62 | 60 | readConfiguredRuntimeKind(process.env.PM_RUNTIME_KIND) ?? pmRuntimeAdapters[0].kind; |
| @@ -85,22 +83,22 @@ const controlPlaneWebHandler = makeControlPlaneWebHandler( · 22 unmodified lines | ||
| 85 | 83 | PmActorLive(localStateStore), |
| 86 | 84 | ); |
| 87 | 85 | |
| 86 | +const resolveCredentials = createPmResolveToolCredentials(localStateStore); | |
| 87 | +const invokeRuntimeTool = createUnimplementedRuntimeToolInvoker("pm"); | |
| 88 | +const handleToolCall = createRuntimeToolCallHandler({ | |
| 89 | + resolveCredentials, | |
| 90 | + invokeRuntimeTool, | |
| 91 | +}); | |
| 92 | + | |
| 88 | 93 | const executeRuntimeRun = createPmExecuteRuntimeRun({ |
| 89 | 94 | defaultRuntimeKind, |
| 90 | 95 | runtimeAdapters, |
| 91 | − toolProviders, | |
| 96 | + handleToolCall, | |
| 92 | 97 | }); |
| 93 | 98 | |
| 94 | 99 | const runExecutor = createRunExecutor(executeRuntimeRun); |
| 95 | 100 | const handleMcp = createPmMcpHandler(runExecutor.executeRun); |
| 96 | 101 | |
| 97 | −const resolveCredentials = createPmResolveToolCredentials(localStateStore); | |
| 98 | −const invokeRuntimeTool = createUnimplementedRuntimeToolInvoker("pm"); | |
| 99 | −const handleToolCall = createRuntimeToolCallHandler({ | |
| 100 | − resolveCredentials, | |
| 101 | − invokeRuntimeTool, | |
| 102 | −}); | |
| 103 | − | |
| 104 | 102 | const handleToolCallHttp = createPmToolCallHttpHandler((input) => |
| 105 | 103 | Effect.runPromise(handleToolCall(input)), |
| 106 | 104 | ); |
| @@ -1,26 +1,63 @@ | ||
| 1 | 1 | import { |
| 2 | + RuntimeAdapterError, | |
| 2 | 3 | RuntimeExecutionPortError, |
| 3 | 4 | type ExecuteRuntimeRun, |
| 4 | −} from "@executor-v2/engine"; | |
| 5 | −import { | |
| 6 | − ToolProviderRegistryService, | |
| 5 | + type ExecuteRuntimeRunInput, | |
| 7 | 6 | type RuntimeAdapterRegistry, |
| 8 | 7 | type RuntimeExecuteError, |
| 9 | − type ToolProviderRegistry, | |
| 8 | + type RuntimeToolCallService, | |
| 10 | 9 | } from "@executor-v2/engine"; |
| 11 | −import { type ExecuteRunInput } from "@executor-v2/sdk"; | |
| 10 | +import { | |
| 11 | + type RuntimeToolCallRequest, | |
| 12 | + type RuntimeToolCallResult, | |
| 13 | +} from "@executor-v2/sdk"; | |
| 12 | 14 | import * as Effect from "effect/Effect"; |
| 13 | 15 | |
| 14 | 16 | export type PmRuntimeExecutionPortOptions = { |
| 15 | 17 | defaultRuntimeKind: string; |
| 16 | 18 | runtimeAdapters: RuntimeAdapterRegistry; |
| 17 | − toolProviders: ToolProviderRegistry; | |
| 19 | + handleToolCall: ( | |
| 20 | + input: RuntimeToolCallRequest, | |
| 21 | + ) => Effect.Effect<RuntimeToolCallResult, never>; | |
| 18 | 22 | }; |
| 19 | 23 | |
| 24 | +const runtimeToolCallService = ( | |
| 25 | + options: PmRuntimeExecutionPortOptions, | |
| 26 | +): RuntimeToolCallService => ({ | |
| 27 | + callTool: (input) => | |
| 28 | + options.handleToolCall(input).pipe( | |
| 29 | + Effect.flatMap((result) => { | |
| 30 | + if (result.ok) { | |
| 31 | + return Effect.succeed(result.value); | |
| 32 | + } | |
| 33 | + | |
| 34 | + if (result.kind === "pending") { | |
| 35 | + return Effect.fail( | |
| 36 | + new RuntimeAdapterError({ | |
| 37 | + operation: "call_tool", | |
| 38 | + runtimeKind: options.defaultRuntimeKind, | |
| 39 | + message: `Tool call pending approval: ${input.toolPath}`, | |
| 40 | + details: result.error ?? `approvalId=${result.approvalId}`, | |
| 41 | + }), | |
| 42 | + ); | |
| 43 | + } | |
| 44 | + | |
| 45 | + return Effect.fail( | |
| 46 | + new RuntimeAdapterError({ | |
| 47 | + operation: "call_tool", | |
| 48 | + runtimeKind: options.defaultRuntimeKind, | |
| 49 | + message: `Tool call ${result.kind}: ${input.toolPath}`, | |
| 50 | + details: result.error, | |
| 51 | + }), | |
| 52 | + ); | |
| 53 | + }), | |
| 54 | + ), | |
| 55 | +}); | |
| 56 | + | |
| 20 | 57 | export const createPmExecuteRuntimeRun = ( |
| 21 | 58 | options: PmRuntimeExecutionPortOptions, |
| 22 | 59 | ): ExecuteRuntimeRun => { |
| 23 | − return (input: ExecuteRunInput) => | |
| 60 | + return (input: ExecuteRuntimeRunInput) => | |
| 24 | 61 | Effect.gen(function* () { |
| 25 | 62 | const runtimeAdapter = yield* options.runtimeAdapters |
| 26 | 63 | .get(options.defaultRuntimeKind) |
| @@ -46,12 +83,12 @@ export const createPmExecuteRuntimeRun = ( · 19 unmodified lines | ||
| 46 | 83 | |
| 47 | 84 | return yield* runtimeAdapter |
| 48 | 85 | .execute({ |
| 86 | + runId: input.runId, | |
| 49 | 87 | code: input.code, |
| 50 | 88 | timeoutMs: input.timeoutMs, |
| 51 | − tools: [], | |
| 89 | + toolCallService: runtimeToolCallService(options), | |
| 52 | 90 | }) |
| 53 | 91 | .pipe( |
| 54 | − Effect.provideService(ToolProviderRegistryService, options.toolProviders), | |
| 55 | 92 | Effect.mapError( |
| 56 | 93 | (error: RuntimeExecuteError) => |
| 57 | 94 | new RuntimeExecutionPortError({ |
| @@ -86,14 +86,10 @@ · 85 unmodified lines | ||
| 86 | 86 | "zod": "^4.3.6", |
| 87 | 87 | }, |
| 88 | 88 | "devDependencies": { |
| 89 | − "@effect/platform": "catalog:", | |
| 90 | 89 | "@effect/vitest": "catalog:", |
| 91 | 90 | "@executor-v2/engine": "workspace:*", |
| 92 | − "@executor-v2/management-api": "workspace:*", | |
| 93 | − "@executor-v2/persistence-ports": "workspace:*", | |
| 94 | 91 | "@executor-v2/runtime-deno-subprocess": "workspace:*", |
| 95 | 92 | "@executor-v2/runtime-local-inproc": "workspace:*", |
| 96 | − "@executor-v2/schema": "workspace:*", | |
| 97 | 93 | "ai": "^6.0.0", |
| 98 | 94 | "effect": "catalog:", |
| 99 | 95 | "vitest": "catalog:", |
| @@ -137,6 +133,7 @@ · 37 unmodified lines | ||
| 137 | 133 | "effect": "catalog:", |
| 138 | 134 | }, |
| 139 | 135 | "devDependencies": { |
| 136 | + "@effect/platform": "catalog:", | |
| 140 | 137 | "@effect/vitest": "catalog:", |
| 141 | 138 | "@executor-v2/management-api": "workspace:*", |
| 142 | 139 | "vitest": "catalog:", |
| @@ -15,14 +15,10 @@ · 14 unmodified lines | ||
| 15 | 15 | "zod": "^4.3.6" |
| 16 | 16 | }, |
| 17 | 17 | "devDependencies": { |
| 18 | − "@effect/platform": "catalog:", | |
| 19 | 18 | "@effect/vitest": "catalog:", |
| 20 | 19 | "@executor-v2/engine": "workspace:*", |
| 21 | − "@executor-v2/management-api": "workspace:*", | |
| 22 | − "@executor-v2/persistence-ports": "workspace:*", | |
| 23 | 20 | "@executor-v2/runtime-deno-subprocess": "workspace:*", |
| 24 | 21 | "@executor-v2/runtime-local-inproc": "workspace:*", |
| 25 | − "@executor-v2/schema": "workspace:*", | |
| 26 | 22 | "ai": "^6.0.0", |
| 27 | 23 | "effect": "catalog:", |
| 28 | 24 | "vitest": "catalog:" |
| @@ -1,39 +1,14 @@ | ||
| 1 | −import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; | |
| 2 | − | |
| 3 | −import { | |
| 4 | − HttpApi, | |
| 5 | − HttpApiEndpoint, | |
| 6 | − HttpApiGroup, | |
| 7 | − HttpApiSchema, | |
| 8 | − OpenApi, | |
| 9 | −} from "@effect/platform"; | |
| 10 | −import * as FetchHttpClient from "@effect/platform/FetchHttpClient"; | |
| 11 | −import * as HttpClient from "@effect/platform/HttpClient"; | |
| 12 | −import * as HttpClientResponse from "@effect/platform/HttpClientResponse"; | |
| 13 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 14 | 2 | import { |
| 15 | 3 | createInMemoryRuntimeRunClient, |
| 16 | 4 | createRuntimeRunClient, |
| 17 | − makeOpenApiToolProvider, | |
| 18 | − makeToolProviderRegistry, | |
| 19 | − openApiToolDescriptorsFromManifest, | |
| 20 | 5 | } from "@executor-v2/engine"; |
| 21 | −import { makeSourceManagerService } from "@executor-v2/management-api"; | |
| 22 | −import { type ToolArtifactStore } from "@executor-v2/persistence-ports"; | |
| 23 | −import { | |
| 24 | − SourceSchema, | |
| 25 | − type Source, | |
| 26 | − type ToolArtifact, | |
| 27 | −} from "@executor-v2/schema"; | |
| 28 | 6 | import { makeDenoSubprocessRuntimeAdapter } from "@executor-v2/runtime-deno-subprocess"; |
| 29 | 7 | import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc"; |
| 30 | 8 | import { createExecutorRunClient } from "@executor-v2/sdk"; |
| 31 | 9 | import type { ExecuteRunResult } from "@executor-v2/sdk"; |
| 32 | 10 | import { createGateway, generateText, stepCountIs, tool } from "ai"; |
| 33 | −import * as Data from "effect/Data"; | |
| 34 | 11 | import * as Effect from "effect/Effect"; |
| 35 | −import * as Option from "effect/Option"; | |
| 36 | −import * as Schema from "effect/Schema"; | |
| 37 | 12 | |
| 38 | 13 | import { z } from "zod"; |
| 39 | 14 | |
| @@ -41,415 +16,165 @@ import { toAiSdkTools } from "./index"; · 1 unmodified line | ||
| 41 | 16 | |
| 42 | 17 | const gateway = createGateway(); |
| 43 | 18 | |
| 44 | −const decodeSource = Schema.decodeUnknownSync(SourceSchema); | |
| 45 | − | |
| 46 | −type GitHubApiTestServer = { | |
| 47 | − baseUrl: string; | |
| 48 | − requests: Array<{ | |
| 49 | − path: string; | |
| 50 | − accept: string | null; | |
| 51 | − }>; | |
| 52 | − close: () => Promise<void>; | |
| 53 | −}; | |
| 54 | − | |
| 55 | −class GitHubApiServerReleaseError extends Data.TaggedError( | |
| 56 | − "GitHubApiServerReleaseError", | |
| 57 | −)<{ | |
| 58 | − message: string; | |
| 59 | −}> {} | |
| 60 | − | |
| 61 | −const jsonResponse = (res: ServerResponse, statusCode: number, body: unknown): void => { | |
| 62 | − res.statusCode = statusCode; | |
| 63 | − res.setHeader("content-type", "application/json"); | |
| 64 | − res.end(JSON.stringify(body)); | |
| 65 | −}; | |
| 66 | − | |
| 67 | −const getHeaderValue = (req: IncomingMessage, key: string): string | null => { | |
| 68 | − const value = req.headers[key]; | |
| 69 | − if (typeof value === "string") { | |
| 70 | − return value; | |
| 71 | − } | |
| 72 | − | |
| 73 | − if (Array.isArray(value)) { | |
| 74 | − return value[0] ?? null; | |
| 75 | − } | |
| 76 | − | |
| 77 | − return null; | |
| 78 | −}; | |
| 79 | − | |
| 80 | −const makeGitHubApiTestServer = Effect.acquireRelease( | |
| 81 | − Effect.promise<GitHubApiTestServer>( | |
| 82 | − () => | |
| 83 | − new Promise<GitHubApiTestServer>((resolve, reject) => { | |
| 84 | − const requests: GitHubApiTestServer["requests"] = []; | |
| 85 | − | |
| 86 | − const server = createServer((req, res) => { | |
| 87 | − const host = getHeaderValue(req, "host") ?? "127.0.0.1"; | |
| 88 | − const url = new URL(req.url ?? "/", `http://${host}`); | |
| 89 | − | |
| 90 | − if (url.pathname === openApiPath && req.method === "GET") { | |
| 91 | − jsonResponse(res, 200, githubOpenApiSpec); | |
| 92 | − return; | |
| 93 | − } | |
| 94 | − | |
| 95 | − if (url.pathname === "/repos/octocat/hello-world" && req.method === "GET") { | |
| 96 | − requests.push({ | |
| 97 | − path: url.pathname, | |
| 98 | − accept: getHeaderValue(req, "accept"), | |
| 99 | − }); | |
| 100 | − | |
| 101 | − jsonResponse(res, 200, { | |
| 102 | − full_name: "octocat/hello-world", | |
| 103 | − stargazers_count: 42, | |
| 104 | − private: false, | |
| 105 | − }); | |
| 106 | − return; | |
| 107 | − } | |
| 108 | − | |
| 109 | − jsonResponse(res, 404, { | |
| 110 | − message: "Not Found", | |
| 111 | − }); | |
| 112 | − }); | |
| 113 | − | |
| 114 | − server.once("error", (error) => { | |
| 115 | − reject(error); | |
| 116 | − }); | |
| 117 | − | |
| 118 | − server.listen(0, "127.0.0.1", () => { | |
| 119 | − const address = server.address(); | |
| 120 | − if (!address || typeof address === "string") { | |
| 121 | − reject(new Error("failed to resolve GitHub API test server address")); | |
| 122 | − return; | |
| 123 | − } | |
| 124 | − | |
| 125 | − resolve({ | |
| 126 | − baseUrl: `http://127.0.0.1:${address.port}`, | |
| 127 | − requests, | |
| 128 | − close: () => | |
| 129 | − new Promise<void>((closeResolve, closeReject) => { | |
| 130 | − server.close((error) => { | |
| 131 | − if (error) { | |
| 132 | − closeReject(error); | |
| 133 | − return; | |
| 134 | − } | |
| 135 | − closeResolve(); | |
| 136 | − }); | |
| 137 | − }), | |
| 138 | − }); | |
| 139 | − }); | |
| 140 | − }), | |
| 141 | − ), | |
| 142 | − (server) => | |
| 143 | − Effect.tryPromise({ | |
| 144 | − try: () => server.close(), | |
| 145 | − catch: (cause) => | |
| 146 | − new GitHubApiServerReleaseError({ | |
| 147 | − message: cause instanceof Error ? cause.message : String(cause), | |
| 19 | +describe("toAiSdkTools", () => { | |
| 20 | + it.effect("generates a tool call via generateText with a mock executor", () => | |
| 21 | + Effect.gen(function* () { | |
| 22 | + const executionLog: Array<{ code: string; timeoutMs?: number }> = []; | |
| 23 | + | |
| 24 | + const mockResult: ExecuteRunResult = { | |
| 25 | + runId: "run-test-123", | |
| 26 | + status: "completed", | |
| 27 | + result: 42, | |
| 28 | + }; | |
| 29 | + | |
| 30 | + const runClient = createExecutorRunClient(async (input) => { | |
| 31 | + executionLog.push({ code: input.code, timeoutMs: input.timeoutMs }); | |
| 32 | + return mockResult; | |
| 33 | + }); | |
| 34 | + | |
| 35 | + const tools = toAiSdkTools({ | |
| 36 | + runClient, | |
| 37 | + makeTool: (def) => tool(def), | |
| 38 | + defaults: { timeoutMs: 30_000 }, | |
| 39 | + }); | |
| 40 | + | |
| 41 | + const result = yield* Effect.tryPromise(() => | |
| 42 | + generateText({ | |
| 43 | + model: gateway("openai/gpt-4o-mini"), | |
| 44 | + tools, | |
| 45 | + stopWhen: stepCountIs(3), | |
| 46 | + system: | |
| 47 | + "You have an execute tool that runs JavaScript code. Always use it when asked to run code.", | |
| 48 | + prompt: 'Run this code using the execute tool: console.log("hello")', | |
| 148 | 49 | }), |
| 149 | − }).pipe(Effect.orDie), | |
| 150 | −); | |
| 151 | − | |
| 152 | −const openApiPath = "/openapi.json"; | |
| 153 | − | |
| 154 | −const githubOwnerParam = HttpApiSchema.param("owner", Schema.String); | |
| 155 | −const githubRepoParam = HttpApiSchema.param("repo", Schema.String); | |
| 156 | − | |
| 157 | −class GitHubReposApi extends HttpApiGroup.make("repos").add( | |
| 158 | − HttpApiEndpoint.get("getRepo")`/repos/${githubOwnerParam}/${githubRepoParam}`.addSuccess( | |
| 159 | − Schema.Unknown, | |
| 160 | − ), | |
| 161 | −) {} | |
| 162 | − | |
| 163 | −class GitHubApi extends HttpApi.make("github").add(GitHubReposApi) {} | |
| 164 | − | |
| 165 | −const githubOpenApiSpec = OpenApi.fromApi(GitHubApi); | |
| 166 | − | |
| 167 | −const makeGitHubSource = (baseUrl: string): Source => | |
| 168 | − decodeSource({ | |
| 169 | − id: "src_github", | |
| 170 | − workspaceId: "ws_local", | |
| 171 | − name: "github", | |
| 172 | − kind: "openapi", | |
| 173 | − endpoint: baseUrl, | |
| 174 | − status: "connected", | |
| 175 | − enabled: true, | |
| 176 | − configJson: "{}", | |
| 177 | − sourceHash: null, | |
| 178 | − lastError: null, | |
| 179 | − createdAt: Date.now(), | |
| 180 | − updatedAt: Date.now(), | |
| 181 | − }); | |
| 182 | − | |
| 183 | −const onlyTool = <T extends { toolId: string }>(descriptors: ReadonlyArray<T>): T => { | |
| 184 | − if (descriptors.length !== 1) { | |
| 185 | − throw new Error(`expected exactly one tool descriptor, got ${descriptors.length}`); | |
| 186 | − } | |
| 187 | − return descriptors[0]!; | |
| 188 | −}; | |
| 189 | − | |
| 190 | −const githubRepoLookupCodeFor = (toolId: string): string => | |
| 191 | − `return await tools[${JSON.stringify(toolId)}]({ owner: "octocat", repo: "hello-world" });`; | |
| 192 | − | |
| 193 | −const createGitHubRepoLookupRunClient = (baseUrl: string) => | |
| 194 | − Effect.gen(function* () { | |
| 195 | − const source = makeGitHubSource(baseUrl); | |
| 50 | + ); | |
| 51 | + | |
| 52 | + expect(executionLog.length).toBeGreaterThanOrEqual(1); | |
| 53 | + expect(executionLog[0]!.code).toBeTypeOf("string"); | |
| 54 | + expect(executionLog[0]!.code.length).toBeGreaterThan(0); | |
| 55 | + expect(executionLog[0]!.timeoutMs).toBeTypeOf("number"); | |
| 56 | + | |
| 57 | + const toolCallSteps = result.steps.filter( | |
| 58 | + (step) => step.toolCalls.length > 0, | |
| 59 | + ); | |
| 60 | + expect(toolCallSteps.length).toBeGreaterThanOrEqual(1); | |
| 61 | + | |
| 62 | + const firstToolCall = toolCallSteps[0]!.toolCalls[0]!; | |
| 63 | + expect(firstToolCall.toolName).toBe("execute"); | |
| 64 | + expect(firstToolCall.input).toHaveProperty("code"); | |
| 65 | + | |
| 66 | + const toolResultSteps = result.steps.filter( | |
| 67 | + (step) => step.toolResults.length > 0, | |
| 68 | + ); | |
| 69 | + expect(toolResultSteps.length).toBeGreaterThanOrEqual(1); | |
| 70 | + const toolResult = toolResultSteps[0]!.toolResults[0]!; | |
| 71 | + expect(toolResult.toolName).toBe("execute"); | |
| 72 | + expect(toolResult.output).toMatchObject(mockResult); | |
| 73 | + | |
| 74 | + expect(result.text).toBeTypeOf("string"); | |
| 75 | + }), | |
| 76 | + ); | |
| 196 | 77 | |
| 197 | − const openApiSpec = yield* HttpClient.get(`${baseUrl}${openApiPath}`).pipe( | |
| 198 | − Effect.flatMap(HttpClientResponse.filterStatusOk), | |
| 199 | − Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Unknown)), | |
| 200 | − Effect.provide(FetchHttpClient.layer), | |
| 201 | − ); | |
| 78 | + it.effect("calls a normal AI SDK tool from the execute sandbox", () => | |
| 79 | + Effect.gen(function* () { | |
| 80 | + const normalToolCalls: Array<{ query: string }> = []; | |
| 202 | 81 | |
| 203 | − const artifactsByKey = new Map<string, ToolArtifact>(); | |
| 204 | − const artifactStore: ToolArtifactStore = { | |
| 205 | − getBySource: (workspaceId: Source["workspaceId"], sourceId: Source["id"]) => | |
| 206 | − Effect.succeed( | |
| 207 | − Option.fromNullable(artifactsByKey.get(`${workspaceId}:${sourceId}`)), | |
| 208 | − ), | |
| 209 | − upsert: (artifact: ToolArtifact) => | |
| 210 | − Effect.sync(() => { | |
| 211 | − artifactsByKey.set(`${artifact.workspaceId}:${artifact.sourceId}`, artifact); | |
| 82 | + const searchDocsTool = tool({ | |
| 83 | + description: "Search docs by query", | |
| 84 | + inputSchema: z.object({ | |
| 85 | + query: z.string(), | |
| 212 | 86 | }), |
| 213 | − }; | |
| 214 | − | |
| 215 | − const sourceManager = makeSourceManagerService(artifactStore); | |
| 216 | − const refreshResult = yield* sourceManager.refreshOpenApiArtifact({ | |
| 217 | − source, | |
| 218 | − openApiSpec, | |
| 219 | − }); | |
| 220 | − | |
| 221 | − const descriptors = yield* openApiToolDescriptorsFromManifest( | |
| 222 | − source, | |
| 223 | − refreshResult.artifact.manifestJson, | |
| 224 | − ); | |
| 225 | − const githubTool = onlyTool(descriptors); | |
| 87 | + execute: async (input: { query: string }) => { | |
| 88 | + normalToolCalls.push(input); | |
| 89 | + return { | |
| 90 | + hits: [`match:${input.query}`], | |
| 91 | + }; | |
| 92 | + }, | |
| 93 | + }); | |
| 226 | 94 | |
| 227 | − return { | |
| 228 | − runClient: createRuntimeRunClient({ | |
| 95 | + const runClient = createInMemoryRuntimeRunClient({ | |
| 229 | 96 | runtimeAdapter: makeLocalInProcessRuntimeAdapter(), |
| 230 | − toolProviderRegistry: makeToolProviderRegistry([makeOpenApiToolProvider()]), | |
| 231 | − tools: [ | |
| 232 | − { | |
| 233 | − descriptor: githubTool, | |
| 234 | − source, | |
| 235 | − }, | |
| 236 | − ], | |
| 97 | + tools: { | |
| 98 | + search_docs: searchDocsTool, | |
| 99 | + }, | |
| 237 | 100 | defaults: { |
| 238 | 101 | timeoutMs: 30_000, |
| 239 | 102 | }, |
| 240 | − }), | |
| 241 | − toolId: githubTool.toolId, | |
| 242 | − }; | |
| 243 | − }); | |
| 103 | + }); | |
| 244 | 104 | |
| 245 | −describe("toAiSdkTools", () => { | |
| 246 | − it.effect( | |
| 247 | − "generates a tool call via generateText with a mock executor", | |
| 248 | − () => | |
| 249 | − Effect.gen(function* () { | |
| 250 | − const executionLog: Array<{ code: string; timeoutMs?: number }> = []; | |
| 105 | + const tools = toAiSdkTools({ | |
| 106 | + runClient, | |
| 107 | + makeTool: (def) => def, | |
| 108 | + }); | |
| 251 | 109 | |
| 252 | − const mockResult: ExecuteRunResult = { | |
| 253 | − runId: "run-test-123", | |
| 254 | − status: "completed", | |
| 255 | − result: 42, | |
| 256 | − }; | |
| 257 | − | |
| 258 | − const runClient = createExecutorRunClient(async (input) => { | |
| 259 | − executionLog.push({ code: input.code, timeoutMs: input.timeoutMs }); | |
| 260 | − return mockResult; | |
| 261 | − }); | |
| 262 | − | |
| 263 | − const tools = toAiSdkTools({ | |
| 264 | − runClient, | |
| 265 | − makeTool: (def) => tool(def), | |
| 266 | − defaults: { timeoutMs: 30_000 }, | |
| 267 | − }); | |
| 268 | − | |
| 269 | − const result = yield* Effect.tryPromise(() => | |
| 270 | − generateText({ | |
| 271 | − model: gateway("openai/gpt-4o-mini"), | |
| 272 | − tools, | |
| 273 | − stopWhen: stepCountIs(3), | |
| 274 | − system: | |
| 275 | − "You have an execute tool that runs JavaScript code. Always use it when asked to run code.", | |
| 276 | − prompt: | |
| 277 | − 'Run this code using the execute tool: console.log("hello")', | |
| 278 | − }), | |
| 279 | − ); | |
| 280 | − | |
| 281 | − expect(executionLog.length).toBeGreaterThanOrEqual(1); | |
| 282 | − expect(executionLog[0]!.code).toBeTypeOf("string"); | |
| 283 | − expect(executionLog[0]!.code.length).toBeGreaterThan(0); | |
| 284 | − expect(executionLog[0]!.timeoutMs).toBeTypeOf("number"); | |
| 285 | − | |
| 286 | − const toolCallSteps = result.steps.filter( | |
| 287 | − (step) => step.toolCalls.length > 0, | |
| 288 | − ); | |
| 289 | − expect(toolCallSteps.length).toBeGreaterThanOrEqual(1); | |
| 290 | − | |
| 291 | − const firstToolCall = toolCallSteps[0]!.toolCalls[0]!; | |
| 292 | − expect(firstToolCall.toolName).toBe("execute"); | |
| 293 | − expect(firstToolCall.input).toHaveProperty("code"); | |
| 294 | − | |
| 295 | − const toolResultSteps = result.steps.filter( | |
| 296 | − (step) => step.toolResults.length > 0, | |
| 297 | − ); | |
| 298 | − expect(toolResultSteps.length).toBeGreaterThanOrEqual(1); | |
| 299 | − const toolResult = toolResultSteps[0]!.toolResults[0]!; | |
| 300 | − expect(toolResult.toolName).toBe("execute"); | |
| 301 | − expect(toolResult.output).toMatchObject(mockResult); | |
| 302 | − | |
| 303 | − expect(result.text).toBeTypeOf("string"); | |
| 304 | − }), | |
| 305 | − { timeout: 30_000 }, | |
| 306 | − ); | |
| 307 | − | |
| 308 | − it.effect( | |
| 309 | − "calls a normal AI SDK tool from the execute sandbox", | |
| 310 | − () => | |
| 311 | − Effect.gen(function* () { | |
| 312 | − const normalToolCalls: Array<{ query: string }> = []; | |
| 313 | − | |
| 314 | − const searchDocsTool = tool({ | |
| 315 | − description: "Search docs by query", | |
| 316 | − inputSchema: z.object({ | |
| 317 | − query: z.string(), | |
| 318 | − }), | |
| 319 | − execute: async (input: { query: string }) => { | |
| 320 | − normalToolCalls.push(input); | |
| 321 | − return { | |
| 322 | − hits: [`match:${input.query}`], | |
| 323 | − }; | |
| 324 | − }, | |
| 325 | − }); | |
| 326 | − | |
| 327 | − const runClient = createInMemoryRuntimeRunClient({ | |
| 328 | − runtimeAdapter: makeLocalInProcessRuntimeAdapter(), | |
| 329 | − tools: { | |
| 330 | − search_docs: searchDocsTool, | |
| 331 | − }, | |
| 332 | − defaults: { | |
| 333 | − timeoutMs: 30_000, | |
| 334 | − }, | |
| 335 | − }); | |
| 336 | − | |
| 337 | − const tools = toAiSdkTools({ | |
| 338 | − runClient, | |
| 339 | − makeTool: (def) => def, | |
| 340 | − }); | |
| 341 | − | |
| 342 | − const result = yield* Effect.tryPromise(() => | |
| 343 | − tools.execute.execute({ | |
| 344 | − code: "return await tools.search_docs({ query: 'codemode adapter integration' });", | |
| 345 | − }), | |
| 346 | − ); | |
| 347 | − | |
| 348 | − expect(result.status).toBe("completed"); | |
| 349 | − expect(result.result).toEqual({ | |
| 350 | − hits: ["match:codemode adapter integration"], | |
| 351 | − }); | |
| 352 | − expect(normalToolCalls).toEqual([ | |
| 353 | − { | |
| 354 | − query: "codemode adapter integration", | |
| 355 | − }, | |
| 356 | − ]); | |
| 357 | − }), | |
| 358 | − { timeout: 30_000 }, | |
| 359 | − ); | |
| 360 | − | |
| 361 | − it.scoped( | |
| 362 | − "loads a GitHub OpenAPI tool and calls it inside the sandbox", | |
| 363 | − () => | |
| 364 | − Effect.gen(function* () { | |
| 365 | − const server = yield* makeGitHubApiTestServer; | |
| 366 | − const { runClient, toolId } = yield* createGitHubRepoLookupRunClient( | |
| 367 | − server.baseUrl, | |
| 368 | − ); | |
| 369 | − | |
| 370 | − const tools = toAiSdkTools({ | |
| 371 | − runClient, | |
| 372 | − makeTool: (definition) => definition, | |
| 373 | − }); | |
| 374 | − | |
| 375 | − const result = yield* Effect.tryPromise(() => | |
| 376 | − tools.execute.execute({ | |
| 377 | − code: githubRepoLookupCodeFor(toolId), | |
| 378 | − }), | |
| 379 | − ); | |
| 380 | − | |
| 381 | − expect(result.status).toBe("completed"); | |
| 382 | − expect(result.result).toMatchObject({ | |
| 383 | − status: 200, | |
| 384 | − body: { | |
| 385 | − full_name: "octocat/hello-world", | |
| 386 | − stargazers_count: 42, | |
| 387 | − private: false, | |
| 388 | − }, | |
| 389 | − }); | |
| 390 | − | |
| 391 | − expect(server.requests).toHaveLength(1); | |
| 392 | − expect(server.requests[0]?.path).toBe("/repos/octocat/hello-world"); | |
| 393 | − }), | |
| 394 | − { timeout: 30_000 }, | |
| 110 | + const result = yield* Effect.tryPromise(() => | |
| 111 | + tools.execute.execute({ | |
| 112 | + code: "return await tools.search_docs({ query: 'codemode adapter integration' });", | |
| 113 | + }), | |
| 114 | + ); | |
| 115 | + | |
| 116 | + expect(result.status).toBe("completed"); | |
| 117 | + expect(result.result).toEqual({ | |
| 118 | + hits: ["match:codemode adapter integration"], | |
| 119 | + }); | |
| 120 | + expect(normalToolCalls).toEqual([ | |
| 121 | + { | |
| 122 | + query: "codemode adapter integration", | |
| 123 | + }, | |
| 124 | + ]); | |
| 125 | + }), | |
| 395 | 126 | ); |
| 396 | 127 | |
| 397 | − it.effect( | |
| 398 | − "executes code in a real Deno subprocess via generateText", | |
| 399 | − () => | |
| 400 | − Effect.gen(function* () { | |
| 401 | − const runtimeAdapter = makeDenoSubprocessRuntimeAdapter({ | |
| 402 | − defaultTimeoutMs: 10_000, | |
| 403 | − }); | |
| 404 | − | |
| 405 | − const toolProviderRegistry = makeToolProviderRegistry([]); | |
| 406 | − | |
| 407 | − const runClient = createRuntimeRunClient({ | |
| 408 | − runtimeAdapter, | |
| 409 | − toolProviderRegistry, | |
| 410 | − }); | |
| 411 | − | |
| 412 | − const tools = toAiSdkTools({ | |
| 413 | − runClient, | |
| 414 | − makeTool: (def) => tool(def), | |
| 415 | − }); | |
| 416 | − | |
| 417 | − const result = yield* Effect.tryPromise(() => | |
| 418 | − generateText({ | |
| 419 | − model: gateway("openai/gpt-4o-mini"), | |
| 420 | − tools, | |
| 421 | − stopWhen: stepCountIs(3), | |
| 422 | − system: [ | |
| 423 | − "You have an execute tool that runs JavaScript code in a sandboxed Deno runtime.", | |
| 424 | − "Always use it when asked to run code.", | |
| 425 | − "The code must use `return` to produce a result value.", | |
| 426 | − ].join(" "), | |
| 427 | − prompt: "Use the execute tool to compute 2 + 3. The code should be: return 2 + 3;", | |
| 428 | − }), | |
| 429 | − ); | |
| 430 | − | |
| 431 | − const toolCallSteps = result.steps.filter( | |
| 432 | − (step) => step.toolCalls.length > 0, | |
| 433 | − ); | |
| 434 | − expect(toolCallSteps.length).toBeGreaterThanOrEqual(1); | |
| 435 | − | |
| 436 | − const firstToolCall = toolCallSteps[0]!.toolCalls[0]!; | |
| 437 | − expect(firstToolCall.toolName).toBe("execute"); | |
| 438 | − | |
| 439 | − const toolResultSteps = result.steps.filter( | |
| 440 | − (step) => step.toolResults.length > 0, | |
| 441 | − ); | |
| 442 | − expect(toolResultSteps.length).toBeGreaterThanOrEqual(1); | |
| 443 | − | |
| 444 | − const toolResult = toolResultSteps[0]!.toolResults[0]!; | |
| 445 | − expect(toolResult.toolName).toBe("execute"); | |
| 446 | − expect(toolResult.output).toMatchObject({ | |
| 447 | − status: "completed", | |
| 448 | − result: 5, | |
| 449 | − }); | |
| 450 | − | |
| 451 | − expect(result.text).toBeTypeOf("string"); | |
| 452 | − }), | |
| 453 | − { timeout: 30_000 }, | |
| 128 | + it.effect("executes code in a real Deno subprocess via generateText", () => | |
| 129 | + Effect.gen(function* () { | |
| 130 | + const runtimeAdapter = makeDenoSubprocessRuntimeAdapter({ | |
| 131 | + defaultTimeoutMs: 10_000, | |
| 132 | + }); | |
| 133 | + | |
| 134 | + const runClient = createRuntimeRunClient({ | |
| 135 | + runtimeAdapter, | |
| 136 | + }); | |
| 137 | + | |
| 138 | + const tools = toAiSdkTools({ | |
| 139 | + runClient, | |
| 140 | + makeTool: (def) => tool(def), | |
| 141 | + }); | |
| 142 | + | |
| 143 | + const result = yield* Effect.tryPromise(() => | |
| 144 | + generateText({ | |
| 145 | + model: gateway("openai/gpt-4o-mini"), | |
| 146 | + tools, | |
| 147 | + stopWhen: stepCountIs(3), | |
| 148 | + system: [ | |
| 149 | + "You have an execute tool that runs JavaScript code in a sandboxed Deno runtime.", | |
| 150 | + "Always use it when asked to run code.", | |
| 151 | + "The code must use `return` to produce a result value.", | |
| 152 | + ].join(" "), | |
| 153 | + prompt: "Use the execute tool to compute 2 + 3. The code should be: return 2 + 3;", | |
| 154 | + }), | |
| 155 | + ); | |
| 156 | + | |
| 157 | + const toolCallSteps = result.steps.filter( | |
| 158 | + (step) => step.toolCalls.length > 0, | |
| 159 | + ); | |
| 160 | + expect(toolCallSteps.length).toBeGreaterThanOrEqual(1); | |
| 161 | + | |
| 162 | + const firstToolCall = toolCallSteps[0]!.toolCalls[0]!; | |
| 163 | + expect(firstToolCall.toolName).toBe("execute"); | |
| 164 | + | |
| 165 | + const toolResultSteps = result.steps.filter( | |
| 166 | + (step) => step.toolResults.length > 0, | |
| 167 | + ); | |
| 168 | + expect(toolResultSteps.length).toBeGreaterThanOrEqual(1); | |
| 169 | + | |
| 170 | + const toolResult = toolResultSteps[0]!.toolResults[0]!; | |
| 171 | + expect(toolResult.toolName).toBe("execute"); | |
| 172 | + expect(toolResult.output).toMatchObject({ | |
| 173 | + status: "completed", | |
| 174 | + result: 5, | |
| 175 | + }); | |
| 176 | + | |
| 177 | + expect(result.text).toBeTypeOf("string"); | |
| 178 | + }), | |
| 454 | 179 | ); |
| 455 | 180 | }); |
| @@ -17,6 +17,7 @@ · 16 unmodified lines | ||
| 17 | 17 | "effect": "catalog:" |
| 18 | 18 | }, |
| 19 | 19 | "devDependencies": { |
| 20 | + "@effect/platform": "catalog:", | |
| 20 | 21 | "@effect/vitest": "catalog:", |
| 21 | 22 | "@executor-v2/management-api": "workspace:*", |
| 22 | 23 | "vitest": "catalog:" |
| @@ -1,72 +1,34 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | −import { | |
| 3 | − CanonicalToolDescriptorSchema, | |
| 4 | − type CanonicalToolDescriptor, | |
| 5 | −} from "@executor-v2/schema"; | |
| 6 | 2 | import * as Effect from "effect/Effect"; |
| 7 | 3 | import * as Either from "effect/Either"; |
| 8 | −import * as Schema from "effect/Schema"; | |
| 9 | 4 | |
| 10 | 5 | import { |
| 11 | 6 | DenoSubprocessRunnerError, |
| 12 | 7 | executeJavaScriptInDenoSubprocess, |
| 13 | 8 | } from "@executor-v2/runtime-deno-subprocess"; |
| 14 | −import { | |
| 15 | − makeToolProviderRegistry, | |
| 16 | − ToolProviderRegistryService, | |
| 17 | − type ToolProvider, | |
| 18 | −} from "./tool-providers"; | |
| 19 | − | |
| 20 | −const decodeCanonicalToolDescriptor = Schema.decodeUnknownSync( | |
| 21 | − CanonicalToolDescriptorSchema, | |
| 22 | −); | |
| 23 | − | |
| 24 | −const sumToolDescriptor: CanonicalToolDescriptor = decodeCanonicalToolDescriptor({ | |
| 25 | − providerKind: "in_memory", | |
| 26 | − sourceId: null, | |
| 27 | − workspaceId: null, | |
| 28 | − toolId: "sum", | |
| 29 | − name: "sum", | |
| 30 | − description: null, | |
| 31 | − invocationMode: "in_memory", | |
| 32 | − availability: "local_only", | |
| 33 | − providerPayload: null, | |
| 34 | −}); | |
| 35 | 9 | |
| 36 | 10 | describe("executeJavaScriptInDenoSubprocess", () => { |
| 37 | 11 | it.effect("runs code in Deno subprocess and proxies tool calls", () => |
| 38 | 12 | Effect.gen(function* () { |
| 39 | − const sumProvider: ToolProvider = { | |
| 40 | − kind: "in_memory", | |
| 41 | − invoke: (input) => | |
| 42 | − Effect.gen(function* () { | |
| 43 | − if (input.tool.toolId !== "sum") { | |
| 44 | − return { | |
| 45 | − output: `unknown tool: ${input.tool.toolId}`, | |
| 46 | − isError: true, | |
| 47 | − } as const; | |
| 48 | − } | |
| 49 | − | |
| 50 | − const args = input.args as { a: number; b: number }; | |
| 51 | − return { | |
| 52 | − output: args.a + args.b, | |
| 53 | − isError: false, | |
| 54 | − } as const; | |
| 55 | − }), | |
| 56 | − }; | |
| 57 | − | |
| 58 | − const registry = makeToolProviderRegistry([sumProvider]); | |
| 59 | − | |
| 60 | 13 | const result = yield* executeJavaScriptInDenoSubprocess({ |
| 14 | + runId: "run_deno_1", | |
| 61 | 15 | code: "console.log('hello'); return await tools.sum({ a: 2, b: 3 });", |
| 62 | − tools: [ | |
| 63 | − { | |
| 64 | − descriptor: sumToolDescriptor, | |
| 65 | − source: null, | |
| 66 | − }, | |
| 67 | − ], | |
| 16 | + toolCallService: { | |
| 17 | + callTool: (input) => | |
| 18 | + input.toolPath === "sum" | |
| 19 | + ? Effect.succeed( | |
| 20 | + ((input.input?.a as number) ?? 0) + | |
| 21 | + ((input.input?.b as number) ?? 0), | |
| 22 | + ) | |
| 23 | + : new DenoSubprocessRunnerError({ | |
| 24 | + operation: "call_tool", | |
| 25 | + runtimeKind: "deno-subprocess", | |
| 26 | + message: `Unknown tool path: ${input.toolPath}`, | |
| 27 | + details: null, | |
| 28 | + }), | |
| 29 | + }, | |
| 68 | 30 | timeoutMs: 10_000, |
| 69 | − }).pipe(Effect.provideService(ToolProviderRegistryService, registry)); | |
| 31 | + }); | |
| 70 | 32 | |
| 71 | 33 | expect(result).toBe(5); |
| 72 | 34 | }), |
| @@ -74,15 +36,13 @@ describe("executeJavaScriptInDenoSubprocess", () => { · 1 unmodified line | ||
| 74 | 36 | |
| 75 | 37 | it.effect("returns a typed error when Deno executable is missing", () => |
| 76 | 38 | Effect.gen(function* () { |
| 77 | − const registry = makeToolProviderRegistry([]); | |
| 78 | − | |
| 79 | 39 | const result = yield* Effect.either( |
| 80 | 40 | executeJavaScriptInDenoSubprocess({ |
| 41 | + runId: "run_deno_2", | |
| 81 | 42 | code: "return 1;", |
| 82 | − tools: [], | |
| 83 | 43 | denoExecutable: "/definitely-missing-deno-binary", |
| 84 | 44 | timeoutMs: 1_000, |
| 85 | − }).pipe(Effect.provideService(ToolProviderRegistryService, registry)), | |
| 45 | + }), | |
| 86 | 46 | ); |
| 87 | 47 | |
| 88 | 48 | expect(Either.isLeft(result)).toBe(true); |
| @@ -10,6 +10,7 @@ export { · 9 unmodified lines | ||
| 10 | 10 | type RuntimeExecuteError, |
| 11 | 11 | type RuntimeExecuteInput, |
| 12 | 12 | type RuntimeRunnableTool, |
| 13 | + type RuntimeToolCallService, | |
| 13 | 14 | } from "./runtime-adapters"; |
| 14 | 15 | |
| 15 | 16 | export { |
| @@ -46,6 +47,7 @@ export { · 30 unmodified lines | ||
| 46 | 47 | export { |
| 47 | 48 | RuntimeExecutionPortError, |
| 48 | 49 | type ExecuteRuntimeRun, |
| 50 | + type ExecuteRuntimeRunInput, | |
| 49 | 51 | } from "./runtime-execution-port"; |
| 50 | 52 | |
| 51 | 53 | export { |
| @@ -1,23 +1,25 @@ | ||
| 1 | 1 | import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; |
| 2 | 2 | |
| 3 | +import { | |
| 4 | + HttpApi, | |
| 5 | + HttpApiEndpoint, | |
| 6 | + HttpApiGroup, | |
| 7 | + HttpApiSchema, | |
| 8 | + OpenApi, | |
| 9 | +} from "@effect/platform"; | |
| 3 | 10 | import { describe, expect, it } from "@effect/vitest"; |
| 11 | +import { makeSourceManagerService } from "@executor-v2/management-api"; | |
| 4 | 12 | import { type ToolArtifactStore } from "@executor-v2/persistence-ports"; |
| 5 | 13 | import { type Source, SourceSchema, type ToolArtifact } from "@executor-v2/schema"; |
| 6 | −import { makeSourceManagerService } from "@executor-v2/management-api"; | |
| 7 | 14 | import * as Data from "effect/Data"; |
| 8 | 15 | import * as Effect from "effect/Effect"; |
| 9 | 16 | import * as Option from "effect/Option"; |
| 10 | 17 | import * as Schema from "effect/Schema"; |
| 11 | 18 | |
| 12 | 19 | import { executeJavaScriptWithTools } from "@executor-v2/runtime-local-inproc"; |
| 13 | −import { | |
| 14 | − makeOpenApiToolProvider, | |
| 15 | − openApiToolDescriptorsFromManifest, | |
| 16 | −} from "./openapi-provider"; | |
| 17 | −import { | |
| 18 | − makeToolProviderRegistry, | |
| 19 | − ToolProviderRegistryService, | |
| 20 | −} from "./tool-providers"; | |
| 20 | +import { makeOpenApiToolProvider, openApiToolDescriptorsFromManifest } from "./openapi-provider"; | |
| 21 | +import { RuntimeAdapterError } from "./runtime-adapters"; | |
| 22 | +import { makeToolProviderRegistry } from "./tool-providers"; | |
| 21 | 23 | |
| 22 | 24 | const decodeSource = Schema.decodeUnknownSync(SourceSchema); |
| 23 | 25 | |
| @@ -25,8 +27,7 @@ type TestServer = { · 1 unmodified line | ||
| 25 | 27 | baseUrl: string; |
| 26 | 28 | requests: Array<{ |
| 27 | 29 | path: string; |
| 28 | − query: string; | |
| 29 | − apiKey: string | null; | |
| 30 | + accept: string | null; | |
| 30 | 31 | }>; |
| 31 | 32 | close: () => Promise<void>; |
| 32 | 33 | }; |
| @@ -57,6 +58,22 @@ const getHeaderValue = ( · 24 unmodified lines | ||
| 57 | 58 | return null; |
| 58 | 59 | }; |
| 59 | 60 | |
| 61 | +const githubOwnerParam = HttpApiSchema.param("owner", Schema.String); | |
| 62 | +const githubRepoParam = HttpApiSchema.param("repo", Schema.String); | |
| 63 | + | |
| 64 | +class GitHubReposApi extends HttpApiGroup.make("repos").add( | |
| 65 | + HttpApiEndpoint.get("getRepo")`/repos/${githubOwnerParam}/${githubRepoParam}`.addSuccess( | |
| 66 | + Schema.Unknown, | |
| 67 | + ), | |
| 68 | +) {} | |
| 69 | + | |
| 70 | +class GitHubApi extends HttpApi.make("github").add(GitHubReposApi) {} | |
| 71 | + | |
| 72 | +const githubOpenApiSpec = OpenApi.fromApi(GitHubApi); | |
| 73 | + | |
| 74 | +const quoteJavaScriptString = (value: string): string => | |
| 75 | + `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; | |
| 76 | + | |
| 60 | 77 | const makeTestServer = Effect.acquireRelease( |
| 61 | 78 | Effect.promise<TestServer>( |
| 62 | 79 | () => |
| @@ -67,17 +84,16 @@ const makeTestServer = Effect.acquireRelease( · 4 unmodified lines | ||
| 67 | 84 | const host = getHeaderValue(req, "host") ?? "127.0.0.1"; |
| 68 | 85 | const url = new URL(req.url ?? "/", `http://${host}`); |
| 69 | 86 | |
| 70 | − if (url.pathname === "/users/u123") { | |
| 87 | + if (url.pathname === "/repos/octocat/hello-world" && req.method === "GET") { | |
| 71 | 88 | requests.push({ |
| 72 | 89 | path: url.pathname, |
| 73 | − query: url.search, | |
| 74 | − apiKey: getHeaderValue(req, "x-api-key"), | |
| 90 | + accept: getHeaderValue(req, "accept"), | |
| 75 | 91 | }); |
| 76 | 92 | |
| 77 | 93 | jsonResponse(res, 200, { |
| 78 | − id: "u123", | |
| 79 | − verbose: url.searchParams.get("verbose") === "true", | |
| 80 | − apiKey: getHeaderValue(req, "x-api-key"), | |
| 94 | + full_name: "octocat/hello-world", | |
| 95 | + stargazers_count: 42, | |
| 96 | + private: false, | |
| 81 | 97 | }); |
| 82 | 98 | return; |
| 83 | 99 | } |
| @@ -111,7 +127,7 @@ const makeTestServer = Effect.acquireRelease( · 27 unmodified lines | ||
| 111 | 127 | }), |
| 112 | 128 | }); |
| 113 | 129 | }); |
| 114 | − }) | |
| 130 | + }), | |
| 115 | 131 | ), |
| 116 | 132 | (testServer) => |
| 117 | 133 | Effect.tryPromise({ |
| @@ -124,41 +140,14 @@ const makeTestServer = Effect.acquireRelease( · 6 unmodified lines | ||
| 124 | 140 | ); |
| 125 | 141 | |
| 126 | 142 | describe("OpenAPI execution vertical slice", () => { |
| 127 | − it.scoped("extracts OpenAPI tools and executes code against provider", () => | |
| 143 | + it.scoped("loads GitHub OpenAPI tool and executes it in sandbox", () => | |
| 128 | 144 | Effect.gen(function* () { |
| 129 | 145 | const testServer = yield* makeTestServer; |
| 130 | 146 | |
| 131 | − const openApiSpec = { | |
| 132 | − openapi: "3.1.0", | |
| 133 | − paths: { | |
| 134 | − "/users/{userId}": { | |
| 135 | − get: { | |
| 136 | − operationId: "getUser", | |
| 137 | − parameters: [ | |
| 138 | − { | |
| 139 | − name: "userId", | |
| 140 | − in: "path", | |
| 141 | − required: true, | |
| 142 | − }, | |
| 143 | − { | |
| 144 | − name: "verbose", | |
| 145 | − in: "query", | |
| 146 | − }, | |
| 147 | − { | |
| 148 | − name: "x-api-key", | |
| 149 | − in: "header", | |
| 150 | − required: true, | |
| 151 | − }, | |
| 152 | − ], | |
| 153 | − }, | |
| 154 | − }, | |
| 155 | − }, | |
| 156 | − }; | |
| 157 | − | |
| 158 | 147 | const source: Source = decodeSource({ |
| 159 | 148 | id: "src_openapi", |
| 160 | 149 | workspaceId: "ws_local", |
| 161 | − name: "local-openapi", | |
| 150 | + name: "github", | |
| 162 | 151 | kind: "openapi", |
| 163 | 152 | endpoint: testServer.baseUrl, |
| 164 | 153 | status: "connected", |
| @@ -178,17 +167,14 @@ describe("OpenAPI execution vertical slice", () => { · 13 unmodified lines | ||
| 178 | 167 | ), |
| 179 | 168 | upsert: (artifact: ToolArtifact) => |
| 180 | 169 | Effect.sync(() => { |
| 181 | − artifactsByKey.set( | |
| 182 | − `${artifact.workspaceId}:${artifact.sourceId}`, | |
| 183 | − artifact, | |
| 184 | − ); | |
| 170 | + artifactsByKey.set(`${artifact.workspaceId}:${artifact.sourceId}`, artifact); | |
| 185 | 171 | }), |
| 186 | 172 | }; |
| 187 | − const sourceManager = makeSourceManagerService(artifactStore); | |
| 188 | 173 | |
| 174 | + const sourceManager = makeSourceManagerService(artifactStore); | |
| 189 | 175 | const refreshResult = yield* sourceManager.refreshOpenApiArtifact({ |
| 190 | 176 | source, |
| 191 | − openApiSpec, | |
| 177 | + openApiSpec: githubOpenApiSpec, | |
| 192 | 178 | }); |
| 193 | 179 | |
| 194 | 180 | const tools = yield* openApiToolDescriptorsFromManifest( |
| @@ -196,49 +182,69 @@ describe("OpenAPI execution vertical slice", () => { · 1 unmodified line | ||
| 196 | 182 | refreshResult.artifact.manifestJson, |
| 197 | 183 | ); |
| 198 | 184 | |
| 199 | − const getUserTool = tools.find((tool) => tool.toolId === "getUser"); | |
| 200 | − if (!getUserTool) { | |
| 201 | − throw new Error("expected getUser tool"); | |
| 202 | − } | |
| 185 | + expect(tools).toHaveLength(1); | |
| 186 | + const githubTool = tools[0]!; | |
| 203 | 187 | |
| 204 | 188 | const registry = makeToolProviderRegistry([makeOpenApiToolProvider()]); |
| 205 | 189 | |
| 206 | 190 | const executionResult = yield* executeJavaScriptWithTools({ |
| 207 | − code: ` | |
| 208 | −return await tools.getUser({ | |
| 209 | − userId: "u123", | |
| 210 | − verbose: "true", | |
| 211 | − "x-api-key": "sk_test" | |
| 212 | −}); | |
| 213 | −`, | |
| 214 | − tools: [ | |
| 215 | − { | |
| 216 | − descriptor: getUserTool, | |
| 217 | − source, | |
| 191 | + runId: "run_openapi_1", | |
| 192 | + code: `return await tools[${quoteJavaScriptString(githubTool.toolId)}]({ owner: "octocat", repo: "hello-world" });`, | |
| 193 | + toolCallService: { | |
| 194 | + callTool: (input) => { | |
| 195 | + if (input.toolPath !== githubTool.toolId) { | |
| 196 | + return new RuntimeAdapterError({ | |
| 197 | + operation: "call_tool", | |
| 198 | + runtimeKind: "local-inproc", | |
| 199 | + message: `Unknown tool path: ${input.toolPath}`, | |
| 200 | + details: null, | |
| 201 | + }); | |
| 202 | + } | |
| 203 | + | |
| 204 | + return registry | |
| 205 | + .invoke({ | |
| 206 | + source, | |
| 207 | + tool: githubTool, | |
| 208 | + args: input.input ?? {}, | |
| 209 | + }) | |
| 210 | + .pipe( | |
| 211 | + Effect.mapError( | |
| 212 | + (error) => | |
| 213 | + new RuntimeAdapterError({ | |
| 214 | + operation: "call_tool", | |
| 215 | + runtimeKind: "local-inproc", | |
| 216 | + message: error.message, | |
| 217 | + details: null, | |
| 218 | + }), | |
| 219 | + ), | |
| 220 | + Effect.flatMap((result) => | |
| 221 | + result.isError | |
| 222 | + ? Effect.fail( | |
| 223 | + new RuntimeAdapterError({ | |
| 224 | + operation: "call_tool", | |
| 225 | + runtimeKind: "local-inproc", | |
| 226 | + message: `Tool call returned error: ${input.toolPath}`, | |
| 227 | + details: null, | |
| 228 | + }), | |
| 229 | + ) | |
| 230 | + : Effect.succeed(result.output), | |
| 231 | + ), | |
| 232 | + ); | |
| 218 | 233 | }, |
| 219 | − ], | |
| 220 | − }).pipe( | |
| 221 | − Effect.provideService(ToolProviderRegistryService, registry), | |
| 222 | − ); | |
| 234 | + }, | |
| 235 | + }); | |
| 223 | 236 | |
| 224 | − const output = executionResult as { | |
| 225 | − status: number; | |
| 237 | + expect(executionResult).toMatchObject({ | |
| 238 | + status: 200, | |
| 226 | 239 | body: { |
| 227 | − id: string; | |
| 228 | − verbose: boolean; | |
| 229 | − apiKey: string | null; | |
| 230 | − }; | |
| 231 | − }; | |
| 232 | − | |
| 233 | − expect(output.status).toBe(200); | |
| 234 | − expect(output.body.id).toBe("u123"); | |
| 235 | − expect(output.body.verbose).toBe(true); | |
| 236 | − expect(output.body.apiKey).toBe("sk_test"); | |
| 240 | + full_name: "octocat/hello-world", | |
| 241 | + stargazers_count: 42, | |
| 242 | + private: false, | |
| 243 | + }, | |
| 244 | + }); | |
| 237 | 245 | |
| 238 | 246 | expect(testServer.requests).toHaveLength(1); |
| 239 | − expect(testServer.requests[0]?.path).toBe("/users/u123"); | |
| 240 | − expect(testServer.requests[0]?.query).toBe("?verbose=true"); | |
| 241 | − expect(testServer.requests[0]?.apiKey).toBe("sk_test"); | |
| 247 | + expect(testServer.requests[0]?.path).toBe("/repos/octocat/hello-world"); | |
| 242 | 248 | }), |
| 243 | 249 | ); |
| 244 | 250 | }); |
| @@ -1,19 +1,12 @@ | ||
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | import * as Either from "effect/Either"; |
| 3 | 3 | |
| 4 | −import { | |
| 5 | − ToolProviderError, | |
| 6 | − ToolProviderRegistryService, | |
| 7 | − makeToolProviderRegistry, | |
| 8 | − type CanonicalToolDescriptor, | |
| 9 | − type ToolProvider, | |
| 10 | − type ToolProviderRegistry, | |
| 11 | −} from "./tool-providers"; | |
| 12 | 4 | import type { |
| 13 | 5 | RuntimeAdapter, |
| 14 | 6 | RuntimeExecuteError, |
| 15 | − RuntimeRunnableTool, | |
| 7 | + RuntimeToolCallService, | |
| 16 | 8 | } from "./runtime-adapters"; |
| 9 | +import { RuntimeAdapterError } from "./runtime-adapters"; | |
| 17 | 10 | |
| 18 | 11 | export type RuntimeRunClientExecuteInput = { |
| 19 | 12 | code: string; |
| @@ -44,8 +37,7 @@ export type InMemorySandboxToolMap = Record<string, InMemorySandboxTool>; · 24 unmodified lines | ||
| 44 | 37 | |
| 45 | 38 | export type CreateRuntimeRunClientOptions = { |
| 46 | 39 | runtimeAdapter: RuntimeAdapter; |
| 47 | − tools?: ReadonlyArray<RuntimeRunnableTool>; | |
| 48 | − toolProviderRegistry: ToolProviderRegistry; | |
| 40 | + toolCallService?: RuntimeToolCallService; | |
| 49 | 41 | defaults?: { |
| 50 | 42 | timeoutMs?: number; |
| 51 | 43 | }; |
| @@ -64,59 +56,37 @@ export type CreateInMemoryRuntimeRunClientOptions = { · 12 unmodified lines | ||
| 64 | 56 | const formatRuntimeExecuteError = (error: RuntimeExecuteError): string => |
| 65 | 57 | error.details ? `${error.message}: ${error.details}` : error.message; |
| 66 | 58 | |
| 67 | −const toInMemoryDescriptor = ( | |
| 68 | − toolName: string, | |
| 69 | − tool: InMemorySandboxTool, | |
| 70 | −): CanonicalToolDescriptor => ({ | |
| 71 | − providerKind: "in_memory", | |
| 72 | − sourceId: null, | |
| 73 | − workspaceId: null, | |
| 74 | − toolId: toolName, | |
| 75 | − name: toolName, | |
| 76 | − description: tool.description ?? null, | |
| 77 | − invocationMode: "in_memory", | |
| 78 | − availability: "local_only", | |
| 79 | − providerPayload: null, | |
| 80 | −}); | |
| 81 | − | |
| 82 | −const makeInMemoryRuntimeTools = ( | |
| 83 | − tools: InMemorySandboxToolMap, | |
| 84 | −): ReadonlyArray<RuntimeRunnableTool> => | |
| 85 | − Object.entries(tools).map(([toolName, tool]) => ({ | |
| 86 | − descriptor: toInMemoryDescriptor(toolName, tool), | |
| 87 | − source: null, | |
| 88 | − })); | |
| 89 | − | |
| 90 | −const makeInMemoryToolProvider = ( | |
| 59 | +const inMemoryToolCallService = ( | |
| 60 | + runtimeKind: string, | |
| 91 | 61 | tools: InMemorySandboxToolMap, |
| 92 | −): ToolProvider => ({ | |
| 93 | − kind: "in_memory", | |
| 94 | − invoke: (input) => { | |
| 95 | − const implementation = tools[input.tool.toolId]; | |
| 96 | − | |
| 62 | +): RuntimeToolCallService => ({ | |
| 63 | + callTool: (input) => { | |
| 64 | + const implementation = tools[input.toolPath]; | |
| 97 | 65 | if (!implementation) { |
| 98 | − return Effect.succeed({ | |
| 99 | − output: `Unknown in-memory tool: ${input.tool.toolId}`, | |
| 100 | − isError: true, | |
| 66 | + return new RuntimeAdapterError({ | |
| 67 | + operation: "call_tool", | |
| 68 | + runtimeKind, | |
| 69 | + message: `Unknown in-memory tool: ${input.toolPath}`, | |
| 70 | + details: null, | |
| 101 | 71 | }); |
| 102 | 72 | } |
| 103 | 73 | |
| 104 | − return Effect.tryPromise({ | |
| 105 | − try: async () => { | |
| 106 | − if (!implementation.execute) { | |
| 107 | − throw new Error(`In-memory tool '${input.tool.toolId}' has no execute function`); | |
| 108 | − } | |
| 74 | + if (!implementation.execute) { | |
| 75 | + return new RuntimeAdapterError({ | |
| 76 | + operation: "call_tool", | |
| 77 | + runtimeKind, | |
| 78 | + message: `In-memory tool '${input.toolPath}' has no execute function`, | |
| 79 | + details: null, | |
| 80 | + }); | |
| 81 | + } | |
| 109 | 82 | |
| 110 | − return { | |
| 111 | − output: await implementation.execute(input.args, undefined), | |
| 112 | − isError: false, | |
| 113 | − }; | |
| 114 | − }, | |
| 83 | + return Effect.tryPromise({ | |
| 84 | + try: () => implementation.execute!(input.input ?? {}, undefined), | |
| 115 | 85 | catch: (cause) => |
| 116 | − new ToolProviderError({ | |
| 117 | − operation: "invoke", | |
| 118 | − providerKind: "in_memory", | |
| 119 | − message: `In-memory tool invocation failed: ${input.tool.toolId}`, | |
| 86 | + new RuntimeAdapterError({ | |
| 87 | + operation: "call_tool", | |
| 88 | + runtimeKind, | |
| 89 | + message: `In-memory tool invocation failed: ${input.toolPath}`, | |
| 120 | 90 | details: String(cause), |
| 121 | 91 | }), |
| 122 | 92 | }); |
| @@ -156,18 +126,12 @@ export const createRuntimeRunClient = ( · 33 unmodified lines | ||
| 156 | 126 | |
| 157 | 127 | const executionResult = await Effect.runPromise( |
| 158 | 128 | Effect.either( |
| 159 | − options.runtimeAdapter | |
| 160 | − .execute({ | |
| 161 | − code: input.code, | |
| 162 | − timeoutMs: input.timeoutMs ?? options.defaults?.timeoutMs, | |
| 163 | − tools: options.tools ?? [], | |
| 164 | − }) | |
| 165 | − .pipe( | |
| 166 | − Effect.provideService( | |
| 167 | − ToolProviderRegistryService, | |
| 168 | − options.toolProviderRegistry, | |
| 169 | − ), | |
| 170 | − ), | |
| 129 | + options.runtimeAdapter.execute({ | |
| 130 | + runId, | |
| 131 | + code: input.code, | |
| 132 | + timeoutMs: input.timeoutMs ?? options.defaults?.timeoutMs, | |
| 133 | + toolCallService: options.toolCallService, | |
| 134 | + }), | |
| 171 | 135 | ), |
| 172 | 136 | ); |
| 173 | 137 | |
| @@ -191,14 +155,9 @@ export const createRuntimeRunClient = ( · 17 unmodified lines | ||
| 191 | 155 | export const createInMemoryRuntimeRunClient = ( |
| 192 | 156 | options: CreateInMemoryRuntimeRunClientOptions, |
| 193 | 157 | ): RuntimeRunClient => { |
| 194 | − const toolProviderRegistry = makeToolProviderRegistry([ | |
| 195 | − makeInMemoryToolProvider(options.tools), | |
| 196 | − ]); | |
| 197 | − | |
| 198 | 158 | return createRuntimeRunClient({ |
| 199 | 159 | runtimeAdapter: options.runtimeAdapter, |
| 200 | − tools: makeInMemoryRuntimeTools(options.tools), | |
| 201 | − toolProviderRegistry, | |
| 160 | + toolCallService: inMemoryToolCallService(options.runtimeAdapter.kind, options.tools), | |
| 202 | 161 | defaults: options.defaults, |
| 203 | 162 | makeRunId: options.makeRunId, |
| 204 | 163 | }); |
| @@ -16,7 +16,10 @@ export const executeRun = ( · 15 unmodified lines | ||
| 16 | 16 | Effect.gen(function* () { |
| 17 | 17 | const runId = options.makeRunId?.() ?? `run_${crypto.randomUUID()}`; |
| 18 | 18 | |
| 19 | − const runtimeResult = yield* executeRuntimeRun(input).pipe(Effect.either); | |
| 19 | + const runtimeResult = yield* executeRuntimeRun({ | |
| 20 | + ...input, | |
| 21 | + runId, | |
| 22 | + }).pipe(Effect.either); | |
| 20 | 23 | if (Either.isLeft(runtimeResult)) { |
| 21 | 24 | const error = runtimeResult.left; |
| 22 | 25 | return { |
| @@ -1,67 +1,40 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | −import { | |
| 3 | − CanonicalToolDescriptorSchema, | |
| 4 | − type CanonicalToolDescriptor, | |
| 5 | −} from "@executor-v2/schema"; | |
| 6 | 2 | import * as Effect from "effect/Effect"; |
| 7 | 3 | import * as Either from "effect/Either"; |
| 8 | −import * as Schema from "effect/Schema"; | |
| 9 | 4 | |
| 5 | +import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader"; | |
| 6 | +import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc"; | |
| 10 | 7 | import { |
| 11 | 8 | makeRuntimeAdapterRegistry, |
| 12 | 9 | RuntimeAdapterError, |
| 13 | 10 | } from "./runtime-adapters"; |
| 14 | −import { makeCloudflareWorkerLoaderRuntimeAdapter } from "@executor-v2/runtime-cloudflare-worker-loader"; | |
| 15 | −import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc"; | |
| 16 | −import { | |
| 17 | − makeToolProviderRegistry, | |
| 18 | − ToolProviderRegistryService, | |
| 19 | − type ToolProvider, | |
| 20 | −} from "./tool-providers"; | |
| 21 | − | |
| 22 | −const decodeCanonicalToolDescriptor = Schema.decodeUnknownSync( | |
| 23 | − CanonicalToolDescriptorSchema, | |
| 24 | −); | |
| 25 | − | |
| 26 | −const sumToolDescriptor: CanonicalToolDescriptor = decodeCanonicalToolDescriptor({ | |
| 27 | − providerKind: "in_memory", | |
| 28 | − sourceId: null, | |
| 29 | − workspaceId: null, | |
| 30 | − toolId: "sum", | |
| 31 | − name: "sum", | |
| 32 | − description: null, | |
| 33 | − invocationMode: "in_memory", | |
| 34 | − availability: "local_only", | |
| 35 | − providerPayload: null, | |
| 36 | −}); | |
| 37 | 11 | |
| 38 | 12 | describe("runtime adapters", () => { |
| 39 | 13 | it.effect("executes with local-inproc adapter via runtime registry", () => |
| 40 | 14 | Effect.gen(function* () { |
| 41 | − const sumProvider: ToolProvider = { | |
| 42 | − kind: "in_memory", | |
| 43 | − invoke: (input) => | |
| 44 | − Effect.gen(function* () { | |
| 45 | − const args = input.args as { a: number; b: number }; | |
| 46 | − return { | |
| 47 | − output: args.a + args.b, | |
| 48 | − isError: false, | |
| 49 | − } as const; | |
| 50 | − }), | |
| 51 | − }; | |
| 52 | − | |
| 53 | − const toolRegistry = makeToolProviderRegistry([sumProvider]); | |
| 54 | 15 | const runtimeRegistry = makeRuntimeAdapterRegistry([ |
| 55 | 16 | makeLocalInProcessRuntimeAdapter(), |
| 56 | 17 | ]); |
| 57 | 18 | |
| 58 | − const result = yield* runtimeRegistry | |
| 59 | − .execute({ | |
| 60 | − runtimeKind: "local-inproc", | |
| 61 | − code: "return await tools.sum({ a: 2, b: 4 });", | |
| 62 | − tools: [{ descriptor: sumToolDescriptor, source: null }], | |
| 63 | − }) | |
| 64 | − .pipe(Effect.provideService(ToolProviderRegistryService, toolRegistry)); | |
| 19 | + const result = yield* runtimeRegistry.execute({ | |
| 20 | + runtimeKind: "local-inproc", | |
| 21 | + runId: "run_test_1", | |
| 22 | + code: "return await tools.sum({ a: 2, b: 4 });", | |
| 23 | + toolCallService: { | |
| 24 | + callTool: (input) => | |
| 25 | + input.toolPath === "sum" | |
| 26 | + ? Effect.succeed( | |
| 27 | + ((input.input?.a as number) ?? 0) + | |
| 28 | + ((input.input?.b as number) ?? 0), | |
| 29 | + ) | |
| 30 | + : new RuntimeAdapterError({ | |
| 31 | + operation: "call_tool", | |
| 32 | + runtimeKind: "local-inproc", | |
| 33 | + message: `Unknown tool path: ${input.toolPath}`, | |
| 34 | + details: null, | |
| 35 | + }), | |
| 36 | + }, | |
| 37 | + }); | |
| 65 | 38 | |
| 66 | 39 | expect(result).toBe(6); |
| 67 | 40 | }), |
| @@ -69,19 +42,16 @@ describe("runtime adapters", () => { · 1 unmodified line | ||
| 69 | 42 | |
| 70 | 43 | it.effect("returns typed not-implemented error for cloudflare adapter", () => |
| 71 | 44 | Effect.gen(function* () { |
| 72 | − const toolRegistry = makeToolProviderRegistry([]); | |
| 73 | 45 | const runtimeRegistry = makeRuntimeAdapterRegistry([ |
| 74 | 46 | makeCloudflareWorkerLoaderRuntimeAdapter(), |
| 75 | 47 | ]); |
| 76 | 48 | |
| 77 | 49 | const result = yield* Effect.either( |
| 78 | − runtimeRegistry | |
| 79 | − .execute({ | |
| 80 | − runtimeKind: "cloudflare-worker-loader", | |
| 81 | − code: "return 1;", | |
| 82 | − tools: [], | |
| 83 | − }) | |
| 84 | − .pipe(Effect.provideService(ToolProviderRegistryService, toolRegistry)), | |
| 50 | + runtimeRegistry.execute({ | |
| 51 | + runtimeKind: "cloudflare-worker-loader", | |
| 52 | + runId: "run_test_2", | |
| 53 | + code: "return 1;", | |
| 54 | + }), | |
| 85 | 55 | ); |
| 86 | 56 | |
| 87 | 57 | expect(Either.isLeft(result)).toBe(true); |
| @@ -5,10 +5,7 @@ import * as Layer from "effect/Layer"; · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | import type { Source } from "@executor-v2/schema"; |
| 7 | 7 | |
| 8 | −import type { | |
| 9 | − CanonicalToolDescriptor, | |
| 10 | − ToolProviderRegistryService, | |
| 11 | −} from "./tool-providers"; | |
| 8 | +import type { CanonicalToolDescriptor } from "./tool-providers"; | |
| 12 | 9 | |
| 13 | 10 | export type RuntimeAdapterKind = string; |
| 14 | 11 | |
| @@ -17,9 +14,19 @@ export type RuntimeRunnableTool = { · 2 unmodified lines | ||
| 17 | 14 | source: Source | null; |
| 18 | 15 | }; |
| 19 | 16 | |
| 17 | +export type RuntimeToolCallService = { | |
| 18 | + callTool: (input: { | |
| 19 | + runId: string; | |
| 20 | + callId: string; | |
| 21 | + toolPath: string; | |
| 22 | + input?: Record<string, unknown>; | |
| 23 | + }) => Effect.Effect<unknown, RuntimeAdapterError>; | |
| 24 | +}; | |
| 25 | + | |
| 20 | 26 | export type RuntimeExecuteInput = { |
| 27 | + runId: string; | |
| 21 | 28 | code: string; |
| 22 | − tools: ReadonlyArray<RuntimeRunnableTool>; | |
| 29 | + toolCallService?: RuntimeToolCallService; | |
| 23 | 30 | timeoutMs?: number; |
| 24 | 31 | }; |
| 25 | 32 | |
| @@ -37,7 +44,7 @@ export interface RuntimeAdapter { · 11 unmodified lines | ||
| 37 | 44 | readonly isAvailable: () => Effect.Effect<boolean>; |
| 38 | 45 | readonly execute: ( |
| 39 | 46 | input: RuntimeExecuteInput, |
| 40 | − ) => Effect.Effect<unknown, RuntimeExecuteError, ToolProviderRegistryService>; | |
| 47 | + ) => Effect.Effect<unknown, RuntimeExecuteError>; | |
| 41 | 48 | } |
| 42 | 49 | |
| 43 | 50 | export class RuntimeAdapterRegistryError extends Data.TaggedError( |
| @@ -63,7 +70,7 @@ export interface RuntimeAdapterRegistry { · 19 unmodified lines | ||
| 63 | 70 | |
| 64 | 71 | readonly execute: ( |
| 65 | 72 | input: RuntimeExecuteInput & { runtimeKind: RuntimeAdapterKind }, |
| 66 | − ) => Effect.Effect<unknown, RuntimeExecuteError | RuntimeAdapterRegistryError, ToolProviderRegistryService>; | |
| 73 | + ) => Effect.Effect<unknown, RuntimeExecuteError | RuntimeAdapterRegistryError>; | |
| 67 | 74 | } |
| 68 | 75 | |
| 69 | 76 | export class RuntimeAdapterRegistryService extends Context.Tag( |
| @@ -124,11 +131,7 @@ export const makeRuntimeAdapterRegistry = ( · 54 unmodified lines | ||
| 124 | 131 | |
| 125 | 132 | const execute = ( |
| 126 | 133 | input: RuntimeExecuteInput & { runtimeKind: RuntimeAdapterKind }, |
| 127 | − ): Effect.Effect< | |
| 128 | − unknown, | |
| 129 | − RuntimeExecuteError | RuntimeAdapterRegistryError, | |
| 130 | − ToolProviderRegistryService | |
| 131 | − > => | |
| 134 | + ): Effect.Effect<unknown, RuntimeExecuteError | RuntimeAdapterRegistryError> => | |
| 132 | 135 | Effect.gen(function* () { |
| 133 | 136 | const adapter = yield* get(input.runtimeKind); |
| 134 | 137 | return yield* adapter.execute(input); |
| @@ -10,6 +10,10 @@ export class RuntimeExecutionPortError extends Data.TaggedError( · 9 unmodified lines | ||
| 10 | 10 | details: string | null; |
| 11 | 11 | }> {} |
| 12 | 12 | |
| 13 | +export type ExecuteRuntimeRunInput = ExecuteRunInput & { | |
| 14 | + runId: string; | |
| 15 | +}; | |
| 16 | + | |
| 13 | 17 | export type ExecuteRuntimeRun = ( |
| 14 | − input: ExecuteRunInput, | |
| 18 | + input: ExecuteRuntimeRunInput, | |
| 15 | 19 | ) => Effect.Effect<unknown, RuntimeExecutionPortError>; |
| @@ -17,7 +17,7 @@ const toErrorMessage = (error) => { · 16 unmodified lines | ||
| 17 | 17 | return String(error); |
| 18 | 18 | }; |
| 19 | 19 | |
| 20 | −const createToolCaller = (toolId) => (args) => | |
| 20 | +const createToolCaller = (toolPath) => (args) => | |
| 21 | 21 | new Promise((resolve, reject) => { |
| 22 | 22 | const requestId = crypto.randomUUID(); |
| 23 | 23 | pendingToolCalls.set(requestId, { resolve, reject }); |
| @@ -25,16 +25,33 @@ const createToolCaller = (toolId) => (args) => · 1 unmodified line | ||
| 25 | 25 | writeMessage({ |
| 26 | 26 | type: "tool_call", |
| 27 | 27 | requestId, |
| 28 | − toolId, | |
| 28 | + toolPath, | |
| 29 | 29 | args: args === undefined ? {} : args, |
| 30 | 30 | }); |
| 31 | 31 | }); |
| 32 | 32 | |
| 33 | −const runUserCode = async (code, toolIds) => { | |
| 34 | − const tools = Object.create(null); | |
| 35 | − for (const toolId of toolIds) { | |
| 36 | − tools[toolId] = createToolCaller(toolId); | |
| 37 | − } | |
| 33 | +const createToolsProxy = (path = []) => { | |
| 34 | + const callable = () => undefined; | |
| 35 | + | |
| 36 | + return new Proxy(callable, { | |
| 37 | + get(_target, prop) { | |
| 38 | + if (prop === "then") return undefined; | |
| 39 | + if (typeof prop !== "string") return undefined; | |
| 40 | + return createToolsProxy([...path, prop]); | |
| 41 | + }, | |
| 42 | + apply(_target, _thisArg, args) { | |
| 43 | + const toolPath = path.join("."); | |
| 44 | + if (!toolPath) { | |
| 45 | + throw new Error("Tool path missing in invocation"); | |
| 46 | + } | |
| 47 | + | |
| 48 | + return createToolCaller(toolPath)(args.length > 0 ? args[0] : undefined); | |
| 49 | + }, | |
| 50 | + }); | |
| 51 | +}; | |
| 52 | + | |
| 53 | +const runUserCode = async (code) => { | |
| 54 | + const tools = createToolsProxy(); | |
| 38 | 55 | |
| 39 | 56 | const execute = new Function( |
| 40 | 57 | "tools", |
| @@ -55,7 +72,7 @@ const handleStart = (message) => { · 14 unmodified lines | ||
| 55 | 72 | |
| 56 | 73 | started = true; |
| 57 | 74 | |
| 58 | − runUserCode(message.code, message.toolIds) | |
| 75 | + runUserCode(message.code) | |
| 59 | 76 | .then((result) => { |
| 60 | 77 | writeMessage({ |
| 61 | 78 | type: "completed", |
| @@ -8,8 +8,7 @@ import * as Schema from "effect/Schema"; · 7 unmodified lines | ||
| 8 | 8 | import { |
| 9 | 9 | RuntimeAdapterError, |
| 10 | 10 | type RuntimeAdapter, |
| 11 | − type RuntimeRunnableTool, | |
| 12 | − ToolProviderRegistryService, | |
| 11 | + type RuntimeToolCallService, | |
| 13 | 12 | } from "@executor-v2/engine"; |
| 14 | 13 | import { spawnDenoWorkerProcess } from "./deno-worker-process"; |
| 15 | 14 | |
| @@ -18,11 +17,10 @@ const runtimeKind = "deno-subprocess"; · 2 unmodified lines | ||
| 18 | 17 | export const DenoSubprocessRunnerError = RuntimeAdapterError; |
| 19 | 18 | export type DenoSubprocessRunnerError = RuntimeAdapterError; |
| 20 | 19 | |
| 21 | −export type DenoRunnableTool = RuntimeRunnableTool; | |
| 22 | − | |
| 23 | 20 | export type ExecuteJavaScriptInDenoInput = { |
| 21 | + runId: string; | |
| 24 | 22 | code: string; |
| 25 | − tools: ReadonlyArray<RuntimeRunnableTool>; | |
| 23 | + toolCallService?: RuntimeToolCallService; | |
| 26 | 24 | timeoutMs?: number; |
| 27 | 25 | denoExecutable?: string; |
| 28 | 26 | }; |
| @@ -37,7 +35,6 @@ const IPC_PREFIX = "@@engine-ipc@@"; · 8 unmodified lines | ||
| 37 | 35 | const HostStartMessageSchema = Schema.Struct({ |
| 38 | 36 | type: Schema.Literal("start"), |
| 39 | 37 | code: Schema.String, |
| 40 | − toolIds: Schema.Array(Schema.String), | |
| 41 | 38 | }); |
| 42 | 39 | |
| 43 | 40 | const HostToolResultMessageSchema = Schema.Struct({ |
| @@ -56,7 +53,7 @@ const HostToWorkerMessageSchema = Schema.Union( · 12 unmodified lines | ||
| 56 | 53 | const WorkerToolCallMessageSchema = Schema.Struct({ |
| 57 | 54 | type: Schema.Literal("tool_call"), |
| 58 | 55 | requestId: Schema.String, |
| 59 | − toolId: Schema.String, | |
| 56 | + toolPath: Schema.String, | |
| 60 | 57 | args: Schema.Unknown, |
| 61 | 58 | }); |
| 62 | 59 | |
| @@ -85,27 +82,11 @@ const decodeWorkerMessageLine = Schema.decodeUnknownSync( · 22 unmodified lines | ||
| 85 | 82 | ); |
| 86 | 83 | const encodeHostMessage = Schema.encodeSync(Schema.parseJson(HostToWorkerMessageSchema)); |
| 87 | 84 | |
| 88 | −const duplicateToolIdError = (toolId: string): DenoSubprocessRunnerError => | |
| 89 | − new DenoSubprocessRunnerError({ | |
| 90 | − operation: "build_tools", | |
| 91 | − runtimeKind, | |
| 92 | − message: `Duplicate tool id in run context: ${toolId}`, | |
| 93 | − details: null, | |
| 94 | − }); | |
| 95 | − | |
| 96 | −const missingToolBindingError = (toolId: string): DenoSubprocessRunnerError => | |
| 85 | +const missingToolCallServiceError = (toolPath: string): DenoSubprocessRunnerError => | |
| 97 | 86 | new DenoSubprocessRunnerError({ |
| 98 | − operation: "invoke_tool", | |
| 87 | + operation: "call_tool", | |
| 99 | 88 | runtimeKind, |
| 100 | − message: `Worker requested unknown tool id: ${toolId}`, | |
| 101 | − details: null, | |
| 102 | − }); | |
| 103 | − | |
| 104 | −const toolCallFailedError = (toolId: string): DenoSubprocessRunnerError => | |
| 105 | − new DenoSubprocessRunnerError({ | |
| 106 | − operation: "invoke_tool", | |
| 107 | − runtimeKind, | |
| 108 | − message: `Tool call returned error: ${toolId}`, | |
| 89 | + message: `No tool call service configured for path: ${toolPath}`, | |
| 109 | 90 | details: null, |
| 110 | 91 | }); |
| 111 | 92 | |
| @@ -132,23 +113,6 @@ const workerProcessError = ( · 20 unmodified lines | ||
| 132 | 113 | details, |
| 133 | 114 | }); |
| 134 | 115 | |
| 135 | −const buildToolBindings = ( | |
| 136 | − tools: ReadonlyArray<DenoRunnableTool>, | |
| 137 | −): Effect.Effect<Map<string, DenoRunnableTool>, DenoSubprocessRunnerError> => | |
| 138 | − Effect.gen(function* () { | |
| 139 | − const byToolId = new Map<string, DenoRunnableTool>(); | |
| 140 | − | |
| 141 | − for (const tool of tools) { | |
| 142 | − const toolId = tool.descriptor.toolId; | |
| 143 | − if (byToolId.has(toolId)) { | |
| 144 | − return yield* duplicateToolIdError(toolId); | |
| 145 | − } | |
| 146 | − byToolId.set(toolId, tool); | |
| 147 | − } | |
| 148 | − | |
| 149 | − return byToolId; | |
| 150 | − }); | |
| 151 | − | |
| 152 | 116 | const defaultDenoExecutable = (): string => { |
| 153 | 117 | const configured = process.env.DENO_BIN?.trim(); |
| 154 | 118 | if (configured) { |
| @@ -186,51 +150,66 @@ const writeMessage = ( · 31 unmodified lines | ||
| 186 | 150 | ), |
| 187 | 151 | }); |
| 188 | 152 | |
| 153 | +const normalizeToolInput = (args: unknown): Record<string, unknown> | undefined => { | |
| 154 | + if (!args || typeof args !== "object" || Array.isArray(args)) { | |
| 155 | + return undefined; | |
| 156 | + } | |
| 157 | + | |
| 158 | + return args as Record<string, unknown>; | |
| 159 | +}; | |
| 160 | + | |
| 161 | +const callTool = ( | |
| 162 | + runId: string, | |
| 163 | + message: WorkerToolCallMessage, | |
| 164 | + toolCallService: RuntimeToolCallService | undefined, | |
| 165 | +): Effect.Effect<unknown, DenoSubprocessRunnerError> => | |
| 166 | + Effect.gen(function* () { | |
| 167 | + if (!toolCallService) { | |
| 168 | + return yield* missingToolCallServiceError(message.toolPath); | |
| 169 | + } | |
| 170 | + | |
| 171 | + return yield* toolCallService | |
| 172 | + .callTool({ | |
| 173 | + runId, | |
| 174 | + callId: message.requestId, | |
| 175 | + toolPath: message.toolPath, | |
| 176 | + input: normalizeToolInput(message.args), | |
| 177 | + }) | |
| 178 | + .pipe( | |
| 179 | + Effect.mapError((cause) => | |
| 180 | + workerProcessError( | |
| 181 | + "call_tool", | |
| 182 | + `Tool call failed: ${message.toolPath}`, | |
| 183 | + cause.details ?? cause.message, | |
| 184 | + ), | |
| 185 | + ), | |
| 186 | + ); | |
| 187 | + }); | |
| 188 | + | |
| 189 | 189 | const handleToolCall = ( |
| 190 | + runId: string, | |
| 190 | 191 | message: WorkerToolCallMessage, |
| 191 | − toolBindings: ReadonlyMap<string, DenoRunnableTool>, | |
| 192 | + toolCallService: RuntimeToolCallService | undefined, | |
| 192 | 193 | runPromise: <A, E>(effect: Effect.Effect<A, E, never>) => Promise<A>, |
| 193 | − invokeTool: (input: { | |
| 194 | − source: RuntimeRunnableTool["source"]; | |
| 195 | − tool: RuntimeRunnableTool["descriptor"]; | |
| 196 | − args: unknown; | |
| 197 | − }) => Effect.Effect<{ output: unknown; isError: boolean }, unknown>, | |
| 198 | 194 | stdin: NodeJS.WritableStream, |
| 199 | 195 | ): Effect.Effect<void, DenoSubprocessRunnerError> => |
| 200 | 196 | Effect.gen(function* () { |
| 201 | − const binding = toolBindings.get(message.toolId); | |
| 202 | − if (!binding) { | |
| 203 | − return yield* writeMessage(stdin, { | |
| 204 | − type: "tool_result", | |
| 205 | − requestId: message.requestId, | |
| 206 | − ok: false, | |
| 207 | − error: missingToolBindingError(message.toolId).message, | |
| 208 | − }); | |
| 209 | − } | |
| 210 | − | |
| 211 | 197 | const invokeResult = yield* Effect.tryPromise({ |
| 212 | − try: () => | |
| 213 | − runPromise( | |
| 214 | − invokeTool({ | |
| 215 | − source: binding.source, | |
| 216 | − tool: binding.descriptor, | |
| 217 | − args: message.args, | |
| 218 | − }), | |
| 219 | − ), | |
| 198 | + try: () => runPromise(Effect.either(callTool(runId, message, toolCallService))), | |
| 220 | 199 | catch: (cause) => |
| 221 | 200 | workerProcessError( |
| 222 | − "invoke_tool", | |
| 201 | + "call_tool", | |
| 223 | 202 | "Tool invocation threw while handling worker tool_call", |
| 224 | 203 | String(cause), |
| 225 | 204 | ), |
| 226 | 205 | }); |
| 227 | 206 | |
| 228 | − if (invokeResult.isError) { | |
| 207 | + if (invokeResult._tag === "Left") { | |
| 229 | 208 | yield* writeMessage(stdin, { |
| 230 | 209 | type: "tool_result", |
| 231 | 210 | requestId: message.requestId, |
| 232 | 211 | ok: false, |
| 233 | − error: toolCallFailedError(message.toolId).message, | |
| 212 | + error: invokeResult.left.message, | |
| 234 | 213 | }); |
| 235 | 214 | return; |
| 236 | 215 | } |
| @@ -239,7 +218,7 @@ const handleToolCall = ( · 2 unmodified lines | ||
| 239 | 218 | type: "tool_result", |
| 240 | 219 | requestId: message.requestId, |
| 241 | 220 | ok: true, |
| 242 | − value: invokeResult.output, | |
| 221 | + value: invokeResult.right, | |
| 243 | 222 | }); |
| 244 | 223 | }); |
| 245 | 224 | |
| @@ -249,37 +228,43 @@ export const isDenoSubprocessRuntimeAvailable = ( · 3 unmodified lines | ||
| 249 | 228 | |
| 250 | 229 | export const executeJavaScriptInDenoSubprocess = ( |
| 251 | 230 | input: ExecuteJavaScriptInDenoInput, |
| 252 | −): Effect.Effect<unknown, DenoSubprocessRunnerError, ToolProviderRegistryService> => | |
| 231 | +): Effect.Effect<unknown, DenoSubprocessRunnerError> => | |
| 253 | 232 | Effect.gen(function* () { |
| 254 | − const registry = yield* ToolProviderRegistryService; | |
| 255 | 233 | const runtime = yield* Effect.runtime<never>(); |
| 256 | 234 | const runPromise = Runtime.runPromise(runtime); |
| 257 | − const toolBindings = yield* buildToolBindings(input.tools); | |
| 258 | − const toolIds = Array.from(toolBindings.keys()).sort(); | |
| 259 | 235 | const denoExecutable = input.denoExecutable ?? defaultDenoExecutable(); |
| 260 | 236 | const timeoutMs = Math.max(100, input.timeoutMs ?? 30_000); |
| 237 | + | |
| 261 | 238 | return yield* Effect.tryPromise({ |
| 262 | 239 | try: () => |
| 263 | 240 | new Promise<unknown>((resolve, reject) => { |
| 264 | 241 | let settled = false; |
| 265 | 242 | let stderrBuffer = ""; |
| 266 | 243 | let worker: ReturnType<typeof spawnDenoWorkerProcess> | null = null; |
| 267 | − const finish = (result: { ok: true; value: unknown } | { ok: false; error: Error }) => { | |
| 244 | + | |
| 245 | + const finish = ( | |
| 246 | + result: { ok: true; value: unknown } | { ok: false; error: Error }, | |
| 247 | + ) => { | |
| 268 | 248 | if (settled) { |
| 269 | 249 | return; |
| 270 | 250 | } |
| 251 | + | |
| 271 | 252 | settled = true; |
| 272 | 253 | clearTimeout(timeout); |
| 273 | 254 | worker?.dispose(); |
| 255 | + | |
| 274 | 256 | if (result.ok) { |
| 275 | 257 | resolve(result.value); |
| 276 | − } else { | |
| 277 | − reject(result.error); | |
| 258 | + return; | |
| 278 | 259 | } |
| 260 | + | |
| 261 | + reject(result.error); | |
| 279 | 262 | }; |
| 263 | + | |
| 280 | 264 | const fail = (error: DenoSubprocessRunnerError) => { |
| 281 | 265 | finish({ ok: false, error }); |
| 282 | 266 | }; |
| 267 | + | |
| 283 | 268 | const timeout = setTimeout(() => { |
| 284 | 269 | fail( |
| 285 | 270 | workerProcessError( |
| @@ -289,6 +274,7 @@ export const executeJavaScriptInDenoSubprocess = ( · 3 unmodified lines | ||
| 289 | 274 | ), |
| 290 | 275 | ); |
| 291 | 276 | }, timeoutMs); |
| 277 | + | |
| 292 | 278 | const handleStdoutLine = (rawLine: string) => { |
| 293 | 279 | const line = rawLine.trim(); |
| 294 | 280 | if (line.length === 0 || !line.startsWith(IPC_PREFIX)) { |
| @@ -303,11 +289,12 @@ export const executeJavaScriptInDenoSubprocess = ( · 8 unmodified lines | ||
| 303 | 289 | fail(parseWorkerMessageError(payload, cause)); |
| 304 | 290 | return; |
| 305 | 291 | } |
| 292 | + | |
| 306 | 293 | if (message.type === "tool_call") { |
| 307 | 294 | if (!worker) { |
| 308 | 295 | fail( |
| 309 | 296 | workerProcessError( |
| 310 | − "invoke_tool", | |
| 297 | + "call_tool", | |
| 311 | 298 | "Deno subprocess unavailable while handling worker tool_call", |
| 312 | 299 | null, |
| 313 | 300 | ), |
| @@ -317,10 +304,10 @@ export const executeJavaScriptInDenoSubprocess = ( · 3 unmodified lines | ||
| 317 | 304 | |
| 318 | 305 | runPromise( |
| 319 | 306 | handleToolCall( |
| 307 | + input.runId, | |
| 320 | 308 | message, |
| 321 | − toolBindings, | |
| 309 | + input.toolCallService, | |
| 322 | 310 | runPromise, |
| 323 | − (invokeInput) => registry.invoke(invokeInput), | |
| 324 | 311 | worker.stdin, |
| 325 | 312 | ), |
| 326 | 313 | ).catch((cause) => { |
| @@ -336,6 +323,7 @@ export const executeJavaScriptInDenoSubprocess = ( · 9 unmodified lines | ||
| 336 | 323 | }); |
| 337 | 324 | return; |
| 338 | 325 | } |
| 326 | + | |
| 339 | 327 | if (message.type === "completed") { |
| 340 | 328 | finish({ ok: true, value: message.result }); |
| 341 | 329 | return; |
| @@ -400,7 +388,6 @@ export const executeJavaScriptInDenoSubprocess = ( · 58 unmodified lines | ||
| 400 | 388 | writeMessage(worker.stdin, { |
| 401 | 389 | type: "start", |
| 402 | 390 | code: input.code, |
| 403 | − toolIds, | |
| 404 | 391 | }), |
| 405 | 392 | ).catch((cause) => { |
| 406 | 393 | fail( |
| @@ -432,11 +419,10 @@ export const makeDenoSubprocessRuntimeAdapter = ( · 25 unmodified lines | ||
| 432 | 419 | isAvailable: () => Effect.succeed(true), |
| 433 | 420 | execute: (input) => |
| 434 | 421 | executeJavaScriptInDenoSubprocess({ |
| 422 | + runId: input.runId, | |
| 435 | 423 | code: input.code, |
| 436 | − tools: input.tools, | |
| 424 | + toolCallService: input.toolCallService, | |
| 437 | 425 | timeoutMs: input.timeoutMs ?? options.defaultTimeoutMs, |
| 438 | 426 | denoExecutable: options.denoExecutable, |
| 439 | 427 | }), |
| 440 | 428 | }); |
| 441 | − | |
| 442 | −export type { RuntimeRunnableTool }; | |
| @@ -4,15 +4,15 @@ import * as Runtime from "effect/Runtime"; · 3 unmodified lines | ||
| 4 | 4 | import { |
| 5 | 5 | RuntimeAdapterError, |
| 6 | 6 | type RuntimeAdapter, |
| 7 | − type RuntimeRunnableTool, | |
| 8 | − ToolProviderRegistryService, | |
| 7 | + type RuntimeToolCallService, | |
| 9 | 8 | } from "@executor-v2/engine"; |
| 10 | 9 | |
| 11 | 10 | const runtimeKind = "local-inproc"; |
| 12 | 11 | |
| 13 | 12 | export type ExecuteJavaScriptInput = { |
| 13 | + runId: string; | |
| 14 | 14 | code: string; |
| 15 | − tools: ReadonlyArray<RuntimeRunnableTool>; | |
| 15 | + toolCallService?: RuntimeToolCallService; | |
| 16 | 16 | }; |
| 17 | 17 | |
| 18 | 18 | const runtimeError = ( |
| @@ -27,17 +27,12 @@ const runtimeError = ( · 8 unmodified lines | ||
| 27 | 27 | details, |
| 28 | 28 | }); |
| 29 | 29 | |
| 30 | −const toRuntimeAdapterError = (error: { | |
| 31 | − operation: string; | |
| 32 | − message: string; | |
| 33 | − details?: string | null; | |
| 34 | −}): RuntimeAdapterError => runtimeError(error.operation, error.message, error.details ?? null); | |
| 35 | − | |
| 36 | −const duplicateToolIdError = (toolId: string): RuntimeAdapterError => | |
| 37 | − runtimeError("build_tools", `Duplicate tool id in run context: ${toolId}`, null); | |
| 38 | − | |
| 39 | −const toolCallFailedError = (toolId: string): RuntimeAdapterError => | |
| 40 | − runtimeError("invoke_tool", `Tool call returned error: ${toolId}`, null); | |
| 30 | +const missingToolCallServiceError = (toolPath: string): RuntimeAdapterError => | |
| 31 | + runtimeError( | |
| 32 | + "call_tool", | |
| 33 | + `No tool call service configured for tool path: ${toolPath}`, | |
| 34 | + null, | |
| 35 | + ); | |
| 41 | 36 | |
| 42 | 37 | const toExecutionError = (cause: unknown): RuntimeAdapterError => |
| 43 | 38 | cause instanceof RuntimeAdapterError |
| @@ -48,72 +43,94 @@ const toExecutionError = (cause: unknown): RuntimeAdapterError => · 4 unmodified lines | ||
| 48 | 43 | cause instanceof Error ? cause.stack ?? cause.message : String(cause), |
| 49 | 44 | ); |
| 50 | 45 | |
| 51 | −const buildToolBindings = ( | |
| 52 | − tools: ReadonlyArray<RuntimeRunnableTool>, | |
| 53 | −): Effect.Effect<Map<string, RuntimeRunnableTool>, RuntimeAdapterError> => | |
| 46 | +const normalizeToolInput = (args: unknown): Record<string, unknown> | undefined => { | |
| 47 | + if (!args || typeof args !== "object" || Array.isArray(args)) { | |
| 48 | + return undefined; | |
| 49 | + } | |
| 50 | + | |
| 51 | + return args as Record<string, unknown>; | |
| 52 | +}; | |
| 53 | + | |
| 54 | +const invokeTool = ( | |
| 55 | + runId: string, | |
| 56 | + toolPath: string, | |
| 57 | + args: unknown, | |
| 58 | + toolCallService: RuntimeToolCallService | undefined, | |
| 59 | +): Effect.Effect<unknown, RuntimeAdapterError> => | |
| 54 | 60 | Effect.gen(function* () { |
| 55 | − const byToolId = new Map<string, RuntimeRunnableTool>(); | |
| 61 | + if (!toolCallService) { | |
| 62 | + return yield* missingToolCallServiceError(toolPath); | |
| 63 | + } | |
| 56 | 64 | |
| 57 | − for (const tool of tools) { | |
| 58 | − const toolId = tool.descriptor.toolId; | |
| 59 | − if (byToolId.has(toolId)) { | |
| 60 | − return yield* duplicateToolIdError(toolId); | |
| 65 | + return yield* toolCallService.callTool({ | |
| 66 | + runId, | |
| 67 | + callId: `call_${crypto.randomUUID()}`, | |
| 68 | + toolPath, | |
| 69 | + input: normalizeToolInput(args), | |
| 70 | + }); | |
| 71 | + }); | |
| 72 | + | |
| 73 | +const createToolsProxy = ( | |
| 74 | + runId: string, | |
| 75 | + runPromise: <A, E>(effect: Effect.Effect<A, E>) => Promise<A>, | |
| 76 | + toolCallService: RuntimeToolCallService | undefined, | |
| 77 | + path: ReadonlyArray<string> = [], | |
| 78 | +): unknown => { | |
| 79 | + const callable = () => undefined; | |
| 80 | + | |
| 81 | + return new Proxy(callable, { | |
| 82 | + get(_target, prop) { | |
| 83 | + if (prop === "then") { | |
| 84 | + return undefined; | |
| 61 | 85 | } |
| 62 | − byToolId.set(toolId, tool); | |
| 63 | − } | |
| 64 | 86 | |
| 65 | − return byToolId; | |
| 87 | + if (typeof prop !== "string") { | |
| 88 | + return undefined; | |
| 89 | + } | |
| 90 | + | |
| 91 | + return createToolsProxy(runId, runPromise, toolCallService, [...path, prop]); | |
| 92 | + }, | |
| 93 | + apply(_target, _thisArg, args) { | |
| 94 | + const toolPath = path.join("."); | |
| 95 | + if (!toolPath) { | |
| 96 | + throw new Error("Tool path missing in invocation"); | |
| 97 | + } | |
| 98 | + | |
| 99 | + const toolArgs = args.length > 0 ? args[0] : undefined; | |
| 100 | + return runPromise(invokeTool(runId, toolPath, toolArgs, toolCallService)); | |
| 101 | + }, | |
| 66 | 102 | }); |
| 103 | +}; | |
| 67 | 104 | |
| 68 | 105 | const runJavaScript = ( |
| 69 | 106 | code: string, |
| 70 | − toolsObject: Record<string, (args: unknown) => Promise<unknown>>, | |
| 107 | + tools: unknown, | |
| 71 | 108 | ): Effect.Effect<unknown, RuntimeAdapterError> => |
| 72 | 109 | Effect.tryPromise({ |
| 73 | 110 | try: async () => { |
| 74 | 111 | const execute = new Function( |
| 75 | 112 | "tools", |
| 76 | 113 | `"use strict"; return (async () => {\n${code}\n})();`, |
| 77 | − ) as (tools: Record<string, (args: unknown) => Promise<unknown>>) => Promise<unknown>; | |
| 114 | + ) as (tools: unknown) => Promise<unknown>; | |
| 78 | 115 | |
| 79 | − return await execute(toolsObject); | |
| 116 | + return await execute(tools); | |
| 80 | 117 | }, |
| 81 | 118 | catch: toExecutionError, |
| 82 | 119 | }); |
| 83 | 120 | |
| 84 | 121 | export const executeJavaScriptWithTools = ( |
| 85 | 122 | input: ExecuteJavaScriptInput, |
| 86 | −): Effect.Effect<unknown, RuntimeAdapterError, ToolProviderRegistryService> => | |
| 123 | +): Effect.Effect<unknown, RuntimeAdapterError> => | |
| 87 | 124 | Effect.gen(function* () { |
| 88 | − const registry = yield* ToolProviderRegistryService; | |
| 89 | 125 | const runtime = yield* Effect.runtime<never>(); |
| 90 | 126 | const runPromise = Runtime.runPromise(runtime); |
| 91 | − const toolBindings = yield* buildToolBindings(input.tools); | |
| 92 | − | |
| 93 | − const toolsObject: Record<string, (args: unknown) => Promise<unknown>> = | |
| 94 | − Object.create(null); | |
| 95 | − | |
| 96 | − for (const [toolId, binding] of toolBindings.entries()) { | |
| 97 | − toolsObject[toolId] = (args: unknown) => | |
| 98 | − runPromise( | |
| 99 | − registry | |
| 100 | − .invoke({ | |
| 101 | − source: binding.source, | |
| 102 | − tool: binding.descriptor, | |
| 103 | − args, | |
| 104 | − }) | |
| 105 | − .pipe( | |
| 106 | − Effect.mapError(toRuntimeAdapterError), | |
| 107 | − Effect.flatMap((result) => | |
| 108 | − result.isError | |
| 109 | − ? Effect.fail(toolCallFailedError(toolId)) | |
| 110 | − : Effect.succeed(result.output), | |
| 111 | − ), | |
| 112 | − ), | |
| 113 | − ); | |
| 114 | − } | |
| 127 | + const toolsProxy = createToolsProxy( | |
| 128 | + input.runId, | |
| 129 | + runPromise, | |
| 130 | + input.toolCallService, | |
| 131 | + ); | |
| 115 | 132 | |
| 116 | − return yield* runJavaScript(input.code, toolsObject); | |
| 133 | + return yield* runJavaScript(input.code, toolsProxy); | |
| 117 | 134 | }); |
| 118 | 135 | |
| 119 | 136 | export const makeLocalInProcessRuntimeAdapter = (): RuntimeAdapter => ({ |
| @@ -121,9 +138,8 @@ export const makeLocalInProcessRuntimeAdapter = (): RuntimeAdapter => ({ · 1 unmodified line | ||
| 121 | 138 | isAvailable: () => Effect.succeed(true), |
| 122 | 139 | execute: (input) => |
| 123 | 140 | executeJavaScriptWithTools({ |
| 141 | + runId: input.runId, | |
| 124 | 142 | code: input.code, |
| 125 | − tools: input.tools, | |
| 143 | + toolCallService: input.toolCallService, | |
| 126 | 144 | }), |
| 127 | 145 | }); |
| 128 | − | |
| 129 | −export type { RuntimeRunnableTool }; | |