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 | for (const url of extractUrls(markdown)) { | 205 | for (const url of extractUrls(markdown)) { |
| 206 | const lower = url.toLowerCase(); | 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 | graphqlUrls.add(url); | 218 | graphqlUrls.add(url); |
| 210 | } | 219 | } |
| 211 | 220 | ||
| @@ -7,6 +7,7 @@ · 6 unmodified lines | |||
| 7 | "start": "bun server.ts", | 7 | "start": "bun server.ts", |
| 8 | "sync": "bun server.ts --sync-only", | 8 | "sync": "bun server.ts --sync-only", |
| 9 | "scrape:composio": "bun composio-scrape/scrape.ts", | 9 | "scrape:composio": "bun composio-scrape/scrape.ts", |
| 10 | "scrape:composio:enrich": "bun composio-scrape/enrich.ts", | ||
| 10 | "typecheck": "bunx --package @typescript/native-preview tsgo --noEmit -p tsconfig.json", | 11 | "typecheck": "bunx --package @typescript/native-preview tsgo --noEmit -p tsconfig.json", |
| 11 | "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json", | 12 | "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json", |
| 12 | "clean": "rm -rf node_modules .turbo" | 13 | "clean": "rm -rf node_modules .turbo" |
| @@ -1,23 +1,29 @@ | |||
| 1 | import { | 1 | import { |
| 2 | RuntimeAdapterError, | ||
| 2 | RuntimeExecutionPortError, | 3 | RuntimeExecutionPortError, |
| 3 | type ExecuteRuntimeRun, | 4 | type ExecuteRuntimeRun, |
| 4 | } from "@executor-v2/engine"; | 5 | type ExecuteRuntimeRunInput, |
| 5 | import { | ||
| 6 | ToolProviderRegistryService, | ||
| 7 | makeRuntimeAdapterRegistry, | 6 | makeRuntimeAdapterRegistry, |
| 8 | makeToolProviderRegistry, | ||
| 9 | type RuntimeExecuteError, | 7 | type RuntimeExecuteError, |
| 10 | } from "@executor-v2/engine"; | 8 | } from "@executor-v2/engine"; |
| 11 | import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc"; | 9 | import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc"; |
| 12 | import { type ExecuteRunInput } from "@executor-v2/sdk"; | ||
| 13 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 14 | 11 | ||
| 15 | const convexRuntimeAdapter = makeLocalInProcessRuntimeAdapter(); | 12 | const convexRuntimeAdapter = makeLocalInProcessRuntimeAdapter(); |
| 16 | 13 | ||
| 17 | const runtimeAdapters = makeRuntimeAdapterRegistry([convexRuntimeAdapter]); | 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 | Effect.gen(function* () { | 27 | Effect.gen(function* () { |
| 22 | const runtimeAdapter = yield* runtimeAdapters.get(convexRuntimeAdapter.kind).pipe( | 28 | const runtimeAdapter = yield* runtimeAdapters.get(convexRuntimeAdapter.kind).pipe( |
| 23 | Effect.mapError( | 29 | Effect.mapError( |
| @@ -41,12 +47,15 @@ export const executeRuntimeRunInConvex: ExecuteRuntimeRun = (input: ExecuteRunIn · 17 unmodified lines | |||
| 41 | 47 | ||
| 42 | return yield* runtimeAdapter | 48 | return yield* runtimeAdapter |
| 43 | .execute({ | 49 | .execute({ |
| 50 | runId: input.runId, | ||
| 44 | code: input.code, | 51 | code: input.code, |
| 45 | timeoutMs: input.timeoutMs, | 52 | timeoutMs: input.timeoutMs, |
| 46 | tools: [], | 53 | toolCallService: { |
| 54 | callTool: (toolInput) => | ||
| 55 | Effect.fail(missingToolCallService(toolInput.runId, toolInput.toolPath)), | ||
| 56 | }, | ||
| 47 | }) | 57 | }) |
| 48 | .pipe( | 58 | .pipe( |
| 49 | Effect.provideService(ToolProviderRegistryService, toolProviders), | ||
| 50 | Effect.mapError( | 59 | Effect.mapError( |
| 51 | (error: RuntimeExecuteError) => | 60 | (error: RuntimeExecuteError) => |
| 52 | new RuntimeExecutionPortError({ | 61 | new RuntimeExecutionPortError({ |
| @@ -11,7 +11,6 @@ import { · 10 unmodified lines | |||
| 11 | createRuntimeToolCallHandler, | 11 | createRuntimeToolCallHandler, |
| 12 | createUnimplementedRuntimeToolInvoker, | 12 | createUnimplementedRuntimeToolInvoker, |
| 13 | makeRuntimeAdapterRegistry, | 13 | makeRuntimeAdapterRegistry, |
| 14 | makeToolProviderRegistry, | ||
| 15 | } from "@executor-v2/engine"; | 14 | } from "@executor-v2/engine"; |
| 16 | import { | 15 | import { |
| 17 | makeLocalSourceStore, | 16 | makeLocalSourceStore, |
| @@ -56,7 +55,6 @@ const pmRuntimeAdapters = [ · 38 unmodified lines | |||
| 56 | ]; | 55 | ]; |
| 57 | 56 | ||
| 58 | const runtimeAdapters = makeRuntimeAdapterRegistry(pmRuntimeAdapters); | 57 | const runtimeAdapters = makeRuntimeAdapterRegistry(pmRuntimeAdapters); |
| 59 | const toolProviders = makeToolProviderRegistry([]); | ||
| 60 | 58 | ||
| 61 | const defaultRuntimeKind = | 59 | const defaultRuntimeKind = |
| 62 | readConfiguredRuntimeKind(process.env.PM_RUNTIME_KIND) ?? pmRuntimeAdapters[0].kind; | 60 | readConfiguredRuntimeKind(process.env.PM_RUNTIME_KIND) ?? pmRuntimeAdapters[0].kind; |
| @@ -85,22 +83,22 @@ const controlPlaneWebHandler = makeControlPlaneWebHandler( · 22 unmodified lines | |||
| 85 | PmActorLive(localStateStore), | 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 | const executeRuntimeRun = createPmExecuteRuntimeRun({ | 93 | const executeRuntimeRun = createPmExecuteRuntimeRun({ |
| 89 | defaultRuntimeKind, | 94 | defaultRuntimeKind, |
| 90 | runtimeAdapters, | 95 | runtimeAdapters, |
| 91 | toolProviders, | 96 | handleToolCall, |
| 92 | }); | 97 | }); |
| 93 | 98 | ||
| 94 | const runExecutor = createRunExecutor(executeRuntimeRun); | 99 | const runExecutor = createRunExecutor(executeRuntimeRun); |
| 95 | const handleMcp = createPmMcpHandler(runExecutor.executeRun); | 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 | const handleToolCallHttp = createPmToolCallHttpHandler((input) => | 102 | const handleToolCallHttp = createPmToolCallHttpHandler((input) => |
| 105 | Effect.runPromise(handleToolCall(input)), | 103 | Effect.runPromise(handleToolCall(input)), |
| 106 | ); | 104 | ); |
| @@ -1,26 +1,63 @@ | |||
| 1 | import { | 1 | import { |
| 2 | RuntimeAdapterError, | ||
| 2 | RuntimeExecutionPortError, | 3 | RuntimeExecutionPortError, |
| 3 | type ExecuteRuntimeRun, | 4 | type ExecuteRuntimeRun, |
| 4 | } from "@executor-v2/engine"; | 5 | type ExecuteRuntimeRunInput, |
| 5 | import { | ||
| 6 | ToolProviderRegistryService, | ||
| 7 | type RuntimeAdapterRegistry, | 6 | type RuntimeAdapterRegistry, |
| 8 | type RuntimeExecuteError, | 7 | type RuntimeExecuteError, |
| 9 | type ToolProviderRegistry, | 8 | type RuntimeToolCallService, |
| 10 | } from "@executor-v2/engine"; | 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 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 13 | 15 | ||
| 14 | export type PmRuntimeExecutionPortOptions = { | 16 | export type PmRuntimeExecutionPortOptions = { |
| 15 | defaultRuntimeKind: string; | 17 | defaultRuntimeKind: string; |
| 16 | runtimeAdapters: RuntimeAdapterRegistry; | 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 | export const createPmExecuteRuntimeRun = ( | 57 | export const createPmExecuteRuntimeRun = ( |
| 21 | options: PmRuntimeExecutionPortOptions, | 58 | options: PmRuntimeExecutionPortOptions, |
| 22 | ): ExecuteRuntimeRun => { | 59 | ): ExecuteRuntimeRun => { |
| 23 | return (input: ExecuteRunInput) => | 60 | return (input: ExecuteRuntimeRunInput) => |
| 24 | Effect.gen(function* () { | 61 | Effect.gen(function* () { |
| 25 | const runtimeAdapter = yield* options.runtimeAdapters | 62 | const runtimeAdapter = yield* options.runtimeAdapters |
| 26 | .get(options.defaultRuntimeKind) | 63 | .get(options.defaultRuntimeKind) |
| @@ -46,12 +83,12 @@ export const createPmExecuteRuntimeRun = ( · 19 unmodified lines | |||
| 46 | 83 | ||
| 47 | return yield* runtimeAdapter | 84 | return yield* runtimeAdapter |
| 48 | .execute({ | 85 | .execute({ |
| 86 | runId: input.runId, | ||
| 49 | code: input.code, | 87 | code: input.code, |
| 50 | timeoutMs: input.timeoutMs, | 88 | timeoutMs: input.timeoutMs, |
| 51 | tools: [], | 89 | toolCallService: runtimeToolCallService(options), |
| 52 | }) | 90 | }) |
| 53 | .pipe( | 91 | .pipe( |
| 54 | Effect.provideService(ToolProviderRegistryService, options.toolProviders), | ||
| 55 | Effect.mapError( | 92 | Effect.mapError( |
| 56 | (error: RuntimeExecuteError) => | 93 | (error: RuntimeExecuteError) => |
| 57 | new RuntimeExecutionPortError({ | 94 | new RuntimeExecutionPortError({ |
| @@ -86,14 +86,10 @@ · 85 unmodified lines | |||
| 86 | "zod": "^4.3.6", | 86 | "zod": "^4.3.6", |
| 87 | }, | 87 | }, |
| 88 | "devDependencies": { | 88 | "devDependencies": { |
| 89 | "@effect/platform": "catalog:", | ||
| 90 | "@effect/vitest": "catalog:", | 89 | "@effect/vitest": "catalog:", |
| 91 | "@executor-v2/engine": "workspace:*", | 90 | "@executor-v2/engine": "workspace:*", |
| 92 | "@executor-v2/management-api": "workspace:*", | ||
| 93 | "@executor-v2/persistence-ports": "workspace:*", | ||
| 94 | "@executor-v2/runtime-deno-subprocess": "workspace:*", | 91 | "@executor-v2/runtime-deno-subprocess": "workspace:*", |
| 95 | "@executor-v2/runtime-local-inproc": "workspace:*", | 92 | "@executor-v2/runtime-local-inproc": "workspace:*", |
| 96 | "@executor-v2/schema": "workspace:*", | ||
| 97 | "ai": "^6.0.0", | 93 | "ai": "^6.0.0", |
| 98 | "effect": "catalog:", | 94 | "effect": "catalog:", |
| 99 | "vitest": "catalog:", | 95 | "vitest": "catalog:", |
| @@ -137,6 +133,7 @@ · 37 unmodified lines | |||
| 137 | "effect": "catalog:", | 133 | "effect": "catalog:", |
| 138 | }, | 134 | }, |
| 139 | "devDependencies": { | 135 | "devDependencies": { |
| 136 | "@effect/platform": "catalog:", | ||
| 140 | "@effect/vitest": "catalog:", | 137 | "@effect/vitest": "catalog:", |
| 141 | "@executor-v2/management-api": "workspace:*", | 138 | "@executor-v2/management-api": "workspace:*", |
| 142 | "vitest": "catalog:", | 139 | "vitest": "catalog:", |
| @@ -15,14 +15,10 @@ · 14 unmodified lines | |||
| 15 | "zod": "^4.3.6" | 15 | "zod": "^4.3.6" |
| 16 | }, | 16 | }, |
| 17 | "devDependencies": { | 17 | "devDependencies": { |
| 18 | "@effect/platform": "catalog:", | ||
| 19 | "@effect/vitest": "catalog:", | 18 | "@effect/vitest": "catalog:", |
| 20 | "@executor-v2/engine": "workspace:*", | 19 | "@executor-v2/engine": "workspace:*", |
| 21 | "@executor-v2/management-api": "workspace:*", | ||
| 22 | "@executor-v2/persistence-ports": "workspace:*", | ||
| 23 | "@executor-v2/runtime-deno-subprocess": "workspace:*", | 20 | "@executor-v2/runtime-deno-subprocess": "workspace:*", |
| 24 | "@executor-v2/runtime-local-inproc": "workspace:*", | 21 | "@executor-v2/runtime-local-inproc": "workspace:*", |
| 25 | "@executor-v2/schema": "workspace:*", | ||
| 26 | "ai": "^6.0.0", | 22 | "ai": "^6.0.0", |
| 27 | "effect": "catalog:", | 23 | "effect": "catalog:", |
| 28 | "vitest": "catalog:" | 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 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 14 | import { | 2 | import { |
| 15 | createInMemoryRuntimeRunClient, | 3 | createInMemoryRuntimeRunClient, |
| 16 | createRuntimeRunClient, | 4 | createRuntimeRunClient, |
| 17 | makeOpenApiToolProvider, | ||
| 18 | makeToolProviderRegistry, | ||
| 19 | openApiToolDescriptorsFromManifest, | ||
| 20 | } from "@executor-v2/engine"; | 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 | import { makeDenoSubprocessRuntimeAdapter } from "@executor-v2/runtime-deno-subprocess"; | 6 | import { makeDenoSubprocessRuntimeAdapter } from "@executor-v2/runtime-deno-subprocess"; |
| 29 | import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc"; | 7 | import { makeLocalInProcessRuntimeAdapter } from "@executor-v2/runtime-local-inproc"; |
| 30 | import { createExecutorRunClient } from "@executor-v2/sdk"; | 8 | import { createExecutorRunClient } from "@executor-v2/sdk"; |
| 31 | import type { ExecuteRunResult } from "@executor-v2/sdk"; | 9 | import type { ExecuteRunResult } from "@executor-v2/sdk"; |
| 32 | import { createGateway, generateText, stepCountIs, tool } from "ai"; | 10 | import { createGateway, generateText, stepCountIs, tool } from "ai"; |
| 33 | import * as Data from "effect/Data"; | ||
| 34 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| 35 | import * as Option from "effect/Option"; | ||
| 36 | import * as Schema from "effect/Schema"; | ||
| 37 | 12 | ||
| 38 | import { z } from "zod"; | 13 | import { z } from "zod"; |
| 39 | 14 | ||
| @@ -41,415 +16,165 @@ import { toAiSdkTools } from "./index"; · 1 unmodified line | |||
| 41 | 16 | ||
| 42 | const gateway = createGateway(); | 17 | const gateway = createGateway(); |
| 43 | 18 | ||
| 44 | const decodeSource = Schema.decodeUnknownSync(SourceSchema); | 19 | describe("toAiSdkTools", () => { |
| 45 | 20 | it.effect("generates a tool call via generateText with a mock executor", () => | |
| 46 | type GitHubApiTestServer = { | 21 | Effect.gen(function* () { |
| 47 | baseUrl: string; | 22 | const executionLog: Array<{ code: string; timeoutMs?: number }> = []; |
| 48 | requests: Array<{ | 23 | |
| 49 | path: string; | 24 | const mockResult: ExecuteRunResult = { |
| 50 | accept: string | null; | 25 | runId: "run-test-123", |
| 51 | }>; | 26 | status: "completed", |
| 52 | close: () => Promise<void>; | 27 | result: 42, |
| 53 | }; | 28 | }; |
| 54 | 29 | ||
| 55 | class GitHubApiServerReleaseError extends Data.TaggedError( | 30 | const runClient = createExecutorRunClient(async (input) => { |
| 56 | "GitHubApiServerReleaseError", | 31 | executionLog.push({ code: input.code, timeoutMs: input.timeoutMs }); |
| 57 | )<{ | 32 | return mockResult; |
| 58 | message: string; | 33 | }); |
| 59 | }> {} | 34 | |
| 60 | 35 | const tools = toAiSdkTools({ | |
| 61 | const jsonResponse = (res: ServerResponse, statusCode: number, body: unknown): void => { | 36 | runClient, |
| 62 | res.statusCode = statusCode; | 37 | makeTool: (def) => tool(def), |
| 63 | res.setHeader("content-type", "application/json"); | 38 | defaults: { timeoutMs: 30_000 }, |
| 64 | res.end(JSON.stringify(body)); | 39 | }); |
| 65 | }; | 40 | |
| 66 | 41 | const result = yield* Effect.tryPromise(() => | |
| 67 | const getHeaderValue = (req: IncomingMessage, key: string): string | null => { | 42 | generateText({ |
| 68 | const value = req.headers[key]; | 43 | model: gateway("openai/gpt-4o-mini"), |
| 69 | if (typeof value === "string") { | 44 | tools, |
| 70 | return value; | 45 | stopWhen: stepCountIs(3), |
| 71 | } | 46 | system: |
| 72 | 47 | "You have an execute tool that runs JavaScript code. Always use it when asked to run code.", | |
| 73 | if (Array.isArray(value)) { | 48 | prompt: 'Run this code using the execute tool: console.log("hello")', |
| 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), | ||
| 148 | }), | 49 | }), |
| 149 | }).pipe(Effect.orDie), | 50 | ); |
| 150 | ); | 51 | |
| 151 | 52 | expect(executionLog.length).toBeGreaterThanOrEqual(1); | |
| 152 | const openApiPath = "/openapi.json"; | 53 | expect(executionLog[0]!.code).toBeTypeOf("string"); |
| 153 | 54 | expect(executionLog[0]!.code.length).toBeGreaterThan(0); | |
| 154 | const githubOwnerParam = HttpApiSchema.param("owner", Schema.String); | 55 | expect(executionLog[0]!.timeoutMs).toBeTypeOf("number"); |
| 155 | const githubRepoParam = HttpApiSchema.param("repo", Schema.String); | 56 | |
| 156 | 57 | const toolCallSteps = result.steps.filter( | |
| 157 | class GitHubReposApi extends HttpApiGroup.make("repos").add( | 58 | (step) => step.toolCalls.length > 0, |
| 158 | HttpApiEndpoint.get("getRepo")`/repos/${githubOwnerParam}/${githubRepoParam}`.addSuccess( | 59 | ); |
| 159 | Schema.Unknown, | 60 | expect(toolCallSteps.length).toBeGreaterThanOrEqual(1); |
| 160 | ), | 61 | |
| 161 | ) {} | 62 | const firstToolCall = toolCallSteps[0]!.toolCalls[0]!; |
| 162 | 63 | expect(firstToolCall.toolName).toBe("execute"); | |
| 163 | class GitHubApi extends HttpApi.make("github").add(GitHubReposApi) {} | 64 | expect(firstToolCall.input).toHaveProperty("code"); |
| 164 | 65 | ||
| 165 | const githubOpenApiSpec = OpenApi.fromApi(GitHubApi); | 66 | const toolResultSteps = result.steps.filter( |
| 166 | 67 | (step) => step.toolResults.length > 0, | |
| 167 | const makeGitHubSource = (baseUrl: string): Source => | 68 | ); |
| 168 | decodeSource({ | 69 | expect(toolResultSteps.length).toBeGreaterThanOrEqual(1); |
| 169 | id: "src_github", | 70 | const toolResult = toolResultSteps[0]!.toolResults[0]!; |
| 170 | workspaceId: "ws_local", | 71 | expect(toolResult.toolName).toBe("execute"); |
| 171 | name: "github", | 72 | expect(toolResult.output).toMatchObject(mockResult); |
| 172 | kind: "openapi", | 73 | |
| 173 | endpoint: baseUrl, | 74 | expect(result.text).toBeTypeOf("string"); |
| 174 | status: "connected", | 75 | }), |
| 175 | enabled: true, | 76 | ); |
| 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); | ||
| 196 | 77 | ||
| 197 | const openApiSpec = yield* HttpClient.get(`${baseUrl}${openApiPath}`).pipe( | 78 | it.effect("calls a normal AI SDK tool from the execute sandbox", () => |
| 198 | Effect.flatMap(HttpClientResponse.filterStatusOk), | 79 | Effect.gen(function* () { |
| 199 | Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Unknown)), | 80 | const normalToolCalls: Array<{ query: string }> = []; |
| 200 | Effect.provide(FetchHttpClient.layer), | ||
| 201 | ); | ||
| 202 | 81 | ||
| 203 | const artifactsByKey = new Map<string, ToolArtifact>(); | 82 | const searchDocsTool = tool({ |
| 204 | const artifactStore: ToolArtifactStore = { | 83 | description: "Search docs by query", |
| 205 | getBySource: (workspaceId: Source["workspaceId"], sourceId: Source["id"]) => | 84 | inputSchema: z.object({ |
| 206 | Effect.succeed( | 85 | query: z.string(), |
| 207 | Option.fromNullable(artifactsByKey.get(`${workspaceId}:${sourceId}`)), | ||
| 208 | ), | ||
| 209 | upsert: (artifact: ToolArtifact) => | ||
| 210 | Effect.sync(() => { | ||
| 211 | artifactsByKey.set(`${artifact.workspaceId}:${artifact.sourceId}`, artifact); | ||
| 212 | }), | 86 | }), |
| 213 | }; | 87 | execute: async (input: { query: string }) => { |
| 214 | 88 | normalToolCalls.push(input); | |
| 215 | const sourceManager = makeSourceManagerService(artifactStore); | 89 | return { |
| 216 | const refreshResult = yield* sourceManager.refreshOpenApiArtifact({ | 90 | hits: [`match:${input.query}`], |
| 217 | source, | 91 | }; |
| 218 | openApiSpec, | 92 | }, |
| 219 | }); | 93 | }); |
| 220 | |||
| 221 | const descriptors = yield* openApiToolDescriptorsFromManifest( | ||
| 222 | source, | ||
| 223 | refreshResult.artifact.manifestJson, | ||
| 224 | ); | ||
| 225 | const githubTool = onlyTool(descriptors); | ||
| 226 | 94 | ||
| 227 | return { | 95 | const runClient = createInMemoryRuntimeRunClient({ |
| 228 | runClient: createRuntimeRunClient({ | ||
| 229 | runtimeAdapter: makeLocalInProcessRuntimeAdapter(), | 96 | runtimeAdapter: makeLocalInProcessRuntimeAdapter(), |
| 230 | toolProviderRegistry: makeToolProviderRegistry([makeOpenApiToolProvider()]), | 97 | tools: { |
| 231 | tools: [ | 98 | search_docs: searchDocsTool, |
| 232 | { | 99 | }, |
| 233 | descriptor: githubTool, | ||
| 234 | source, | ||
| 235 | }, | ||
| 236 | ], | ||
| 237 | defaults: { | 100 | defaults: { |
| 238 | timeoutMs: 30_000, | 101 | timeoutMs: 30_000, |
| 239 | }, | 102 | }, |
| 240 | }), | 103 | }); |
| 241 | toolId: githubTool.toolId, | ||
| 242 | }; | ||
| 243 | }); | ||
| 244 | 104 | ||
| 245 | describe("toAiSdkTools", () => { | 105 | const tools = toAiSdkTools({ |
| 246 | it.effect( | 106 | runClient, |
| 247 | "generates a tool call via generateText with a mock executor", | 107 | makeTool: (def) => def, |
| 248 | () => | 108 | }); |
| 249 | Effect.gen(function* () { | ||
| 250 | const executionLog: Array<{ code: string; timeoutMs?: number }> = []; | ||
| 251 | 109 | ||
| 252 | const mockResult: ExecuteRunResult = { | 110 | const result = yield* Effect.tryPromise(() => |
| 253 | runId: "run-test-123", | 111 | tools.execute.execute({ |
| 254 | status: "completed", | 112 | code: "return await tools.search_docs({ query: 'codemode adapter integration' });", |
| 255 | result: 42, | 113 | }), |
| 256 | }; | 114 | ); |
| 257 | 115 | ||
| 258 | const runClient = createExecutorRunClient(async (input) => { | 116 | expect(result.status).toBe("completed"); |
| 259 | executionLog.push({ code: input.code, timeoutMs: input.timeoutMs }); | 117 | expect(result.result).toEqual({ |
| 260 | return mockResult; | 118 | hits: ["match:codemode adapter integration"], |
| 261 | }); | 119 | }); |
| 262 | 120 | expect(normalToolCalls).toEqual([ | |
| 263 | const tools = toAiSdkTools({ | 121 | { |
| 264 | runClient, | 122 | query: "codemode adapter integration", |
| 265 | makeTool: (def) => tool(def), | 123 | }, |
| 266 | defaults: { timeoutMs: 30_000 }, | 124 | ]); |
| 267 | }); | 125 | }), |
| 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 }, | ||
| 395 | ); | 126 | ); |
| 396 | 127 | ||
| 397 | it.effect( | 128 | it.effect("executes code in a real Deno subprocess via generateText", () => |
| 398 | "executes code in a real Deno subprocess via generateText", | 129 | Effect.gen(function* () { |
| 399 | () => | 130 | const runtimeAdapter = makeDenoSubprocessRuntimeAdapter({ |
| 400 | Effect.gen(function* () { | 131 | defaultTimeoutMs: 10_000, |
| 401 | const runtimeAdapter = makeDenoSubprocessRuntimeAdapter({ | 132 | }); |
| 402 | defaultTimeoutMs: 10_000, | 133 | |
| 403 | }); | 134 | const runClient = createRuntimeRunClient({ |
| 404 | 135 | runtimeAdapter, | |
| 405 | const toolProviderRegistry = makeToolProviderRegistry([]); | 136 | }); |
| 406 | 137 | ||
| 407 | const runClient = createRuntimeRunClient({ | 138 | const tools = toAiSdkTools({ |
| 408 | runtimeAdapter, | 139 | runClient, |
| 409 | toolProviderRegistry, | 140 | makeTool: (def) => tool(def), |
| 410 | }); | 141 | }); |
| 411 | 142 | ||
| 412 | const tools = toAiSdkTools({ | 143 | const result = yield* Effect.tryPromise(() => |
| 413 | runClient, | 144 | generateText({ |
| 414 | makeTool: (def) => tool(def), | 145 | model: gateway("openai/gpt-4o-mini"), |
| 415 | }); | 146 | tools, |
| 416 | 147 | stopWhen: stepCountIs(3), | |
| 417 | const result = yield* Effect.tryPromise(() => | 148 | system: [ |
| 418 | generateText({ | 149 | "You have an execute tool that runs JavaScript code in a sandboxed Deno runtime.", |
| 419 | model: gateway("openai/gpt-4o-mini"), | 150 | "Always use it when asked to run code.", |
| 420 | tools, | 151 | "The code must use `return` to produce a result value.", |
| 421 | stopWhen: stepCountIs(3), | 152 | ].join(" "), |
| 422 | system: [ | 153 | prompt: "Use the execute tool to compute 2 + 3. The code should be: return 2 + 3;", |
| 423 | "You have an execute tool that runs JavaScript code in a sandboxed Deno runtime.", | 154 | }), |
| 424 | "Always use it when asked to run code.", | 155 | ); |
| 425 | "The code must use `return` to produce a result value.", | 156 | |
| 426 | ].join(" "), | 157 | const toolCallSteps = result.steps.filter( |
| 427 | prompt: "Use the execute tool to compute 2 + 3. The code should be: return 2 + 3;", | 158 | (step) => step.toolCalls.length > 0, |
| 428 | }), | 159 | ); |
| 429 | ); | 160 | expect(toolCallSteps.length).toBeGreaterThanOrEqual(1); |
| 430 | 161 | ||
| 431 | const toolCallSteps = result.steps.filter( | 162 | const firstToolCall = toolCallSteps[0]!.toolCalls[0]!; |
| 432 | (step) => step.toolCalls.length > 0, | 163 | expect(firstToolCall.toolName).toBe("execute"); |
| 433 | ); | 164 | |
| 434 | expect(toolCallSteps.length).toBeGreaterThanOrEqual(1); | 165 | const toolResultSteps = result.steps.filter( |
| 435 | 166 | (step) => step.toolResults.length > 0, | |
| 436 | const firstToolCall = toolCallSteps[0]!.toolCalls[0]!; | 167 | ); |
| 437 | expect(firstToolCall.toolName).toBe("execute"); | 168 | expect(toolResultSteps.length).toBeGreaterThanOrEqual(1); |
| 438 | 169 | ||
| 439 | const toolResultSteps = result.steps.filter( | 170 | const toolResult = toolResultSteps[0]!.toolResults[0]!; |
| 440 | (step) => step.toolResults.length > 0, | 171 | expect(toolResult.toolName).toBe("execute"); |
| 441 | ); | 172 | expect(toolResult.output).toMatchObject({ |
| 442 | expect(toolResultSteps.length).toBeGreaterThanOrEqual(1); | 173 | status: "completed", |
| 443 | 174 | result: 5, | |
| 444 | const toolResult = toolResultSteps[0]!.toolResults[0]!; | 175 | }); |
| 445 | expect(toolResult.toolName).toBe("execute"); | 176 | |
| 446 | expect(toolResult.output).toMatchObject({ | 177 | expect(result.text).toBeTypeOf("string"); |
| 447 | status: "completed", | 178 | }), |
| 448 | result: 5, | ||
| 449 | }); | ||
| 450 | |||
| 451 | expect(result.text).toBeTypeOf("string"); | ||
| 452 | }), | ||
| 453 | { timeout: 30_000 }, | ||
| 454 | ); | 179 | ); |
| 455 | }); | 180 | }); |
| @@ -17,6 +17,7 @@ · 16 unmodified lines | |||
| 17 | "effect": "catalog:" | 17 | "effect": "catalog:" |
| 18 | }, | 18 | }, |
| 19 | "devDependencies": { | 19 | "devDependencies": { |
| 20 | "@effect/platform": "catalog:", | ||
| 20 | "@effect/vitest": "catalog:", | 21 | "@effect/vitest": "catalog:", |
| 21 | "@executor-v2/management-api": "workspace:*", | 22 | "@executor-v2/management-api": "workspace:*", |
| 22 | "vitest": "catalog:" | 23 | "vitest": "catalog:" |
| @@ -1,72 +1,34 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { | ||
| 3 | CanonicalToolDescriptorSchema, | ||
| 4 | type CanonicalToolDescriptor, | ||
| 5 | } from "@executor-v2/schema"; | ||
| 6 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 7 | import * as Either from "effect/Either"; | 3 | import * as Either from "effect/Either"; |
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | 4 | ||
| 10 | import { | 5 | import { |
| 11 | DenoSubprocessRunnerError, | 6 | DenoSubprocessRunnerError, |
| 12 | executeJavaScriptInDenoSubprocess, | 7 | executeJavaScriptInDenoSubprocess, |
| 13 | } from "@executor-v2/runtime-deno-subprocess"; | 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 | describe("executeJavaScriptInDenoSubprocess", () => { | 10 | describe("executeJavaScriptInDenoSubprocess", () => { |
| 37 | it.effect("runs code in Deno subprocess and proxies tool calls", () => | 11 | it.effect("runs code in Deno subprocess and proxies tool calls", () => |
| 38 | Effect.gen(function* () { | 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 | const result = yield* executeJavaScriptInDenoSubprocess({ | 13 | const result = yield* executeJavaScriptInDenoSubprocess({ |
| 14 | runId: "run_deno_1", | ||
| 61 | code: "console.log('hello'); return await tools.sum({ a: 2, b: 3 });", | 15 | code: "console.log('hello'); return await tools.sum({ a: 2, b: 3 });", |
| 62 | tools: [ | 16 | toolCallService: { |
| 63 | { | 17 | callTool: (input) => |
| 64 | descriptor: sumToolDescriptor, | 18 | input.toolPath === "sum" |
| 65 | source: null, | 19 | ? Effect.succeed( |
| 66 | }, | 20 | ((input.input?.a as number) ?? 0) + |
| 67 | ], | 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 | timeoutMs: 10_000, | 30 | timeoutMs: 10_000, |
| 69 | }).pipe(Effect.provideService(ToolProviderRegistryService, registry)); | 31 | }); |
| 70 | 32 | ||
| 71 | expect(result).toBe(5); | 33 | expect(result).toBe(5); |
| 72 | }), | 34 | }), |
| @@ -74,15 +36,13 @@ describe("executeJavaScriptInDenoSubprocess", () => { · 1 unmodified line | |||
| 74 | 36 | ||
| 75 | it.effect("returns a typed error when Deno executable is missing", () => | 37 | it.effect("returns a typed error when Deno executable is missing", () => |
| 76 | Effect.gen(function* () { | 38 | Effect.gen(function* () { |
| 77 | const registry = makeToolProviderRegistry([]); | ||
| 78 | |||
| 79 | const result = yield* Effect.either( | 39 | const result = yield* Effect.either( |
| 80 | executeJavaScriptInDenoSubprocess({ | 40 | executeJavaScriptInDenoSubprocess({ |
| 41 | runId: "run_deno_2", | ||
| 81 | code: "return 1;", | 42 | code: "return 1;", |
| 82 | tools: [], | ||
| 83 | denoExecutable: "/definitely-missing-deno-binary", | 43 | denoExecutable: "/definitely-missing-deno-binary", |
| 84 | timeoutMs: 1_000, | 44 | timeoutMs: 1_000, |
| 85 | }).pipe(Effect.provideService(ToolProviderRegistryService, registry)), | 45 | }), |
| 86 | ); | 46 | ); |
| 87 | 47 | ||
| 88 | expect(Either.isLeft(result)).toBe(true); | 48 | expect(Either.isLeft(result)).toBe(true); |
| @@ -10,6 +10,7 @@ export { · 9 unmodified lines | |||
| 10 | type RuntimeExecuteError, | 10 | type RuntimeExecuteError, |
| 11 | type RuntimeExecuteInput, | 11 | type RuntimeExecuteInput, |
| 12 | type RuntimeRunnableTool, | 12 | type RuntimeRunnableTool, |
| 13 | type RuntimeToolCallService, | ||
| 13 | } from "./runtime-adapters"; | 14 | } from "./runtime-adapters"; |
| 14 | 15 | ||
| 15 | export { | 16 | export { |
| @@ -46,6 +47,7 @@ export { · 30 unmodified lines | |||
| 46 | export { | 47 | export { |
| 47 | RuntimeExecutionPortError, | 48 | RuntimeExecutionPortError, |
| 48 | type ExecuteRuntimeRun, | 49 | type ExecuteRuntimeRun, |
| 50 | type ExecuteRuntimeRunInput, | ||
| 49 | } from "./runtime-execution-port"; | 51 | } from "./runtime-execution-port"; |
| 50 | 52 | ||
| 51 | export { | 53 | export { |
| @@ -1,23 +1,25 @@ | |||
| 1 | import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; | 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 | import { describe, expect, it } from "@effect/vitest"; | 10 | import { describe, expect, it } from "@effect/vitest"; |
| 11 | import { makeSourceManagerService } from "@executor-v2/management-api"; | ||
| 4 | import { type ToolArtifactStore } from "@executor-v2/persistence-ports"; | 12 | import { type ToolArtifactStore } from "@executor-v2/persistence-ports"; |
| 5 | import { type Source, SourceSchema, type ToolArtifact } from "@executor-v2/schema"; | 13 | import { type Source, SourceSchema, type ToolArtifact } from "@executor-v2/schema"; |
| 6 | import { makeSourceManagerService } from "@executor-v2/management-api"; | ||
| 7 | import * as Data from "effect/Data"; | 14 | import * as Data from "effect/Data"; |
| 8 | import * as Effect from "effect/Effect"; | 15 | import * as Effect from "effect/Effect"; |
| 9 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| 10 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| 11 | 18 | ||
| 12 | import { executeJavaScriptWithTools } from "@executor-v2/runtime-local-inproc"; | 19 | import { executeJavaScriptWithTools } from "@executor-v2/runtime-local-inproc"; |
| 13 | import { | 20 | import { makeOpenApiToolProvider, openApiToolDescriptorsFromManifest } from "./openapi-provider"; |
| 14 | makeOpenApiToolProvider, | 21 | import { RuntimeAdapterError } from "./runtime-adapters"; |
| 15 | openApiToolDescriptorsFromManifest, | 22 | import { makeToolProviderRegistry } from "./tool-providers"; |
| 16 | } from "./openapi-provider"; | ||
| 17 | import { | ||
| 18 | makeToolProviderRegistry, | ||
| 19 | ToolProviderRegistryService, | ||
| 20 | } from "./tool-providers"; | ||
| 21 | 23 | ||
| 22 | const decodeSource = Schema.decodeUnknownSync(SourceSchema); | 24 | const decodeSource = Schema.decodeUnknownSync(SourceSchema); |
| 23 | 25 | ||
| @@ -25,8 +27,7 @@ type TestServer = { · 1 unmodified line | |||
| 25 | baseUrl: string; | 27 | baseUrl: string; |
| 26 | requests: Array<{ | 28 | requests: Array<{ |
| 27 | path: string; | 29 | path: string; |
| 28 | query: string; | 30 | accept: string | null; |
| 29 | apiKey: string | null; | ||
| 30 | }>; | 31 | }>; |
| 31 | close: () => Promise<void>; | 32 | close: () => Promise<void>; |
| 32 | }; | 33 | }; |
| @@ -57,6 +58,22 @@ const getHeaderValue = ( · 24 unmodified lines | |||
| 57 | return null; | 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 | const makeTestServer = Effect.acquireRelease( | 77 | const makeTestServer = Effect.acquireRelease( |
| 61 | Effect.promise<TestServer>( | 78 | Effect.promise<TestServer>( |
| 62 | () => | 79 | () => |
| @@ -67,17 +84,16 @@ const makeTestServer = Effect.acquireRelease( · 4 unmodified lines | |||
| 67 | const host = getHeaderValue(req, "host") ?? "127.0.0.1"; | 84 | const host = getHeaderValue(req, "host") ?? "127.0.0.1"; |
| 68 | const url = new URL(req.url ?? "/", `http://${host}`); | 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 | requests.push({ | 88 | requests.push({ |
| 72 | path: url.pathname, | 89 | path: url.pathname, |
| 73 | query: url.search, | 90 | accept: getHeaderValue(req, "accept"), |
| 74 | apiKey: getHeaderValue(req, "x-api-key"), | ||
| 75 | }); | 91 | }); |
| 76 | 92 | ||
| 77 | jsonResponse(res, 200, { | 93 | jsonResponse(res, 200, { |
| 78 | id: "u123", | 94 | full_name: "octocat/hello-world", |
| 79 | verbose: url.searchParams.get("verbose") === "true", | 95 | stargazers_count: 42, |
| 80 | apiKey: getHeaderValue(req, "x-api-key"), | 96 | private: false, |
| 81 | }); | 97 | }); |
| 82 | return; | 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 | (testServer) => | 132 | (testServer) => |
| 117 | Effect.tryPromise({ | 133 | Effect.tryPromise({ |
| @@ -124,41 +140,14 @@ const makeTestServer = Effect.acquireRelease( · 6 unmodified lines | |||
| 124 | ); | 140 | ); |
| 125 | 141 | ||
| 126 | describe("OpenAPI execution vertical slice", () => { | 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 | Effect.gen(function* () { | 144 | Effect.gen(function* () { |
| 129 | const testServer = yield* makeTestServer; | 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 | const source: Source = decodeSource({ | 147 | const source: Source = decodeSource({ |
| 159 | id: "src_openapi", | 148 | id: "src_openapi", |
| 160 | workspaceId: "ws_local", | 149 | workspaceId: "ws_local", |
| 161 | name: "local-openapi", | 150 | name: "github", |
| 162 | kind: "openapi", | 151 | kind: "openapi", |
| 163 | endpoint: testServer.baseUrl, | 152 | endpoint: testServer.baseUrl, |
| 164 | status: "connected", | 153 | status: "connected", |
| @@ -178,17 +167,14 @@ describe("OpenAPI execution vertical slice", () => { · 13 unmodified lines | |||
| 178 | ), | 167 | ), |
| 179 | upsert: (artifact: ToolArtifact) => | 168 | upsert: (artifact: ToolArtifact) => |
| 180 | Effect.sync(() => { | 169 | Effect.sync(() => { |
| 181 | artifactsByKey.set( | 170 | artifactsByKey.set(`${artifact.workspaceId}:${artifact.sourceId}`, artifact); |
| 182 | `${artifact.workspaceId}:${artifact.sourceId}`, | ||
| 183 | artifact, | ||
| 184 | ); | ||
| 185 | }), | 171 | }), |
| 186 | }; | 172 | }; |
| 187 | const sourceManager = makeSourceManagerService(artifactStore); | ||
| 188 | 173 | ||
| 174 | const sourceManager = makeSourceManagerService(artifactStore); | ||
| 189 | const refreshResult = yield* sourceManager.refreshOpenApiArtifact({ | 175 | const refreshResult = yield* sourceManager.refreshOpenApiArtifact({ |
| 190 | source, | 176 | source, |
| 191 | openApiSpec, | 177 | openApiSpec: githubOpenApiSpec, |
| 192 | }); | 178 | }); |
| 193 | 179 | ||
| 194 | const tools = yield* openApiToolDescriptorsFromManifest( | 180 | const tools = yield* openApiToolDescriptorsFromManifest( |
| @@ -196,49 +182,69 @@ describe("OpenAPI execution vertical slice", () => { · 1 unmodified line | |||
| 196 | refreshResult.artifact.manifestJson, | 182 | refreshResult.artifact.manifestJson, |
| 197 | ); | 183 | ); |
| 198 | 184 | ||
| 199 | const getUserTool = tools.find((tool) => tool.toolId === "getUser"); | 185 | expect(tools).toHaveLength(1); |
| 200 | if (!getUserTool) { | 186 | const githubTool = tools[0]!; |
| 201 | throw new Error("expected getUser tool"); | ||
| 202 | } | ||
| 203 | 187 | ||
| 204 | const registry = makeToolProviderRegistry([makeOpenApiToolProvider()]); | 188 | const registry = makeToolProviderRegistry([makeOpenApiToolProvider()]); |
| 205 | 189 | ||
| 206 | const executionResult = yield* executeJavaScriptWithTools({ | 190 | const executionResult = yield* executeJavaScriptWithTools({ |
| 207 | code: ` | 191 | runId: "run_openapi_1", |
| 208 | return await tools.getUser({ | 192 | code: `return await tools[${quoteJavaScriptString(githubTool.toolId)}]({ owner: "octocat", repo: "hello-world" });`, |
| 209 | userId: "u123", | 193 | toolCallService: { |
| 210 | verbose: "true", | 194 | callTool: (input) => { |
| 211 | "x-api-key": "sk_test" | 195 | if (input.toolPath !== githubTool.toolId) { |
| 212 | }); | 196 | return new RuntimeAdapterError({ |
| 213 | `, | 197 | operation: "call_tool", |
| 214 | tools: [ | 198 | runtimeKind: "local-inproc", |
| 215 | { | 199 | message: `Unknown tool path: ${input.toolPath}`, |
| 216 | descriptor: getUserTool, | 200 | details: null, |
| 217 | source, | 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 | ], | 234 | }, |
| 220 | }).pipe( | 235 | }); |
| 221 | Effect.provideService(ToolProviderRegistryService, registry), | ||
| 222 | ); | ||
| 223 | 236 | ||
| 224 | const output = executionResult as { | 237 | expect(executionResult).toMatchObject({ |
| 225 | status: number; | 238 | status: 200, |
| 226 | body: { | 239 | body: { |
| 227 | id: string; | 240 | full_name: "octocat/hello-world", |
| 228 | verbose: boolean; | 241 | stargazers_count: 42, |
| 229 | apiKey: string | null; | 242 | private: false, |
| 230 | }; | 243 | }, |
| 231 | }; | 244 | }); |
| 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"); | ||
| 237 | 245 | ||
| 238 | expect(testServer.requests).toHaveLength(1); | 246 | expect(testServer.requests).toHaveLength(1); |
| 239 | expect(testServer.requests[0]?.path).toBe("/users/u123"); | 247 | expect(testServer.requests[0]?.path).toBe("/repos/octocat/hello-world"); |
| 240 | expect(testServer.requests[0]?.query).toBe("?verbose=true"); | ||
| 241 | expect(testServer.requests[0]?.apiKey).toBe("sk_test"); | ||
| 242 | }), | 248 | }), |
| 243 | ); | 249 | ); |
| 244 | }); | 250 | }); |
| @@ -1,19 +1,12 @@ | |||
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Either from "effect/Either"; | 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 | import type { | 4 | import type { |
| 13 | RuntimeAdapter, | 5 | RuntimeAdapter, |
| 14 | RuntimeExecuteError, | 6 | RuntimeExecuteError, |
| 15 | RuntimeRunnableTool, | 7 | RuntimeToolCallService, |
| 16 | } from "./runtime-adapters"; | 8 | } from "./runtime-adapters"; |
| 9 | import { RuntimeAdapterError } from "./runtime-adapters"; | ||
| 17 | 10 | ||
| 18 | export type RuntimeRunClientExecuteInput = { | 11 | export type RuntimeRunClientExecuteInput = { |
| 19 | code: string; | 12 | code: string; |
| @@ -44,8 +37,7 @@ export type InMemorySandboxToolMap = Record<string, InMemorySandboxTool>; · 24 unmodified lines | |||
| 44 | 37 | ||
| 45 | export type CreateRuntimeRunClientOptions = { | 38 | export type CreateRuntimeRunClientOptions = { |
| 46 | runtimeAdapter: RuntimeAdapter; | 39 | runtimeAdapter: RuntimeAdapter; |
| 47 | tools?: ReadonlyArray<RuntimeRunnableTool>; | 40 | toolCallService?: RuntimeToolCallService; |
| 48 | toolProviderRegistry: ToolProviderRegistry; | ||
| 49 | defaults?: { | 41 | defaults?: { |
| 50 | timeoutMs?: number; | 42 | timeoutMs?: number; |
| 51 | }; | 43 | }; |
| @@ -64,59 +56,37 @@ export type CreateInMemoryRuntimeRunClientOptions = { · 12 unmodified lines | |||
| 64 | const formatRuntimeExecuteError = (error: RuntimeExecuteError): string => | 56 | const formatRuntimeExecuteError = (error: RuntimeExecuteError): string => |
| 65 | error.details ? `${error.message}: ${error.details}` : error.message; | 57 | error.details ? `${error.message}: ${error.details}` : error.message; |
| 66 | 58 | ||
| 67 | const toInMemoryDescriptor = ( | 59 | const inMemoryToolCallService = ( |
| 68 | toolName: string, | 60 | runtimeKind: 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 = ( | ||
| 91 | tools: InMemorySandboxToolMap, | 61 | tools: InMemorySandboxToolMap, |
| 92 | ): ToolProvider => ({ | 62 | ): RuntimeToolCallService => ({ |
| 93 | kind: "in_memory", | 63 | callTool: (input) => { |
| 94 | invoke: (input) => { | 64 | const implementation = tools[input.toolPath]; |
| 95 | const implementation = tools[input.tool.toolId]; | ||
| 96 | |||
| 97 | if (!implementation) { | 65 | if (!implementation) { |
| 98 | return Effect.succeed({ | 66 | return new RuntimeAdapterError({ |
| 99 | output: `Unknown in-memory tool: ${input.tool.toolId}`, | 67 | operation: "call_tool", |
| 100 | isError: true, | 68 | runtimeKind, |
| 69 | message: `Unknown in-memory tool: ${input.toolPath}`, | ||
| 70 | details: null, | ||
| 101 | }); | 71 | }); |
| 102 | } | 72 | } |
| 103 | 73 | ||
| 104 | return Effect.tryPromise({ | 74 | if (!implementation.execute) { |
| 105 | try: async () => { | 75 | return new RuntimeAdapterError({ |
| 106 | if (!implementation.execute) { | 76 | operation: "call_tool", |
| 107 | throw new Error(`In-memory tool '${input.tool.toolId}' has no execute function`); | 77 | runtimeKind, |
| 108 | } | 78 | message: `In-memory tool '${input.toolPath}' has no execute function`, |
| 79 | details: null, | ||
| 80 | }); | ||
| 81 | } | ||
| 109 | 82 | ||
| 110 | return { | 83 | return Effect.tryPromise({ |
| 111 | output: await implementation.execute(input.args, undefined), | 84 | try: () => implementation.execute!(input.input ?? {}, undefined), |
| 112 | isError: false, | ||
| 113 | }; | ||
| 114 | }, | ||
| 115 | catch: (cause) => | 85 | catch: (cause) => |
| 116 | new ToolProviderError({ | 86 | new RuntimeAdapterError({ |
| 117 | operation: "invoke", | 87 | operation: "call_tool", |
| 118 | providerKind: "in_memory", | 88 | runtimeKind, |
| 119 | message: `In-memory tool invocation failed: ${input.tool.toolId}`, | 89 | message: `In-memory tool invocation failed: ${input.toolPath}`, |
| 120 | details: String(cause), | 90 | details: String(cause), |
| 121 | }), | 91 | }), |
| 122 | }); | 92 | }); |
| @@ -156,18 +126,12 @@ export const createRuntimeRunClient = ( · 33 unmodified lines | |||
| 156 | 126 | ||
| 157 | const executionResult = await Effect.runPromise( | 127 | const executionResult = await Effect.runPromise( |
| 158 | Effect.either( | 128 | Effect.either( |
| 159 | options.runtimeAdapter | 129 | options.runtimeAdapter.execute({ |
| 160 | .execute({ | 130 | runId, |
| 161 | code: input.code, | 131 | code: input.code, |
| 162 | timeoutMs: input.timeoutMs ?? options.defaults?.timeoutMs, | 132 | timeoutMs: input.timeoutMs ?? options.defaults?.timeoutMs, |
| 163 | tools: options.tools ?? [], | 133 | toolCallService: options.toolCallService, |
| 164 | }) | 134 | }), |
| 165 | .pipe( | ||
| 166 | Effect.provideService( | ||
| 167 | ToolProviderRegistryService, | ||
| 168 | options.toolProviderRegistry, | ||
| 169 | ), | ||
| 170 | ), | ||
| 171 | ), | 135 | ), |
| 172 | ); | 136 | ); |
| 173 | 137 | ||
| @@ -191,14 +155,9 @@ export const createRuntimeRunClient = ( · 17 unmodified lines | |||
| 191 | export const createInMemoryRuntimeRunClient = ( | 155 | export const createInMemoryRuntimeRunClient = ( |
| 192 | options: CreateInMemoryRuntimeRunClientOptions, | 156 | options: CreateInMemoryRuntimeRunClientOptions, |
| 193 | ): RuntimeRunClient => { | 157 | ): RuntimeRunClient => { |
| 194 | const toolProviderRegistry = makeToolProviderRegistry([ | ||
| 195 | makeInMemoryToolProvider(options.tools), | ||
| 196 | ]); | ||
| 197 | |||
| 198 | return createRuntimeRunClient({ | 158 | return createRuntimeRunClient({ |
| 199 | runtimeAdapter: options.runtimeAdapter, | 159 | runtimeAdapter: options.runtimeAdapter, |
| 200 | tools: makeInMemoryRuntimeTools(options.tools), | 160 | toolCallService: inMemoryToolCallService(options.runtimeAdapter.kind, options.tools), |
| 201 | toolProviderRegistry, | ||
| 202 | defaults: options.defaults, | 161 | defaults: options.defaults, |
| 203 | makeRunId: options.makeRunId, | 162 | makeRunId: options.makeRunId, |
| 204 | }); | 163 | }); |
| @@ -16,7 +16,10 @@ export const executeRun = ( · 15 unmodified lines | |||
| 16 | Effect.gen(function* () { | 16 | Effect.gen(function* () { |
| 17 | const runId = options.makeRunId?.() ?? `run_${crypto.randomUUID()}`; | 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 | if (Either.isLeft(runtimeResult)) { | 23 | if (Either.isLeft(runtimeResult)) { |
| 21 | const error = runtimeResult.left; | 24 | const error = runtimeResult.left; |
| 22 | return { | 25 | return { |
| @@ -1,67 +1,40 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { | ||
| 3 | CanonicalToolDescriptorSchema, | ||
| 4 | type CanonicalToolDescriptor, | ||
| 5 | } from "@executor-v2/schema"; | ||
| 6 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 7 | import * as Either from "effect/Either"; | 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 | import { | 7 | import { |
| 11 | makeRuntimeAdapterRegistry, | 8 | makeRuntimeAdapterRegistry, |
| 12 | RuntimeAdapterError, | 9 | RuntimeAdapterError, |
| 13 | } from "./runtime-adapters"; | 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 | describe("runtime adapters", () => { | 12 | describe("runtime adapters", () => { |
| 39 | it.effect("executes with local-inproc adapter via runtime registry", () => | 13 | it.effect("executes with local-inproc adapter via runtime registry", () => |
| 40 | Effect.gen(function* () { | 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 | const runtimeRegistry = makeRuntimeAdapterRegistry([ | 15 | const runtimeRegistry = makeRuntimeAdapterRegistry([ |
| 55 | makeLocalInProcessRuntimeAdapter(), | 16 | makeLocalInProcessRuntimeAdapter(), |
| 56 | ]); | 17 | ]); |
| 57 | 18 | ||
| 58 | const result = yield* runtimeRegistry | 19 | const result = yield* runtimeRegistry.execute({ |
| 59 | .execute({ | 20 | runtimeKind: "local-inproc", |
| 60 | runtimeKind: "local-inproc", | 21 | runId: "run_test_1", |
| 61 | code: "return await tools.sum({ a: 2, b: 4 });", | 22 | code: "return await tools.sum({ a: 2, b: 4 });", |
| 62 | tools: [{ descriptor: sumToolDescriptor, source: null }], | 23 | toolCallService: { |
| 63 | }) | 24 | callTool: (input) => |
| 64 | .pipe(Effect.provideService(ToolProviderRegistryService, toolRegistry)); | 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 | expect(result).toBe(6); | 39 | expect(result).toBe(6); |
| 67 | }), | 40 | }), |
| @@ -69,19 +42,16 @@ describe("runtime adapters", () => { · 1 unmodified line | |||
| 69 | 42 | ||
| 70 | it.effect("returns typed not-implemented error for cloudflare adapter", () => | 43 | it.effect("returns typed not-implemented error for cloudflare adapter", () => |
| 71 | Effect.gen(function* () { | 44 | Effect.gen(function* () { |
| 72 | const toolRegistry = makeToolProviderRegistry([]); | ||
| 73 | const runtimeRegistry = makeRuntimeAdapterRegistry([ | 45 | const runtimeRegistry = makeRuntimeAdapterRegistry([ |
| 74 | makeCloudflareWorkerLoaderRuntimeAdapter(), | 46 | makeCloudflareWorkerLoaderRuntimeAdapter(), |
| 75 | ]); | 47 | ]); |
| 76 | 48 | ||
| 77 | const result = yield* Effect.either( | 49 | const result = yield* Effect.either( |
| 78 | runtimeRegistry | 50 | runtimeRegistry.execute({ |
| 79 | .execute({ | 51 | runtimeKind: "cloudflare-worker-loader", |
| 80 | runtimeKind: "cloudflare-worker-loader", | 52 | runId: "run_test_2", |
| 81 | code: "return 1;", | 53 | code: "return 1;", |
| 82 | tools: [], | 54 | }), |
| 83 | }) | ||
| 84 | .pipe(Effect.provideService(ToolProviderRegistryService, toolRegistry)), | ||
| 85 | ); | 55 | ); |
| 86 | 56 | ||
| 87 | expect(Either.isLeft(result)).toBe(true); | 57 | expect(Either.isLeft(result)).toBe(true); |
| @@ -5,10 +5,7 @@ import * as Layer from "effect/Layer"; · 4 unmodified lines | |||
| 5 | 5 | ||
| 6 | import type { Source } from "@executor-v2/schema"; | 6 | import type { Source } from "@executor-v2/schema"; |
| 7 | 7 | ||
| 8 | import type { | 8 | import type { CanonicalToolDescriptor } from "./tool-providers"; |
| 9 | CanonicalToolDescriptor, | ||
| 10 | ToolProviderRegistryService, | ||
| 11 | } from "./tool-providers"; | ||
| 12 | 9 | ||
| 13 | export type RuntimeAdapterKind = string; | 10 | export type RuntimeAdapterKind = string; |
| 14 | 11 | ||
| @@ -17,9 +14,19 @@ export type RuntimeRunnableTool = { · 2 unmodified lines | |||
| 17 | source: Source | null; | 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 | export type RuntimeExecuteInput = { | 26 | export type RuntimeExecuteInput = { |
| 27 | runId: string; | ||
| 21 | code: string; | 28 | code: string; |
| 22 | tools: ReadonlyArray<RuntimeRunnableTool>; | 29 | toolCallService?: RuntimeToolCallService; |
| 23 | timeoutMs?: number; | 30 | timeoutMs?: number; |
| 24 | }; | 31 | }; |
| 25 | 32 | ||
| @@ -37,7 +44,7 @@ export interface RuntimeAdapter { · 11 unmodified lines | |||
| 37 | readonly isAvailable: () => Effect.Effect<boolean>; | 44 | readonly isAvailable: () => Effect.Effect<boolean>; |
| 38 | readonly execute: ( | 45 | readonly execute: ( |
| 39 | input: RuntimeExecuteInput, | 46 | input: RuntimeExecuteInput, |
| 40 | ) => Effect.Effect<unknown, RuntimeExecuteError, ToolProviderRegistryService>; | 47 | ) => Effect.Effect<unknown, RuntimeExecuteError>; |
| 41 | } | 48 | } |
| 42 | 49 | ||
| 43 | export class RuntimeAdapterRegistryError extends Data.TaggedError( | 50 | export class RuntimeAdapterRegistryError extends Data.TaggedError( |
| @@ -63,7 +70,7 @@ export interface RuntimeAdapterRegistry { · 19 unmodified lines | |||
| 63 | 70 | ||
| 64 | readonly execute: ( | 71 | readonly execute: ( |
| 65 | input: RuntimeExecuteInput & { runtimeKind: RuntimeAdapterKind }, | 72 | input: RuntimeExecuteInput & { runtimeKind: RuntimeAdapterKind }, |
| 66 | ) => Effect.Effect<unknown, RuntimeExecuteError | RuntimeAdapterRegistryError, ToolProviderRegistryService>; | 73 | ) => Effect.Effect<unknown, RuntimeExecuteError | RuntimeAdapterRegistryError>; |
| 67 | } | 74 | } |
| 68 | 75 | ||
| 69 | export class RuntimeAdapterRegistryService extends Context.Tag( | 76 | export class RuntimeAdapterRegistryService extends Context.Tag( |
| @@ -124,11 +131,7 @@ export const makeRuntimeAdapterRegistry = ( · 54 unmodified lines | |||
| 124 | 131 | ||
| 125 | const execute = ( | 132 | const execute = ( |
| 126 | input: RuntimeExecuteInput & { runtimeKind: RuntimeAdapterKind }, | 133 | input: RuntimeExecuteInput & { runtimeKind: RuntimeAdapterKind }, |
| 127 | ): Effect.Effect< | 134 | ): Effect.Effect<unknown, RuntimeExecuteError | RuntimeAdapterRegistryError> => |
| 128 | unknown, | ||
| 129 | RuntimeExecuteError | RuntimeAdapterRegistryError, | ||
| 130 | ToolProviderRegistryService | ||
| 131 | > => | ||
| 132 | Effect.gen(function* () { | 135 | Effect.gen(function* () { |
| 133 | const adapter = yield* get(input.runtimeKind); | 136 | const adapter = yield* get(input.runtimeKind); |
| 134 | return yield* adapter.execute(input); | 137 | return yield* adapter.execute(input); |
| @@ -10,6 +10,10 @@ export class RuntimeExecutionPortError extends Data.TaggedError( · 9 unmodified lines | |||
| 10 | details: string | null; | 10 | details: string | null; |
| 11 | }> {} | 11 | }> {} |
| 12 | 12 | ||
| 13 | export type ExecuteRuntimeRunInput = ExecuteRunInput & { | ||
| 14 | runId: string; | ||
| 15 | }; | ||
| 16 | |||
| 13 | export type ExecuteRuntimeRun = ( | 17 | export type ExecuteRuntimeRun = ( |
| 14 | input: ExecuteRunInput, | 18 | input: ExecuteRuntimeRunInput, |
| 15 | ) => Effect.Effect<unknown, RuntimeExecutionPortError>; | 19 | ) => Effect.Effect<unknown, RuntimeExecutionPortError>; |
| @@ -17,7 +17,7 @@ const toErrorMessage = (error) => { · 16 unmodified lines | |||
| 17 | return String(error); | 17 | return String(error); |
| 18 | }; | 18 | }; |
| 19 | 19 | ||
| 20 | const createToolCaller = (toolId) => (args) => | 20 | const createToolCaller = (toolPath) => (args) => |
| 21 | new Promise((resolve, reject) => { | 21 | new Promise((resolve, reject) => { |
| 22 | const requestId = crypto.randomUUID(); | 22 | const requestId = crypto.randomUUID(); |
| 23 | pendingToolCalls.set(requestId, { resolve, reject }); | 23 | pendingToolCalls.set(requestId, { resolve, reject }); |
| @@ -25,16 +25,33 @@ const createToolCaller = (toolId) => (args) => · 1 unmodified line | |||
| 25 | writeMessage({ | 25 | writeMessage({ |
| 26 | type: "tool_call", | 26 | type: "tool_call", |
| 27 | requestId, | 27 | requestId, |
| 28 | toolId, | 28 | toolPath, |
| 29 | args: args === undefined ? {} : args, | 29 | args: args === undefined ? {} : args, |
| 30 | }); | 30 | }); |
| 31 | }); | 31 | }); |
| 32 | 32 | ||
| 33 | const runUserCode = async (code, toolIds) => { | 33 | const createToolsProxy = (path = []) => { |
| 34 | const tools = Object.create(null); | 34 | const callable = () => undefined; |
| 35 | for (const toolId of toolIds) { | 35 | |
| 36 | tools[toolId] = createToolCaller(toolId); | 36 | return new Proxy(callable, { |
| 37 | } | 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 | const execute = new Function( | 56 | const execute = new Function( |
| 40 | "tools", | 57 | "tools", |
| @@ -55,7 +72,7 @@ const handleStart = (message) => { · 14 unmodified lines | |||
| 55 | 72 | ||
| 56 | started = true; | 73 | started = true; |
| 57 | 74 | ||
| 58 | runUserCode(message.code, message.toolIds) | 75 | runUserCode(message.code) |
| 59 | .then((result) => { | 76 | .then((result) => { |
| 60 | writeMessage({ | 77 | writeMessage({ |
| 61 | type: "completed", | 78 | type: "completed", |
| @@ -8,8 +8,7 @@ import * as Schema from "effect/Schema"; · 7 unmodified lines | |||
| 8 | import { | 8 | import { |
| 9 | RuntimeAdapterError, | 9 | RuntimeAdapterError, |
| 10 | type RuntimeAdapter, | 10 | type RuntimeAdapter, |
| 11 | type RuntimeRunnableTool, | 11 | type RuntimeToolCallService, |
| 12 | ToolProviderRegistryService, | ||
| 13 | } from "@executor-v2/engine"; | 12 | } from "@executor-v2/engine"; |
| 14 | import { spawnDenoWorkerProcess } from "./deno-worker-process"; | 13 | import { spawnDenoWorkerProcess } from "./deno-worker-process"; |
| 15 | 14 | ||
| @@ -18,11 +17,10 @@ const runtimeKind = "deno-subprocess"; · 2 unmodified lines | |||
| 18 | export const DenoSubprocessRunnerError = RuntimeAdapterError; | 17 | export const DenoSubprocessRunnerError = RuntimeAdapterError; |
| 19 | export type DenoSubprocessRunnerError = RuntimeAdapterError; | 18 | export type DenoSubprocessRunnerError = RuntimeAdapterError; |
| 20 | 19 | ||
| 21 | export type DenoRunnableTool = RuntimeRunnableTool; | ||
| 22 | |||
| 23 | export type ExecuteJavaScriptInDenoInput = { | 20 | export type ExecuteJavaScriptInDenoInput = { |
| 21 | runId: string; | ||
| 24 | code: string; | 22 | code: string; |
| 25 | tools: ReadonlyArray<RuntimeRunnableTool>; | 23 | toolCallService?: RuntimeToolCallService; |
| 26 | timeoutMs?: number; | 24 | timeoutMs?: number; |
| 27 | denoExecutable?: string; | 25 | denoExecutable?: string; |
| 28 | }; | 26 | }; |
| @@ -37,7 +35,6 @@ const IPC_PREFIX = "@@engine-ipc@@"; · 8 unmodified lines | |||
| 37 | const HostStartMessageSchema = Schema.Struct({ | 35 | const HostStartMessageSchema = Schema.Struct({ |
| 38 | type: Schema.Literal("start"), | 36 | type: Schema.Literal("start"), |
| 39 | code: Schema.String, | 37 | code: Schema.String, |
| 40 | toolIds: Schema.Array(Schema.String), | ||
| 41 | }); | 38 | }); |
| 42 | 39 | ||
| 43 | const HostToolResultMessageSchema = Schema.Struct({ | 40 | const HostToolResultMessageSchema = Schema.Struct({ |
| @@ -56,7 +53,7 @@ const HostToWorkerMessageSchema = Schema.Union( · 12 unmodified lines | |||
| 56 | const WorkerToolCallMessageSchema = Schema.Struct({ | 53 | const WorkerToolCallMessageSchema = Schema.Struct({ |
| 57 | type: Schema.Literal("tool_call"), | 54 | type: Schema.Literal("tool_call"), |
| 58 | requestId: Schema.String, | 55 | requestId: Schema.String, |
| 59 | toolId: Schema.String, | 56 | toolPath: Schema.String, |
| 60 | args: Schema.Unknown, | 57 | args: Schema.Unknown, |
| 61 | }); | 58 | }); |
| 62 | 59 | ||
| @@ -85,27 +82,11 @@ const decodeWorkerMessageLine = Schema.decodeUnknownSync( · 22 unmodified lines | |||
| 85 | ); | 82 | ); |
| 86 | const encodeHostMessage = Schema.encodeSync(Schema.parseJson(HostToWorkerMessageSchema)); | 83 | const encodeHostMessage = Schema.encodeSync(Schema.parseJson(HostToWorkerMessageSchema)); |
| 87 | 84 | ||
| 88 | const duplicateToolIdError = (toolId: string): DenoSubprocessRunnerError => | 85 | const missingToolCallServiceError = (toolPath: 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 => | ||
| 97 | new DenoSubprocessRunnerError({ | 86 | new DenoSubprocessRunnerError({ |
| 98 | operation: "invoke_tool", | 87 | operation: "call_tool", |
| 99 | runtimeKind, | 88 | runtimeKind, |
| 100 | message: `Worker requested unknown tool id: ${toolId}`, | 89 | message: `No tool call service configured for path: ${toolPath}`, |
| 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}`, | ||
| 109 | details: null, | 90 | details: null, |
| 110 | }); | 91 | }); |
| 111 | 92 | ||
| @@ -132,23 +113,6 @@ const workerProcessError = ( · 20 unmodified lines | |||
| 132 | details, | 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 | const defaultDenoExecutable = (): string => { | 116 | const defaultDenoExecutable = (): string => { |
| 153 | const configured = process.env.DENO_BIN?.trim(); | 117 | const configured = process.env.DENO_BIN?.trim(); |
| 154 | if (configured) { | 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 | const handleToolCall = ( | 189 | const handleToolCall = ( |
| 190 | runId: string, | ||
| 190 | message: WorkerToolCallMessage, | 191 | message: WorkerToolCallMessage, |
| 191 | toolBindings: ReadonlyMap<string, DenoRunnableTool>, | 192 | toolCallService: RuntimeToolCallService | undefined, |
| 192 | runPromise: <A, E>(effect: Effect.Effect<A, E, never>) => Promise<A>, | 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 | stdin: NodeJS.WritableStream, | 194 | stdin: NodeJS.WritableStream, |
| 199 | ): Effect.Effect<void, DenoSubprocessRunnerError> => | 195 | ): Effect.Effect<void, DenoSubprocessRunnerError> => |
| 200 | Effect.gen(function* () { | 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 | const invokeResult = yield* Effect.tryPromise({ | 197 | const invokeResult = yield* Effect.tryPromise({ |
| 212 | try: () => | 198 | try: () => runPromise(Effect.either(callTool(runId, message, toolCallService))), |
| 213 | runPromise( | ||
| 214 | invokeTool({ | ||
| 215 | source: binding.source, | ||
| 216 | tool: binding.descriptor, | ||
| 217 | args: message.args, | ||
| 218 | }), | ||
| 219 | ), | ||
| 220 | catch: (cause) => | 199 | catch: (cause) => |
| 221 | workerProcessError( | 200 | workerProcessError( |
| 222 | "invoke_tool", | 201 | "call_tool", |
| 223 | "Tool invocation threw while handling worker tool_call", | 202 | "Tool invocation threw while handling worker tool_call", |
| 224 | String(cause), | 203 | String(cause), |
| 225 | ), | 204 | ), |
| 226 | }); | 205 | }); |
| 227 | 206 | ||
| 228 | if (invokeResult.isError) { | 207 | if (invokeResult._tag === "Left") { |
| 229 | yield* writeMessage(stdin, { | 208 | yield* writeMessage(stdin, { |
| 230 | type: "tool_result", | 209 | type: "tool_result", |
| 231 | requestId: message.requestId, | 210 | requestId: message.requestId, |
| 232 | ok: false, | 211 | ok: false, |
| 233 | error: toolCallFailedError(message.toolId).message, | 212 | error: invokeResult.left.message, |
| 234 | }); | 213 | }); |
| 235 | return; | 214 | return; |
| 236 | } | 215 | } |
| @@ -239,7 +218,7 @@ const handleToolCall = ( · 2 unmodified lines | |||
| 239 | type: "tool_result", | 218 | type: "tool_result", |
| 240 | requestId: message.requestId, | 219 | requestId: message.requestId, |
| 241 | ok: true, | 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 | export const executeJavaScriptInDenoSubprocess = ( | 229 | export const executeJavaScriptInDenoSubprocess = ( |
| 251 | input: ExecuteJavaScriptInDenoInput, | 230 | input: ExecuteJavaScriptInDenoInput, |
| 252 | ): Effect.Effect<unknown, DenoSubprocessRunnerError, ToolProviderRegistryService> => | 231 | ): Effect.Effect<unknown, DenoSubprocessRunnerError> => |
| 253 | Effect.gen(function* () { | 232 | Effect.gen(function* () { |
| 254 | const registry = yield* ToolProviderRegistryService; | ||
| 255 | const runtime = yield* Effect.runtime<never>(); | 233 | const runtime = yield* Effect.runtime<never>(); |
| 256 | const runPromise = Runtime.runPromise(runtime); | 234 | const runPromise = Runtime.runPromise(runtime); |
| 257 | const toolBindings = yield* buildToolBindings(input.tools); | ||
| 258 | const toolIds = Array.from(toolBindings.keys()).sort(); | ||
| 259 | const denoExecutable = input.denoExecutable ?? defaultDenoExecutable(); | 235 | const denoExecutable = input.denoExecutable ?? defaultDenoExecutable(); |
| 260 | const timeoutMs = Math.max(100, input.timeoutMs ?? 30_000); | 236 | const timeoutMs = Math.max(100, input.timeoutMs ?? 30_000); |
| 237 | |||
| 261 | return yield* Effect.tryPromise({ | 238 | return yield* Effect.tryPromise({ |
| 262 | try: () => | 239 | try: () => |
| 263 | new Promise<unknown>((resolve, reject) => { | 240 | new Promise<unknown>((resolve, reject) => { |
| 264 | let settled = false; | 241 | let settled = false; |
| 265 | let stderrBuffer = ""; | 242 | let stderrBuffer = ""; |
| 266 | let worker: ReturnType<typeof spawnDenoWorkerProcess> | null = null; | 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 | if (settled) { | 248 | if (settled) { |
| 269 | return; | 249 | return; |
| 270 | } | 250 | } |
| 251 | |||
| 271 | settled = true; | 252 | settled = true; |
| 272 | clearTimeout(timeout); | 253 | clearTimeout(timeout); |
| 273 | worker?.dispose(); | 254 | worker?.dispose(); |
| 255 | |||
| 274 | if (result.ok) { | 256 | if (result.ok) { |
| 275 | resolve(result.value); | 257 | resolve(result.value); |
| 276 | } else { | 258 | return; |
| 277 | reject(result.error); | ||
| 278 | } | 259 | } |
| 260 | |||
| 261 | reject(result.error); | ||
| 279 | }; | 262 | }; |
| 263 | |||
| 280 | const fail = (error: DenoSubprocessRunnerError) => { | 264 | const fail = (error: DenoSubprocessRunnerError) => { |
| 281 | finish({ ok: false, error }); | 265 | finish({ ok: false, error }); |
| 282 | }; | 266 | }; |
| 267 | |||
| 283 | const timeout = setTimeout(() => { | 268 | const timeout = setTimeout(() => { |
| 284 | fail( | 269 | fail( |
| 285 | workerProcessError( | 270 | workerProcessError( |
| @@ -289,6 +274,7 @@ export const executeJavaScriptInDenoSubprocess = ( · 3 unmodified lines | |||
| 289 | ), | 274 | ), |
| 290 | ); | 275 | ); |
| 291 | }, timeoutMs); | 276 | }, timeoutMs); |
| 277 | |||
| 292 | const handleStdoutLine = (rawLine: string) => { | 278 | const handleStdoutLine = (rawLine: string) => { |
| 293 | const line = rawLine.trim(); | 279 | const line = rawLine.trim(); |
| 294 | if (line.length === 0 || !line.startsWith(IPC_PREFIX)) { | 280 | if (line.length === 0 || !line.startsWith(IPC_PREFIX)) { |
| @@ -303,11 +289,12 @@ export const executeJavaScriptInDenoSubprocess = ( · 8 unmodified lines | |||
| 303 | fail(parseWorkerMessageError(payload, cause)); | 289 | fail(parseWorkerMessageError(payload, cause)); |
| 304 | return; | 290 | return; |
| 305 | } | 291 | } |
| 292 | |||
| 306 | if (message.type === "tool_call") { | 293 | if (message.type === "tool_call") { |
| 307 | if (!worker) { | 294 | if (!worker) { |
| 308 | fail( | 295 | fail( |
| 309 | workerProcessError( | 296 | workerProcessError( |
| 310 | "invoke_tool", | 297 | "call_tool", |
| 311 | "Deno subprocess unavailable while handling worker tool_call", | 298 | "Deno subprocess unavailable while handling worker tool_call", |
| 312 | null, | 299 | null, |
| 313 | ), | 300 | ), |
| @@ -317,10 +304,10 @@ export const executeJavaScriptInDenoSubprocess = ( · 3 unmodified lines | |||
| 317 | 304 | ||
| 318 | runPromise( | 305 | runPromise( |
| 319 | handleToolCall( | 306 | handleToolCall( |
| 307 | input.runId, | ||
| 320 | message, | 308 | message, |
| 321 | toolBindings, | 309 | input.toolCallService, |
| 322 | runPromise, | 310 | runPromise, |
| 323 | (invokeInput) => registry.invoke(invokeInput), | ||
| 324 | worker.stdin, | 311 | worker.stdin, |
| 325 | ), | 312 | ), |
| 326 | ).catch((cause) => { | 313 | ).catch((cause) => { |
| @@ -336,6 +323,7 @@ export const executeJavaScriptInDenoSubprocess = ( · 9 unmodified lines | |||
| 336 | }); | 323 | }); |
| 337 | return; | 324 | return; |
| 338 | } | 325 | } |
| 326 | |||
| 339 | if (message.type === "completed") { | 327 | if (message.type === "completed") { |
| 340 | finish({ ok: true, value: message.result }); | 328 | finish({ ok: true, value: message.result }); |
| 341 | return; | 329 | return; |
| @@ -400,7 +388,6 @@ export const executeJavaScriptInDenoSubprocess = ( · 58 unmodified lines | |||
| 400 | writeMessage(worker.stdin, { | 388 | writeMessage(worker.stdin, { |
| 401 | type: "start", | 389 | type: "start", |
| 402 | code: input.code, | 390 | code: input.code, |
| 403 | toolIds, | ||
| 404 | }), | 391 | }), |
| 405 | ).catch((cause) => { | 392 | ).catch((cause) => { |
| 406 | fail( | 393 | fail( |
| @@ -432,11 +419,10 @@ export const makeDenoSubprocessRuntimeAdapter = ( · 25 unmodified lines | |||
| 432 | isAvailable: () => Effect.succeed(true), | 419 | isAvailable: () => Effect.succeed(true), |
| 433 | execute: (input) => | 420 | execute: (input) => |
| 434 | executeJavaScriptInDenoSubprocess({ | 421 | executeJavaScriptInDenoSubprocess({ |
| 422 | runId: input.runId, | ||
| 435 | code: input.code, | 423 | code: input.code, |
| 436 | tools: input.tools, | 424 | toolCallService: input.toolCallService, |
| 437 | timeoutMs: input.timeoutMs ?? options.defaultTimeoutMs, | 425 | timeoutMs: input.timeoutMs ?? options.defaultTimeoutMs, |
| 438 | denoExecutable: options.denoExecutable, | 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 | import { | 4 | import { |
| 5 | RuntimeAdapterError, | 5 | RuntimeAdapterError, |
| 6 | type RuntimeAdapter, | 6 | type RuntimeAdapter, |
| 7 | type RuntimeRunnableTool, | 7 | type RuntimeToolCallService, |
| 8 | ToolProviderRegistryService, | ||
| 9 | } from "@executor-v2/engine"; | 8 | } from "@executor-v2/engine"; |
| 10 | 9 | ||
| 11 | const runtimeKind = "local-inproc"; | 10 | const runtimeKind = "local-inproc"; |
| 12 | 11 | ||
| 13 | export type ExecuteJavaScriptInput = { | 12 | export type ExecuteJavaScriptInput = { |
| 13 | runId: string; | ||
| 14 | code: string; | 14 | code: string; |
| 15 | tools: ReadonlyArray<RuntimeRunnableTool>; | 15 | toolCallService?: RuntimeToolCallService; |
| 16 | }; | 16 | }; |
| 17 | 17 | ||
| 18 | const runtimeError = ( | 18 | const runtimeError = ( |
| @@ -27,17 +27,12 @@ const runtimeError = ( · 8 unmodified lines | |||
| 27 | details, | 27 | details, |
| 28 | }); | 28 | }); |
| 29 | 29 | ||
| 30 | const toRuntimeAdapterError = (error: { | 30 | const missingToolCallServiceError = (toolPath: string): RuntimeAdapterError => |
| 31 | operation: string; | 31 | runtimeError( |
| 32 | message: string; | 32 | "call_tool", |
| 33 | details?: string | null; | 33 | `No tool call service configured for tool path: ${toolPath}`, |
| 34 | }): RuntimeAdapterError => runtimeError(error.operation, error.message, error.details ?? null); | 34 | null, |
| 35 | 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); | ||
| 41 | 36 | ||
| 42 | const toExecutionError = (cause: unknown): RuntimeAdapterError => | 37 | const toExecutionError = (cause: unknown): RuntimeAdapterError => |
| 43 | cause instanceof RuntimeAdapterError | 38 | cause instanceof RuntimeAdapterError |
| @@ -48,72 +43,94 @@ const toExecutionError = (cause: unknown): RuntimeAdapterError => · 4 unmodified lines | |||
| 48 | cause instanceof Error ? cause.stack ?? cause.message : String(cause), | 43 | cause instanceof Error ? cause.stack ?? cause.message : String(cause), |
| 49 | ); | 44 | ); |
| 50 | 45 | ||
| 51 | const buildToolBindings = ( | 46 | const normalizeToolInput = (args: unknown): Record<string, unknown> | undefined => { |
| 52 | tools: ReadonlyArray<RuntimeRunnableTool>, | 47 | if (!args || typeof args !== "object" || Array.isArray(args)) { |
| 53 | ): Effect.Effect<Map<string, RuntimeRunnableTool>, RuntimeAdapterError> => | 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 | Effect.gen(function* () { | 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) { | 65 | return yield* toolCallService.callTool({ |
| 58 | const toolId = tool.descriptor.toolId; | 66 | runId, |
| 59 | if (byToolId.has(toolId)) { | 67 | callId: `call_${crypto.randomUUID()}`, |
| 60 | return yield* duplicateToolIdError(toolId); | 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 | const runJavaScript = ( | 105 | const runJavaScript = ( |
| 69 | code: string, | 106 | code: string, |
| 70 | toolsObject: Record<string, (args: unknown) => Promise<unknown>>, | 107 | tools: unknown, |
| 71 | ): Effect.Effect<unknown, RuntimeAdapterError> => | 108 | ): Effect.Effect<unknown, RuntimeAdapterError> => |
| 72 | Effect.tryPromise({ | 109 | Effect.tryPromise({ |
| 73 | try: async () => { | 110 | try: async () => { |
| 74 | const execute = new Function( | 111 | const execute = new Function( |
| 75 | "tools", | 112 | "tools", |
| 76 | `"use strict"; return (async () => {\n${code}\n})();`, | 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 | catch: toExecutionError, | 118 | catch: toExecutionError, |
| 82 | }); | 119 | }); |
| 83 | 120 | ||
| 84 | export const executeJavaScriptWithTools = ( | 121 | export const executeJavaScriptWithTools = ( |
| 85 | input: ExecuteJavaScriptInput, | 122 | input: ExecuteJavaScriptInput, |
| 86 | ): Effect.Effect<unknown, RuntimeAdapterError, ToolProviderRegistryService> => | 123 | ): Effect.Effect<unknown, RuntimeAdapterError> => |
| 87 | Effect.gen(function* () { | 124 | Effect.gen(function* () { |
| 88 | const registry = yield* ToolProviderRegistryService; | ||
| 89 | const runtime = yield* Effect.runtime<never>(); | 125 | const runtime = yield* Effect.runtime<never>(); |
| 90 | const runPromise = Runtime.runPromise(runtime); | 126 | const runPromise = Runtime.runPromise(runtime); |
| 91 | const toolBindings = yield* buildToolBindings(input.tools); | 127 | const toolsProxy = createToolsProxy( |
| 92 | 128 | input.runId, | |
| 93 | const toolsObject: Record<string, (args: unknown) => Promise<unknown>> = | 129 | runPromise, |
| 94 | Object.create(null); | 130 | input.toolCallService, |
| 95 | 131 | ); | |
| 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 | } | ||
| 115 | 132 | ||
| 116 | return yield* runJavaScript(input.code, toolsObject); | 133 | return yield* runJavaScript(input.code, toolsProxy); |
| 117 | }); | 134 | }); |
| 118 | 135 | ||
| 119 | export const makeLocalInProcessRuntimeAdapter = (): RuntimeAdapter => ({ | 136 | export const makeLocalInProcessRuntimeAdapter = (): RuntimeAdapter => ({ |
| @@ -121,9 +138,8 @@ export const makeLocalInProcessRuntimeAdapter = (): RuntimeAdapter => ({ · 1 unmodified line | |||
| 121 | isAvailable: () => Effect.succeed(true), | 138 | isAvailable: () => Effect.succeed(true), |
| 122 | execute: (input) => | 139 | execute: (input) => |
| 123 | executeJavaScriptWithTools({ | 140 | executeJavaScriptWithTools({ |
| 141 | runId: input.runId, | ||
| 124 | code: input.code, | 142 | code: input.code, |
| 125 | tools: input.tools, | 143 | toolCallService: input.toolCallService, |
| 126 | }), | 144 | }), |
| 127 | }); | 145 | }); |
| 128 | |||
| 129 | export type { RuntimeRunnableTool }; | ||