- Added callback server for handling tool calls and responses. - Implemented schema extraction for OpenAPI specifications, including typing payloads and reference hint tables. - Updated integration tests to validate new callback functionality and schema handling. - Removed obsolete test worker files and configurations.
Unified · Split
| @@ -5,11 +5,6 @@ · 4 unmodified lines | ||
| 5 | 5 | "type": "remote", |
| 6 | 6 | "url": "https://www.answeroverflow.com/mcp", |
| 7 | 7 | "enabled": true |
| 8 | − }, | |
| 9 | − "oracle": { | |
| 10 | − "type": "local", | |
| 11 | − "command": ["npx", "-y", "@steipete/oracle", "oracle-mcp"], | |
| 12 | − "enabled": true | |
| 13 | 8 | } |
| 14 | 9 | }, |
| 15 | 10 | "agent": { |
| @@ -18,6 +18,7 @@ export { · 17 unmodified lines | ||
| 18 | 18 | } from "./run-client"; |
| 19 | 19 | |
| 20 | 20 | export { |
| 21 | + createRuntimeToolCallResultHandler, | |
| 21 | 22 | createRuntimeToolCallService, |
| 22 | 23 | createStaticToolRegistry, |
| 23 | 24 | type InMemorySandboxTool, |
| @@ -226,6 +226,23 @@ describe("source tool registry", () => { · 225 unmodified lines | ||
| 226 | 226 | |
| 227 | 227 | expect(discovered.bestPath).not.toBeNull(); |
| 228 | 228 | expect(discovered.results.length).toBeGreaterThan(0); |
| 229 | + expect(discovered.results[0]?.typing).toBeUndefined(); | |
| 230 | + | |
| 231 | + const discoveredWithSchemas = yield* toolRegistry.discover({ | |
| 232 | + query: "repo", | |
| 233 | + includeSchemas: true, | |
| 234 | + }); | |
| 235 | + expect(discoveredWithSchemas.results[0]?.typing?.inputSchemaJson).toBeDefined(); | |
| 236 | + expect(discoveredWithSchemas.results[0]?.inputHint).toContain("object"); | |
| 237 | + | |
| 238 | + const compactDiscovered = yield* toolRegistry.discover({ | |
| 239 | + query: "repo", | |
| 240 | + compact: true, | |
| 241 | + includeSchemas: true, | |
| 242 | + }); | |
| 243 | + expect(compactDiscovered.results[0]?.description).toBeUndefined(); | |
| 244 | + expect(compactDiscovered.results[0]?.inputHint).toBeUndefined(); | |
| 245 | + expect(compactDiscovered.results[0]?.outputHint).toBeUndefined(); | |
| 229 | 246 | |
| 230 | 247 | const bestPath = discovered.bestPath; |
| 231 | 248 | if (!bestPath) { |
| @@ -243,10 +260,13 @@ describe("source tool registry", () => { · 11 unmodified lines | ||
| 243 | 260 | }); |
| 244 | 261 | |
| 245 | 262 | expect(invocationResult).toMatchObject({ |
| 246 | − status: 200, | |
| 247 | − body: { | |
| 248 | − full_name: "octocat/hello-world", | |
| 249 | − stargazers_count: 42, | |
| 263 | + ok: true, | |
| 264 | + value: { | |
| 265 | + status: 200, | |
| 266 | + body: { | |
| 267 | + full_name: "octocat/hello-world", | |
| 268 | + stargazers_count: 42, | |
| 269 | + }, | |
| 250 | 270 | }, |
| 251 | 271 | }); |
| 252 | 272 | |
| @@ -4,9 +4,17 @@ import { · 3 unmodified lines | ||
| 4 | 4 | type ToolArtifactStore, |
| 5 | 5 | type ToolArtifactStoreError, |
| 6 | 6 | } from "@executor-v2/persistence-ports"; |
| 7 | −import type { Source, WorkspaceId } from "@executor-v2/schema"; | |
| 7 | +import { | |
| 8 | + OpenApiToolManifestSchema, | |
| 9 | + type CanonicalToolDescriptor, | |
| 10 | + type DiscoveryTypingPayload, | |
| 11 | + type Source, | |
| 12 | + type WorkspaceId, | |
| 13 | +} from "@executor-v2/schema"; | |
| 14 | +import type { RuntimeToolCallResult } from "@executor-v2/sdk"; | |
| 8 | 15 | import * as Effect from "effect/Effect"; |
| 9 | 16 | import * as Option from "effect/Option"; |
| 17 | +import * as Schema from "effect/Schema"; | |
| 10 | 18 | |
| 11 | 19 | import { openApiToolDescriptorsFromManifest } from "./openapi-provider"; |
| 12 | 20 | import { |
| @@ -14,7 +22,6 @@ import { · 1 unmodified line | ||
| 14 | 22 | type RuntimeAdapterKind, |
| 15 | 23 | } from "./runtime-adapters"; |
| 16 | 24 | import type { |
| 17 | − CanonicalToolDescriptor, | |
| 18 | 25 | ToolProviderError, |
| 19 | 26 | ToolProviderRegistry, |
| 20 | 27 | ToolProviderRegistryError, |
| @@ -45,8 +52,18 @@ type SourceToolEntry = { · 24 unmodified lines | ||
| 45 | 52 | namespace: string; |
| 46 | 53 | source: Source; |
| 47 | 54 | descriptor: CanonicalToolDescriptor; |
| 55 | + typing?: DiscoveryTypingPayload; | |
| 56 | +}; | |
| 57 | + | |
| 58 | +type SourceToolSnapshot = { | |
| 59 | + entries: ReadonlyArray<SourceToolEntry>; | |
| 60 | + refHintTable: Record<string, string>; | |
| 48 | 61 | }; |
| 49 | 62 | |
| 63 | +const decodeOpenApiManifestJson = Schema.decodeUnknown( | |
| 64 | + Schema.parseJson(OpenApiToolManifestSchema), | |
| 65 | +); | |
| 66 | + | |
| 50 | 67 | const toRuntimeAdapterError = ( |
| 51 | 68 | operation: string, |
| 52 | 69 | message: string, |
| @@ -111,7 +128,64 @@ const sourceToolPath = ( · 58 unmodified lines | ||
| 111 | 128 | descriptor: CanonicalToolDescriptor, |
| 112 | 129 | ): string => `${sourceNamespace(source)}.${descriptor.toolId}`; |
| 113 | 130 | |
| 114 | −const scoreSummary = (summary: ToolRegistryToolSummary, query: string): number => { | |
| 131 | +const parseJson = (value: string): unknown | null => { | |
| 132 | + try { | |
| 133 | + return JSON.parse(value); | |
| 134 | + } catch { | |
| 135 | + return null; | |
| 136 | + } | |
| 137 | +}; | |
| 138 | + | |
| 139 | +const deriveHintFromSchemaJson = ( | |
| 140 | + schemaJson: string | undefined, | |
| 141 | + fallback: string, | |
| 142 | +): string => { | |
| 143 | + if (!schemaJson) { | |
| 144 | + return fallback; | |
| 145 | + } | |
| 146 | + | |
| 147 | + const schema = parseJson(schemaJson); | |
| 148 | + if (!schema || typeof schema !== "object" || Array.isArray(schema)) { | |
| 149 | + return fallback; | |
| 150 | + } | |
| 151 | + | |
| 152 | + const schemaRecord = schema as Record<string, unknown>; | |
| 153 | + const title = schemaRecord.title; | |
| 154 | + if (typeof title === "string" && title.trim().length > 0) { | |
| 155 | + return title.trim(); | |
| 156 | + } | |
| 157 | + | |
| 158 | + const type = schemaRecord.type; | |
| 159 | + if (type === "object") { | |
| 160 | + const properties = schemaRecord.properties; | |
| 161 | + if (properties && typeof properties === "object" && !Array.isArray(properties)) { | |
| 162 | + const keys = Object.keys(properties); | |
| 163 | + if (keys.length > 0) { | |
| 164 | + const shown = keys.slice(0, 3).join(", "); | |
| 165 | + return keys.length <= 3 | |
| 166 | + ? `object { ${shown} }` | |
| 167 | + : `object { ${shown}, ... }`; | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 171 | + return "object"; | |
| 172 | + } | |
| 173 | + | |
| 174 | + if (type === "array") { | |
| 175 | + return "array"; | |
| 176 | + } | |
| 177 | + | |
| 178 | + if (typeof type === "string") { | |
| 179 | + return type; | |
| 180 | + } | |
| 181 | + | |
| 182 | + return fallback; | |
| 183 | +}; | |
| 184 | + | |
| 185 | +const scoreSummary = ( | |
| 186 | + summary: ToolRegistryToolSummary, | |
| 187 | + query: string, | |
| 188 | +): number => { | |
| 115 | 189 | if (query.length === 0) { |
| 116 | 190 | return 1; |
| 117 | 191 | } |
| @@ -120,6 +194,8 @@ const scoreSummary = (summary: ToolRegistryToolSummary, query: string): number = · 2 unmodified lines | ||
| 120 | 194 | const lowerPath = summary.path.toLowerCase(); |
| 121 | 195 | const lowerSource = (summary.source ?? "").toLowerCase(); |
| 122 | 196 | const lowerDescription = (summary.description ?? "").toLowerCase(); |
| 197 | + const lowerInputHint = (summary.inputHint ?? "").toLowerCase(); | |
| 198 | + const lowerOutputHint = (summary.outputHint ?? "").toLowerCase(); | |
| 123 | 199 | |
| 124 | 200 | if (lowerPath === lowerQuery) { |
| 125 | 201 | return 100; |
| @@ -141,16 +217,71 @@ const scoreSummary = (summary: ToolRegistryToolSummary, query: string): number = · 15 unmodified lines | ||
| 141 | 217 | return 30; |
| 142 | 218 | } |
| 143 | 219 | |
| 220 | + if (lowerInputHint.includes(lowerQuery) || lowerOutputHint.includes(lowerQuery)) { | |
| 221 | + return 20; | |
| 222 | + } | |
| 223 | + | |
| 144 | 224 | return 0; |
| 145 | 225 | }; |
| 146 | 226 | |
| 147 | −const summaryFromEntry = (entry: SourceToolEntry): ToolRegistryToolSummary => ({ | |
| 227 | +const summarizeEntry = ( | |
| 228 | + entry: SourceToolEntry, | |
| 229 | + includeSchemas: boolean, | |
| 230 | + compact: boolean, | |
| 231 | +): ToolRegistryToolSummary => ({ | |
| 148 | 232 | path: entry.path, |
| 149 | 233 | source: entry.source.name, |
| 150 | 234 | approval: "auto", |
| 151 | − description: entry.descriptor.description ?? undefined, | |
| 235 | + description: compact ? undefined : entry.descriptor.description ?? undefined, | |
| 236 | + inputHint: compact | |
| 237 | + ? undefined | |
| 238 | + : deriveHintFromSchemaJson(entry.typing?.inputSchemaJson, "input"), | |
| 239 | + outputHint: compact | |
| 240 | + ? undefined | |
| 241 | + : deriveHintFromSchemaJson(entry.typing?.outputSchemaJson, "output"), | |
| 242 | + typing: includeSchemas ? entry.typing : undefined, | |
| 152 | 243 | }); |
| 153 | 244 | |
| 245 | +const collectRequestedRefKeys = ( | |
| 246 | + results: ReadonlyArray<ToolRegistryToolSummary>, | |
| 247 | +): Array<string> => { | |
| 248 | + const keys = new Set<string>(); | |
| 249 | + | |
| 250 | + for (const result of results) { | |
| 251 | + for (const key of result.typing?.refHintKeys ?? []) { | |
| 252 | + keys.add(key); | |
| 253 | + } | |
| 254 | + } | |
| 255 | + | |
| 256 | + return [...keys].sort(); | |
| 257 | +}; | |
| 258 | + | |
| 259 | +const pickRefHintTable = ( | |
| 260 | + snapshot: SourceToolSnapshot, | |
| 261 | + results: ReadonlyArray<ToolRegistryToolSummary>, | |
| 262 | + includeSchemas: boolean, | |
| 263 | +): Record<string, string> | undefined => { | |
| 264 | + if (!includeSchemas) { | |
| 265 | + return undefined; | |
| 266 | + } | |
| 267 | + | |
| 268 | + const requestedKeys = collectRequestedRefKeys(results); | |
| 269 | + if (requestedKeys.length === 0) { | |
| 270 | + return undefined; | |
| 271 | + } | |
| 272 | + | |
| 273 | + const selected: Record<string, string> = {}; | |
| 274 | + | |
| 275 | + for (const key of requestedKeys) { | |
| 276 | + const value = snapshot.refHintTable[key]; | |
| 277 | + if (value !== undefined) { | |
| 278 | + selected[key] = value; | |
| 279 | + } | |
| 280 | + } | |
| 281 | + | |
| 282 | + return Object.keys(selected).length > 0 ? selected : undefined; | |
| 283 | +}; | |
| 284 | + | |
| 154 | 285 | const normalizeToolCallInput = ( |
| 155 | 286 | input: unknown, |
| 156 | 287 | ): Record<string, unknown> => { |
| @@ -175,7 +306,7 @@ const describeOutput = (value: unknown): string => { · 18 unmodified lines | ||
| 175 | 306 | |
| 176 | 307 | const loadSourceEntries = ( |
| 177 | 308 | options: SourceToolRegistryOptions, |
| 178 | −): Effect.Effect<ReadonlyArray<SourceToolEntry>, RuntimeAdapterError> => | |
| 309 | +): Effect.Effect<SourceToolSnapshot, RuntimeAdapterError> => | |
| 179 | 310 | Effect.gen(function* () { |
| 180 | 311 | const workspaceSources = yield* options.sourceStore |
| 181 | 312 | .listByWorkspace(options.workspaceId as WorkspaceId) |
| @@ -187,62 +318,128 @@ const loadSourceEntries = ( · 5 unmodified lines | ||
| 187 | 318 | |
| 188 | 319 | const enabledSources = workspaceSources.filter((source) => source.enabled); |
| 189 | 320 | |
| 190 | − const bySource = yield* Effect.forEach(enabledSources, (source) => | |
| 321 | + const loadedPerSource = yield* Effect.forEach(enabledSources, (source) => | |
| 191 | 322 | Effect.gen(function* () { |
| 192 | − if (source.kind !== "openapi") { | |
| 193 | − return [] as Array<SourceToolEntry>; | |
| 194 | − } | |
| 323 | + const namespace = sourceNamespace(source); | |
| 195 | 324 | |
| 196 | − const artifactOption = yield* options.toolArtifactStore | |
| 197 | − .getBySource(source.workspaceId, source.id) | |
| 198 | − .pipe( | |
| 325 | + if (source.kind === "openapi") { | |
| 326 | + const artifactOption = yield* options.toolArtifactStore | |
| 327 | + .getBySource(source.workspaceId, source.id) | |
| 328 | + .pipe( | |
| 329 | + Effect.mapError((cause) => | |
| 330 | + toolArtifactStoreErrorToRuntimeAdapterError( | |
| 331 | + "get_source_artifact", | |
| 332 | + cause, | |
| 333 | + ), | |
| 334 | + ), | |
| 335 | + ); | |
| 336 | + | |
| 337 | + if (Option.isNone(artifactOption)) { | |
| 338 | + return { | |
| 339 | + entries: [] as Array<SourceToolEntry>, | |
| 340 | + refHintTable: {} as Record<string, string>, | |
| 341 | + }; | |
| 342 | + } | |
| 343 | + | |
| 344 | + const artifact = artifactOption.value; | |
| 345 | + const manifest = yield* decodeOpenApiManifestJson(artifact.manifestJson).pipe( | |
| 346 | + Effect.mapError((cause) => | |
| 347 | + toRuntimeAdapterError( | |
| 348 | + "decode_source_manifest", | |
| 349 | + "Failed to decode OpenAPI manifest JSON", | |
| 350 | + String(cause), | |
| 351 | + ), | |
| 352 | + ), | |
| 353 | + ); | |
| 354 | + | |
| 355 | + const descriptors = yield* openApiToolDescriptorsFromManifest( | |
| 356 | + source, | |
| 357 | + artifact.manifestJson, | |
| 358 | + ).pipe( | |
| 199 | 359 | Effect.mapError((cause) => |
| 200 | − toolArtifactStoreErrorToRuntimeAdapterError("get_source_artifact", cause), | |
| 360 | + toolProviderErrorToRuntimeAdapterError("decode_source_manifest", cause), | |
| 201 | 361 | ), |
| 202 | 362 | ); |
| 203 | 363 | |
| 204 | − if (Option.isNone(artifactOption)) { | |
| 205 | − return [] as Array<SourceToolEntry>; | |
| 364 | + const toolTypingById = new Map( | |
| 365 | + manifest.tools.map((tool) => [tool.toolId, tool.typing] as const), | |
| 366 | + ); | |
| 367 | + | |
| 368 | + return { | |
| 369 | + entries: descriptors.map((descriptor) => ({ | |
| 370 | + path: sourceToolPath(source, descriptor), | |
| 371 | + namespace, | |
| 372 | + source, | |
| 373 | + descriptor, | |
| 374 | + typing: toolTypingById.get(descriptor.toolId), | |
| 375 | + })), | |
| 376 | + refHintTable: manifest.refHintTable ?? {}, | |
| 377 | + }; | |
| 206 | 378 | } |
| 207 | 379 | |
| 208 | − const artifact = artifactOption.value; | |
| 209 | − const descriptors = yield* openApiToolDescriptorsFromManifest( | |
| 210 | − source, | |
| 211 | − artifact.manifestJson, | |
| 212 | − ).pipe( | |
| 213 | − Effect.mapError((cause) => | |
| 214 | − toolProviderErrorToRuntimeAdapterError( | |
| 215 | − "decode_source_manifest", | |
| 216 | − cause, | |
| 380 | + const discovered = yield* options.toolProviderRegistry | |
| 381 | + .discoverFromSource(source) | |
| 382 | + .pipe( | |
| 383 | + Effect.mapError((cause) => | |
| 384 | + cause._tag === "ToolProviderError" | |
| 385 | + ? toolProviderErrorToRuntimeAdapterError( | |
| 386 | + "discover_source_tools", | |
| 387 | + cause, | |
| 388 | + ) | |
| 389 | + : toolProviderRegistryErrorToRuntimeAdapterError( | |
| 390 | + "discover_source_tools", | |
| 391 | + cause, | |
| 392 | + ), | |
| 217 | 393 | ), |
| 218 | − ), | |
| 219 | − ); | |
| 394 | + Effect.either, | |
| 395 | + ); | |
| 220 | 396 | |
| 221 | − const namespace = sourceNamespace(source); | |
| 397 | + if (discovered._tag === "Left") { | |
| 398 | + return { | |
| 399 | + entries: [] as Array<SourceToolEntry>, | |
| 400 | + refHintTable: {} as Record<string, string>, | |
| 401 | + }; | |
| 402 | + } | |
| 222 | 403 | |
| 223 | − return descriptors.map((descriptor) => ({ | |
| 224 | − path: sourceToolPath(source, descriptor), | |
| 225 | − namespace, | |
| 226 | − source, | |
| 227 | − descriptor, | |
| 228 | − })); | |
| 404 | + return { | |
| 405 | + entries: discovered.right.tools.map((descriptor) => ({ | |
| 406 | + path: sourceToolPath(source, descriptor), | |
| 407 | + namespace, | |
| 408 | + source, | |
| 409 | + descriptor, | |
| 410 | + })), | |
| 411 | + refHintTable: {} as Record<string, string>, | |
| 412 | + }; | |
| 229 | 413 | }), |
| 230 | 414 | ); |
| 231 | 415 | |
| 232 | − return bySource.flat(); | |
| 416 | + const entries = loadedPerSource.flatMap((entry) => entry.entries); | |
| 417 | + const refHintTable: Record<string, string> = {}; | |
| 418 | + | |
| 419 | + for (const entry of loadedPerSource) { | |
| 420 | + Object.assign(refHintTable, entry.refHintTable); | |
| 421 | + } | |
| 422 | + | |
| 423 | + return { | |
| 424 | + entries, | |
| 425 | + refHintTable, | |
| 426 | + }; | |
| 233 | 427 | }); |
| 234 | 428 | |
| 235 | 429 | const discoverTools = ( |
| 236 | − entries: ReadonlyArray<SourceToolEntry>, | |
| 430 | + snapshot: SourceToolSnapshot, | |
| 237 | 431 | input: ToolRegistryDiscoverInput, |
| 238 | 432 | ): ToolRegistryDiscoverOutput => { |
| 239 | 433 | const limit = Math.max(1, Math.min(50, input.limit ?? 8)); |
| 240 | 434 | const query = (input.query ?? "").trim().toLowerCase(); |
| 241 | − | |
| 242 | − const ranked = entries | |
| 243 | − .map((entry) => ({ | |
| 244 | − summary: summaryFromEntry(entry), | |
| 245 | − score: scoreSummary(summaryFromEntry(entry), query), | |
| 435 | + const includeSchemas = input.includeSchemas === true; | |
| 436 | + const compact = input.compact === true; | |
| 437 | + | |
| 438 | + const ranked = snapshot.entries | |
| 439 | + .map((entry) => summarizeEntry(entry, includeSchemas, compact)) | |
| 440 | + .map((summary) => ({ | |
| 441 | + summary, | |
| 442 | + score: scoreSummary(summary, query), | |
| 246 | 443 | })) |
| 247 | 444 | .filter((item) => item.score > 0) |
| 248 | 445 | .sort((left, right) => right.score - left.score) |
| @@ -253,17 +450,18 @@ const discoverTools = ( · 4 unmodified lines | ||
| 253 | 450 | bestPath: ranked[0]?.path ?? null, |
| 254 | 451 | results: ranked, |
| 255 | 452 | total: ranked.length, |
| 453 | + refHintTable: pickRefHintTable(snapshot, ranked, includeSchemas), | |
| 256 | 454 | }; |
| 257 | 455 | }; |
| 258 | 456 | |
| 259 | 457 | const catalogNamespaces = ( |
| 260 | − entries: ReadonlyArray<SourceToolEntry>, | |
| 458 | + snapshot: SourceToolSnapshot, | |
| 261 | 459 | input: ToolRegistryCatalogNamespacesInput, |
| 262 | 460 | ): ToolRegistryCatalogNamespacesOutput => { |
| 263 | 461 | const limit = Math.max(1, Math.min(200, input.limit ?? 50)); |
| 264 | 462 | const grouped = new Map<string, Array<string>>(); |
| 265 | 463 | |
| 266 | − for (const entry of entries) { | |
| 464 | + for (const entry of snapshot.entries) { | |
| 267 | 465 | const paths = grouped.get(entry.namespace) ?? []; |
| 268 | 466 | paths.push(entry.path); |
| 269 | 467 | grouped.set(entry.namespace, paths); |
| @@ -286,24 +484,27 @@ const catalogNamespaces = ( · 16 unmodified lines | ||
| 286 | 484 | }; |
| 287 | 485 | |
| 288 | 486 | const catalogTools = ( |
| 289 | − entries: ReadonlyArray<SourceToolEntry>, | |
| 487 | + snapshot: SourceToolSnapshot, | |
| 290 | 488 | input: ToolRegistryCatalogToolsInput, |
| 291 | 489 | ): ToolRegistryCatalogToolsOutput => { |
| 292 | 490 | const limit = Math.max(1, Math.min(200, input.limit ?? 50)); |
| 293 | 491 | const query = (input.query ?? "").trim().toLowerCase(); |
| 294 | 492 | const namespace = (input.namespace ?? "").trim().toLowerCase(); |
| 493 | + const includeSchemas = input.includeSchemas === true; | |
| 494 | + const compact = input.compact === true; | |
| 295 | 495 | |
| 296 | − const filtered = entries | |
| 496 | + const filtered = snapshot.entries | |
| 297 | 497 | .filter((entry) => |
| 298 | 498 | namespace.length === 0 ? true : entry.namespace.toLowerCase() === namespace, |
| 299 | 499 | ) |
| 300 | − .map(summaryFromEntry) | |
| 500 | + .map((entry) => summarizeEntry(entry, includeSchemas, compact)) | |
| 301 | 501 | .filter((summary) => scoreSummary(summary, query) > 0) |
| 302 | 502 | .slice(0, limit); |
| 303 | 503 | |
| 304 | 504 | return { |
| 305 | 505 | results: filtered, |
| 306 | 506 | total: filtered.length, |
| 507 | + refHintTable: pickRefHintTable(snapshot, filtered, includeSchemas), | |
| 307 | 508 | }; |
| 308 | 509 | }; |
| 309 | 510 | |
| @@ -312,15 +513,17 @@ export const createSourceToolRegistry = ( · 2 unmodified lines | ||
| 312 | 513 | ): ToolRegistry => ({ |
| 313 | 514 | callTool: (input: ToolRegistryCallInput) => |
| 314 | 515 | Effect.gen(function* () { |
| 315 | − const entries = yield* loadSourceEntries(options); | |
| 316 | − const entry = entries.find((candidate) => candidate.path === input.toolPath); | |
| 516 | + const snapshot = yield* loadSourceEntries(options); | |
| 517 | + const entry = snapshot.entries.find( | |
| 518 | + (candidate) => candidate.path === input.toolPath, | |
| 519 | + ); | |
| 317 | 520 | |
| 318 | 521 | if (!entry) { |
| 319 | − return yield* toRuntimeAdapterError( | |
| 320 | − "call_tool", | |
| 321 | − `Unknown tool path: ${input.toolPath}`, | |
| 322 | − "Use tools.discover({ query }) or tools.catalog.tools({ namespace }) to find available tool paths.", | |
| 323 | − ); | |
| 522 | + return { | |
| 523 | + ok: false, | |
| 524 | + kind: "failed", | |
| 525 | + error: `Unknown tool path: ${input.toolPath}. Use tools.discover({ query }) or tools.catalog.tools({ namespace }) to find available tool paths.`, | |
| 526 | + } satisfies RuntimeToolCallResult; | |
| 324 | 527 | } |
| 325 | 528 | |
| 326 | 529 | const invocation = yield* options.toolProviderRegistry |
| @@ -338,28 +541,31 @@ export const createSourceToolRegistry = ( · 11 unmodified lines | ||
| 338 | 541 | ); |
| 339 | 542 | |
| 340 | 543 | if (invocation.isError) { |
| 341 | − return yield* toRuntimeAdapterError( | |
| 342 | − "call_tool", | |
| 343 | − `Tool call returned provider error: ${input.toolPath}`, | |
| 344 | − describeOutput(invocation.output), | |
| 345 | − ); | |
| 544 | + return { | |
| 545 | + ok: false, | |
| 546 | + kind: "failed", | |
| 547 | + error: describeOutput(invocation.output), | |
| 548 | + } satisfies RuntimeToolCallResult; | |
| 346 | 549 | } |
| 347 | 550 | |
| 348 | − return invocation.output; | |
| 551 | + return { | |
| 552 | + ok: true, | |
| 553 | + value: invocation.output, | |
| 554 | + } satisfies RuntimeToolCallResult; | |
| 349 | 555 | }), |
| 350 | 556 | |
| 351 | 557 | discover: (input: ToolRegistryDiscoverInput) => |
| 352 | 558 | loadSourceEntries(options).pipe( |
| 353 | − Effect.map((entries) => discoverTools(entries, input)), | |
| 559 | + Effect.map((snapshot) => discoverTools(snapshot, input)), | |
| 354 | 560 | ), |
| 355 | 561 | |
| 356 | 562 | catalogNamespaces: (input: ToolRegistryCatalogNamespacesInput) => |
| 357 | 563 | loadSourceEntries(options).pipe( |
| 358 | − Effect.map((entries) => catalogNamespaces(entries, input)), | |
| 564 | + Effect.map((snapshot) => catalogNamespaces(snapshot, input)), | |
| 359 | 565 | ), |
| 360 | 566 | |
| 361 | 567 | catalogTools: (input: ToolRegistryCatalogToolsInput) => |
| 362 | 568 | loadSourceEntries(options).pipe( |
| 363 | − Effect.map((entries) => catalogTools(entries, input)), | |
| 569 | + Effect.map((snapshot) => catalogTools(snapshot, input)), | |
| 364 | 570 | ), |
| 365 | 571 | }); |
| @@ -9,6 +9,9 @@ import { · 8 unmodified lines | ||
| 9 | 9 | describe("tool registry", () => { |
| 10 | 10 | it.effect("supports callTool plus discover and catalog", () => |
| 11 | 11 | Effect.gen(function* () { |
| 12 | + const repoRefSchemaJson = | |
| 13 | + '{"properties":{"id":{"type":"number"}},"type":"object"}'; | |
| 14 | + | |
| 12 | 15 | const registry = createStaticToolRegistry({ |
| 13 | 16 | tools: { |
| 14 | 17 | search_docs: { |
| @@ -17,11 +20,21 @@ describe("tool registry", () => { · 2 unmodified lines | ||
| 17 | 20 | }, |
| 18 | 21 | "github.repos.get": { |
| 19 | 22 | description: "Get repository", |
| 23 | + typing: { | |
| 24 | + inputSchemaJson: | |
| 25 | + '{"properties":{"owner":{"type":"string"},"repo":{"type":"string"}},"title":"GetRepoInput","type":"object"}', | |
| 26 | + outputSchemaJson: | |
| 27 | + '{"properties":{"full_name":{"type":"string"}},"type":"object"}', | |
| 28 | + refHintKeys: ["#/components/schemas/Repo"], | |
| 29 | + }, | |
| 20 | 30 | execute: (input: { owner: string; repo: string }) => ({ |
| 21 | 31 | full_name: `${input.owner}/${input.repo}`, |
| 22 | 32 | }), |
| 23 | 33 | }, |
| 24 | 34 | }, |
| 35 | + refHintTable: { | |
| 36 | + "#/components/schemas/Repo": repoRefSchemaJson, | |
| 37 | + }, | |
| 25 | 38 | }); |
| 26 | 39 | |
| 27 | 40 | const discovered = yield* registry.discover({ query: "github", limit: 5 }); |
| @@ -29,6 +42,29 @@ describe("tool registry", () => { · 1 unmodified line | ||
| 29 | 42 | expect(discovered.results.some((entry) => entry.path === "github.repos.get")).toBe( |
| 30 | 43 | true, |
| 31 | 44 | ); |
| 45 | + expect(discovered.results[0]?.typing).toBeUndefined(); | |
| 46 | + expect(discovered.results[0]?.inputHint).toBe("GetRepoInput"); | |
| 47 | + expect(discovered.refHintTable).toBeUndefined(); | |
| 48 | + | |
| 49 | + const discoveredWithSchemas = yield* registry.discover({ | |
| 50 | + query: "github", | |
| 51 | + includeSchemas: true, | |
| 52 | + }); | |
| 53 | + expect(discoveredWithSchemas.results[0]?.typing?.refHintKeys).toEqual([ | |
| 54 | + "#/components/schemas/Repo", | |
| 55 | + ]); | |
| 56 | + expect(discoveredWithSchemas.refHintTable).toEqual({ | |
| 57 | + "#/components/schemas/Repo": repoRefSchemaJson, | |
| 58 | + }); | |
| 59 | + | |
| 60 | + const compactDiscovered = yield* registry.discover({ | |
| 61 | + query: "github", | |
| 62 | + compact: true, | |
| 63 | + includeSchemas: true, | |
| 64 | + }); | |
| 65 | + expect(compactDiscovered.results[0]?.description).toBeUndefined(); | |
| 66 | + expect(compactDiscovered.results[0]?.inputHint).toBeUndefined(); | |
| 67 | + expect(compactDiscovered.results[0]?.outputHint).toBeUndefined(); | |
| 32 | 68 | |
| 33 | 69 | const namespaces = yield* registry.catalogNamespaces({}); |
| 34 | 70 | expect(namespaces.namespaces.map((namespace) => namespace.namespace)).toEqual([ |
| @@ -50,7 +86,10 @@ describe("tool registry", () => { · 15 unmodified lines | ||
| 50 | 86 | }, |
| 51 | 87 | }); |
| 52 | 88 | expect(callResult).toEqual({ |
| 53 | − full_name: "octocat/hello-world", | |
| 89 | + ok: true, | |
| 90 | + value: { | |
| 91 | + full_name: "octocat/hello-world", | |
| 92 | + }, | |
| 54 | 93 | }); |
| 55 | 94 | }), |
| 56 | 95 | ); |
| @@ -1,3 +1,8 @@ | ||
| 1 | +import type { | |
| 2 | + RuntimeToolCallRequest, | |
| 3 | + RuntimeToolCallResult, | |
| 4 | +} from "@executor-v2/sdk"; | |
| 5 | +import type { DiscoveryTypingPayload } from "@executor-v2/schema"; | |
| 1 | 6 | import * as Effect from "effect/Effect"; |
| 2 | 7 | |
| 3 | 8 | import { |
| @@ -36,12 +41,16 @@ export type ToolRegistryToolSummary = { · 32 unmodified lines | ||
| 36 | 41 | source?: string; |
| 37 | 42 | approval: "auto" | "required"; |
| 38 | 43 | description?: string; |
| 44 | + inputHint?: string; | |
| 45 | + outputHint?: string; | |
| 46 | + typing?: DiscoveryTypingPayload; | |
| 39 | 47 | }; |
| 40 | 48 | |
| 41 | 49 | export type ToolRegistryDiscoverOutput = { |
| 42 | 50 | bestPath: string | null; |
| 43 | 51 | results: Array<ToolRegistryToolSummary>; |
| 44 | 52 | total: number; |
| 53 | + refHintTable?: Record<string, string>; | |
| 45 | 54 | }; |
| 46 | 55 | |
| 47 | 56 | export type ToolRegistryCatalogNamespacesOutput = { |
| @@ -56,10 +65,13 @@ export type ToolRegistryCatalogNamespacesOutput = { · 8 unmodified lines | ||
| 56 | 65 | export type ToolRegistryCatalogToolsOutput = { |
| 57 | 66 | results: Array<ToolRegistryToolSummary>; |
| 58 | 67 | total: number; |
| 68 | + refHintTable?: Record<string, string>; | |
| 59 | 69 | }; |
| 60 | 70 | |
| 61 | 71 | export type ToolRegistry = { |
| 62 | − callTool: (input: ToolRegistryCallInput) => Effect.Effect<unknown, RuntimeAdapterError>; | |
| 72 | + callTool: ( | |
| 73 | + input: ToolRegistryCallInput, | |
| 74 | + ) => Effect.Effect<RuntimeToolCallResult, RuntimeAdapterError>; | |
| 63 | 75 | discover: ( |
| 64 | 76 | input: ToolRegistryDiscoverInput, |
| 65 | 77 | ) => Effect.Effect<ToolRegistryDiscoverOutput, RuntimeAdapterError>; |
| @@ -74,15 +86,18 @@ export type ToolRegistry = { · 8 unmodified lines | ||
| 74 | 86 | export type InMemorySandboxTool = { |
| 75 | 87 | description?: string | null; |
| 76 | 88 | execute?: (...args: Array<any>) => Promise<any> | any; |
| 89 | + typing?: DiscoveryTypingPayload; | |
| 77 | 90 | }; |
| 78 | 91 | |
| 79 | 92 | export type InMemorySandboxToolMap = Record<string, InMemorySandboxTool>; |
| 80 | 93 | |
| 81 | 94 | type StaticToolRegistryOptions = { |
| 82 | 95 | tools: InMemorySandboxToolMap; |
| 96 | + refHintTable?: Record<string, string>; | |
| 83 | 97 | }; |
| 84 | 98 | |
| 85 | 99 | const staticToolRegistryRuntimeKind = "static-tool-registry"; |
| 100 | +const runtimeToolCallServiceRuntimeKind = "tool-registry-runtime-tool-call-service"; | |
| 86 | 101 | |
| 87 | 102 | const toRuntimeAdapterError = ( |
| 88 | 103 | operation: string, |
| @@ -136,19 +151,149 @@ const normalizeCatalogToolsInput = (input: unknown): ToolRegistryCatalogToolsInp · 47 unmodified lines | ||
| 136 | 151 | }; |
| 137 | 152 | }; |
| 138 | 153 | |
| 139 | −const asToolSummaries = ( | |
| 140 | − tools: InMemorySandboxToolMap, | |
| 141 | −): Array<ToolRegistryToolSummary> => | |
| 154 | +const parseJson = (value: string): unknown | null => { | |
| 155 | + try { | |
| 156 | + return JSON.parse(value); | |
| 157 | + } catch { | |
| 158 | + return null; | |
| 159 | + } | |
| 160 | +}; | |
| 161 | + | |
| 162 | +const deriveHintFromSchemaJson = ( | |
| 163 | + schemaJson: string | undefined, | |
| 164 | + fallback: string, | |
| 165 | +): string => { | |
| 166 | + if (!schemaJson) { | |
| 167 | + return fallback; | |
| 168 | + } | |
| 169 | + | |
| 170 | + const schema = parseJson(schemaJson); | |
| 171 | + if (!schema || typeof schema !== "object" || Array.isArray(schema)) { | |
| 172 | + return fallback; | |
| 173 | + } | |
| 174 | + | |
| 175 | + const schemaRecord = schema as Record<string, unknown>; | |
| 176 | + const title = schemaRecord.title; | |
| 177 | + if (typeof title === "string" && title.trim().length > 0) { | |
| 178 | + return title.trim(); | |
| 179 | + } | |
| 180 | + | |
| 181 | + const type = schemaRecord.type; | |
| 182 | + if (type === "object") { | |
| 183 | + const properties = schemaRecord.properties; | |
| 184 | + if (properties && typeof properties === "object" && !Array.isArray(properties)) { | |
| 185 | + const keys = Object.keys(properties); | |
| 186 | + if (keys.length > 0) { | |
| 187 | + const shown = keys.slice(0, 3).join(", "); | |
| 188 | + return keys.length <= 3 | |
| 189 | + ? `object { ${shown} }` | |
| 190 | + : `object { ${shown}, ... }`; | |
| 191 | + } | |
| 192 | + } | |
| 193 | + | |
| 194 | + return "object"; | |
| 195 | + } | |
| 196 | + | |
| 197 | + if (type === "array") { | |
| 198 | + return "array"; | |
| 199 | + } | |
| 200 | + | |
| 201 | + if (typeof type === "string") { | |
| 202 | + return type; | |
| 203 | + } | |
| 204 | + | |
| 205 | + return fallback; | |
| 206 | +}; | |
| 207 | + | |
| 208 | +const includeTyping = ( | |
| 209 | + typing: DiscoveryTypingPayload | undefined, | |
| 210 | + includeSchemas: boolean, | |
| 211 | +): DiscoveryTypingPayload | undefined => { | |
| 212 | + if (!includeSchemas) { | |
| 213 | + return undefined; | |
| 214 | + } | |
| 215 | + | |
| 216 | + return typing; | |
| 217 | +}; | |
| 218 | + | |
| 219 | +const summarizeTool = ( | |
| 220 | + path: string, | |
| 221 | + source: string, | |
| 222 | + description: string | undefined, | |
| 223 | + typing: DiscoveryTypingPayload | undefined, | |
| 224 | + includeSchemas: boolean, | |
| 225 | + compact: boolean, | |
| 226 | +): ToolRegistryToolSummary => ({ | |
| 227 | + path, | |
| 228 | + source, | |
| 229 | + approval: "auto", | |
| 230 | + description: compact ? undefined : description, | |
| 231 | + inputHint: compact | |
| 232 | + ? undefined | |
| 233 | + : deriveHintFromSchemaJson(typing?.inputSchemaJson, "input"), | |
| 234 | + outputHint: compact | |
| 235 | + ? undefined | |
| 236 | + : deriveHintFromSchemaJson(typing?.outputSchemaJson, "output"), | |
| 237 | + typing: includeTyping(typing, includeSchemas), | |
| 238 | +}); | |
| 239 | + | |
| 240 | +const collectRequestedRefKeys = ( | |
| 241 | + results: ReadonlyArray<ToolRegistryToolSummary>, | |
| 242 | +): Array<string> => { | |
| 243 | + const set = new Set<string>(); | |
| 244 | + for (const result of results) { | |
| 245 | + for (const key of result.typing?.refHintKeys ?? []) { | |
| 246 | + set.add(key); | |
| 247 | + } | |
| 248 | + } | |
| 249 | + | |
| 250 | + return [...set].sort(); | |
| 251 | +}; | |
| 252 | + | |
| 253 | +const selectRefHintTable = ( | |
| 254 | + refHintTable: Record<string, string> | undefined, | |
| 255 | + results: ReadonlyArray<ToolRegistryToolSummary>, | |
| 256 | + includeSchemas: boolean, | |
| 257 | +): Record<string, string> | undefined => { | |
| 258 | + if (!includeSchemas || !refHintTable) { | |
| 259 | + return undefined; | |
| 260 | + } | |
| 261 | + | |
| 262 | + const keys = collectRequestedRefKeys(results); | |
| 263 | + if (keys.length === 0) { | |
| 264 | + return undefined; | |
| 265 | + } | |
| 266 | + | |
| 267 | + const filtered: Record<string, string> = {}; | |
| 268 | + for (const key of keys) { | |
| 269 | + const value = refHintTable[key]; | |
| 270 | + if (value !== undefined) { | |
| 271 | + filtered[key] = value; | |
| 272 | + } | |
| 273 | + } | |
| 274 | + | |
| 275 | + return Object.keys(filtered).length > 0 ? filtered : undefined; | |
| 276 | +}; | |
| 277 | + | |
| 278 | +type StaticToolEntry = { | |
| 279 | + path: string; | |
| 280 | + description: string | undefined; | |
| 281 | + typing: DiscoveryTypingPayload | undefined; | |
| 282 | +}; | |
| 283 | + | |
| 284 | +const asStaticEntries = (tools: InMemorySandboxToolMap): Array<StaticToolEntry> => | |
| 142 | 285 | Object.entries(tools) |
| 143 | 286 | .sort(([leftPath], [rightPath]) => leftPath.localeCompare(rightPath)) |
| 144 | 287 | .map(([path, tool]) => ({ |
| 145 | 288 | path, |
| 146 | − source: "in-memory", | |
| 147 | − approval: "auto" as const, | |
| 148 | 289 | description: tool.description ?? undefined, |
| 290 | + typing: tool.typing, | |
| 149 | 291 | })); |
| 150 | 292 | |
| 151 | −const scorePath = (entry: ToolRegistryToolSummary, query: string): number => { | |
| 293 | +const scorePath = ( | |
| 294 | + entry: { path: string; description?: string }, | |
| 295 | + query: string, | |
| 296 | +): number => { | |
| 152 | 297 | if (query.length === 0) { |
| 153 | 298 | return 1; |
| 154 | 299 | } |
| @@ -178,12 +323,15 @@ const scorePath = (entry: ToolRegistryToolSummary, query: string): number => { · 23 unmodified lines | ||
| 178 | 323 | |
| 179 | 324 | const inMemoryDiscover = ( |
| 180 | 325 | tools: InMemorySandboxToolMap, |
| 326 | + refHintTable: Record<string, string> | undefined, | |
| 181 | 327 | input: ToolRegistryDiscoverInput, |
| 182 | 328 | ): ToolRegistryDiscoverOutput => { |
| 183 | 329 | const limit = Math.max(1, Math.min(50, input.limit ?? 8)); |
| 184 | 330 | const query = (input.query ?? "").trim().toLowerCase(); |
| 331 | + const includeSchemas = input.includeSchemas === true; | |
| 332 | + const compact = input.compact === true; | |
| 185 | 333 | |
| 186 | − const ranked = asToolSummaries(tools) | |
| 334 | + const ranked = asStaticEntries(tools) | |
| 187 | 335 | .map((entry) => ({ |
| 188 | 336 | entry, |
| 189 | 337 | score: scorePath(entry, query), |
| @@ -191,12 +339,22 @@ const inMemoryDiscover = ( · 1 unmodified line | ||
| 191 | 339 | .filter((item) => item.score > 0) |
| 192 | 340 | .sort((left, right) => right.score - left.score) |
| 193 | 341 | .slice(0, limit) |
| 194 | − .map((item) => item.entry); | |
| 342 | + .map((item) => | |
| 343 | + summarizeTool( | |
| 344 | + item.entry.path, | |
| 345 | + "in-memory", | |
| 346 | + item.entry.description, | |
| 347 | + item.entry.typing, | |
| 348 | + includeSchemas, | |
| 349 | + compact, | |
| 350 | + ), | |
| 351 | + ); | |
| 195 | 352 | |
| 196 | 353 | return { |
| 197 | 354 | bestPath: ranked[0]?.path ?? null, |
| 198 | 355 | results: ranked, |
| 199 | 356 | total: ranked.length, |
| 357 | + refHintTable: selectRefHintTable(refHintTable, ranked, includeSchemas), | |
| 200 | 358 | }; |
| 201 | 359 | }; |
| 202 | 360 | |
| @@ -230,13 +388,16 @@ const inMemoryCatalogNamespaces = ( · 27 unmodified lines | ||
| 230 | 388 | |
| 231 | 389 | const inMemoryCatalogTools = ( |
| 232 | 390 | tools: InMemorySandboxToolMap, |
| 391 | + refHintTable: Record<string, string> | undefined, | |
| 233 | 392 | input: ToolRegistryCatalogToolsInput, |
| 234 | 393 | ): ToolRegistryCatalogToolsOutput => { |
| 235 | 394 | const limit = Math.max(1, Math.min(200, input.limit ?? 50)); |
| 236 | 395 | const query = (input.query ?? "").trim().toLowerCase(); |
| 237 | 396 | const namespace = (input.namespace ?? "").trim().toLowerCase(); |
| 397 | + const includeSchemas = input.includeSchemas === true; | |
| 398 | + const compact = input.compact === true; | |
| 238 | 399 | |
| 239 | − const filtered = asToolSummaries(tools) | |
| 400 | + const filtered = asStaticEntries(tools) | |
| 240 | 401 | .filter((entry) => { |
| 241 | 402 | if (namespace.length > 0) { |
| 242 | 403 | const entryNamespace = entry.path.split(".")[0]?.toLowerCase() ?? ""; |
| @@ -247,11 +408,22 @@ const inMemoryCatalogTools = ( · 4 unmodified lines | ||
| 247 | 408 | |
| 248 | 409 | return scorePath(entry, query) > 0; |
| 249 | 410 | }) |
| 250 | − .slice(0, limit); | |
| 411 | + .slice(0, limit) | |
| 412 | + .map((entry) => | |
| 413 | + summarizeTool( | |
| 414 | + entry.path, | |
| 415 | + "in-memory", | |
| 416 | + entry.description, | |
| 417 | + entry.typing, | |
| 418 | + includeSchemas, | |
| 419 | + compact, | |
| 420 | + ), | |
| 421 | + ); | |
| 251 | 422 | |
| 252 | 423 | return { |
| 253 | 424 | results: filtered, |
| 254 | 425 | total: filtered.length, |
| 426 | + refHintTable: selectRefHintTable(refHintTable, filtered, includeSchemas), | |
| 255 | 427 | }; |
| 256 | 428 | }; |
| 257 | 429 | |
| @@ -261,20 +433,18 @@ export const createStaticToolRegistry = ( · 3 unmodified lines | ||
| 261 | 433 | callTool: (input) => { |
| 262 | 434 | const implementation = options.tools[input.toolPath]; |
| 263 | 435 | if (!implementation) { |
| 264 | − return new RuntimeAdapterError({ | |
| 265 | − operation: "call_tool", | |
| 266 | − runtimeKind: staticToolRegistryRuntimeKind, | |
| 267 | − message: `Unknown in-memory tool: ${input.toolPath}`, | |
| 268 | − details: null, | |
| 436 | + return Effect.succeed<RuntimeToolCallResult>({ | |
| 437 | + ok: false, | |
| 438 | + kind: "failed", | |
| 439 | + error: `Unknown in-memory tool: ${input.toolPath}`, | |
| 269 | 440 | }); |
| 270 | 441 | } |
| 271 | 442 | |
| 272 | 443 | if (!implementation.execute) { |
| 273 | − return new RuntimeAdapterError({ | |
| 274 | − operation: "call_tool", | |
| 275 | − runtimeKind: staticToolRegistryRuntimeKind, | |
| 276 | − message: `In-memory tool '${input.toolPath}' has no execute function`, | |
| 277 | − details: null, | |
| 444 | + return Effect.succeed<RuntimeToolCallResult>({ | |
| 445 | + ok: false, | |
| 446 | + kind: "failed", | |
| 447 | + error: `In-memory tool '${input.toolPath}' has no execute function`, | |
| 278 | 448 | }); |
| 279 | 449 | } |
| 280 | 450 | |
| @@ -286,14 +456,63 @@ export const createStaticToolRegistry = ( · 5 unmodified lines | ||
| 286 | 456 | `In-memory tool invocation failed: ${input.toolPath}`, |
| 287 | 457 | String(cause), |
| 288 | 458 | ), |
| 289 | − }); | |
| 459 | + }).pipe( | |
| 460 | + Effect.map( | |
| 461 | + (value): RuntimeToolCallResult => ({ | |
| 462 | + ok: true, | |
| 463 | + value, | |
| 464 | + }), | |
| 465 | + ), | |
| 466 | + ); | |
| 290 | 467 | }, |
| 291 | − discover: (input) => Effect.succeed(inMemoryDiscover(options.tools, input)), | |
| 468 | + discover: (input) => | |
| 469 | + Effect.succeed(inMemoryDiscover(options.tools, options.refHintTable, input)), | |
| 292 | 470 | catalogNamespaces: (input) => |
| 293 | 471 | Effect.succeed(inMemoryCatalogNamespaces(options.tools, input)), |
| 294 | − catalogTools: (input) => Effect.succeed(inMemoryCatalogTools(options.tools, input)), | |
| 472 | + catalogTools: (input) => | |
| 473 | + Effect.succeed(inMemoryCatalogTools(options.tools, options.refHintTable, input)), | |
| 295 | 474 | }); |
| 296 | 475 | |
| 476 | +export const createRuntimeToolCallResultHandler = ( | |
| 477 | + request: RuntimeToolCallRequest, | |
| 478 | + result: RuntimeToolCallResult, | |
| 479 | +): Effect.Effect<unknown, RuntimeAdapterError> => { | |
| 480 | + if (result.ok) { | |
| 481 | + return Effect.succeed(result.value); | |
| 482 | + } | |
| 483 | + | |
| 484 | + if (result.kind === "pending") { | |
| 485 | + return Effect.fail( | |
| 486 | + new RuntimeAdapterError({ | |
| 487 | + operation: "call_tool", | |
| 488 | + runtimeKind: runtimeToolCallServiceRuntimeKind, | |
| 489 | + message: result.error ?? `Tool call requires approval: ${request.toolPath}`, | |
| 490 | + details: `approvalId=${result.approvalId} retryAfterMs=${result.retryAfterMs}`, | |
| 491 | + }), | |
| 492 | + ); | |
| 493 | + } | |
| 494 | + | |
| 495 | + if (result.kind === "denied") { | |
| 496 | + return Effect.fail( | |
| 497 | + new RuntimeAdapterError({ | |
| 498 | + operation: "call_tool", | |
| 499 | + runtimeKind: runtimeToolCallServiceRuntimeKind, | |
| 500 | + message: result.error, | |
| 501 | + details: `Tool call denied: ${request.toolPath}`, | |
| 502 | + }), | |
| 503 | + ); | |
| 504 | + } | |
| 505 | + | |
| 506 | + return Effect.fail( | |
| 507 | + new RuntimeAdapterError({ | |
| 508 | + operation: "call_tool", | |
| 509 | + runtimeKind: runtimeToolCallServiceRuntimeKind, | |
| 510 | + message: result.error, | |
| 511 | + details: `Tool call failed: ${request.toolPath}`, | |
| 512 | + }), | |
| 513 | + ); | |
| 514 | +}; | |
| 515 | + | |
| 297 | 516 | export const createRuntimeToolCallService = ( |
| 298 | 517 | toolRegistry: ToolRegistry, |
| 299 | 518 | ): RuntimeToolCallService => ({ |
| @@ -312,6 +531,8 @@ export const createRuntimeToolCallService = ( · 12 unmodified lines | ||
| 312 | 531 | return toolRegistry.catalogTools(normalizeCatalogToolsInput(input.input)); |
| 313 | 532 | } |
| 314 | 533 | |
| 315 | − return toolRegistry.callTool(input); | |
| 534 | + return toolRegistry | |
| 535 | + .callTool(input) | |
| 536 | + .pipe(Effect.flatMap((result) => createRuntimeToolCallResultHandler(input, result))); | |
| 316 | 537 | }, |
| 317 | 538 | }); |
| @@ -70,4 +70,77 @@ describe("extractOpenApiManifest invocation metadata", () => { · 69 unmodified lines | ||
| 70 | 70 | }); |
| 71 | 71 | }), |
| 72 | 72 | ); |
| 73 | + | |
| 74 | + it.effect("extracts typing payload and ref hint table from schemas", () => | |
| 75 | + Effect.gen(function* () { | |
| 76 | + const openApiSpec = { | |
| 77 | + openapi: "3.1.0", | |
| 78 | + paths: { | |
| 79 | + "/repos/{owner}/{repo}": { | |
| 80 | + get: { | |
| 81 | + operationId: "getRepo", | |
| 82 | + parameters: [ | |
| 83 | + { | |
| 84 | + name: "owner", | |
| 85 | + in: "path", | |
| 86 | + required: true, | |
| 87 | + schema: { type: "string" }, | |
| 88 | + }, | |
| 89 | + { | |
| 90 | + name: "repo", | |
| 91 | + in: "path", | |
| 92 | + required: true, | |
| 93 | + schema: { type: "string" }, | |
| 94 | + }, | |
| 95 | + ], | |
| 96 | + responses: { | |
| 97 | + "200": { | |
| 98 | + description: "ok", | |
| 99 | + content: { | |
| 100 | + "application/json": { | |
| 101 | + schema: { $ref: "#/components/schemas/Repo" }, | |
| 102 | + }, | |
| 103 | + }, | |
| 104 | + }, | |
| 105 | + }, | |
| 106 | + }, | |
| 107 | + }, | |
| 108 | + }, | |
| 109 | + components: { | |
| 110 | + schemas: { | |
| 111 | + Repo: { | |
| 112 | + type: "object", | |
| 113 | + properties: { | |
| 114 | + id: { type: "number" }, | |
| 115 | + owner: { $ref: "#/components/schemas/User" }, | |
| 116 | + }, | |
| 117 | + }, | |
| 118 | + User: { | |
| 119 | + type: "object", | |
| 120 | + properties: { | |
| 121 | + login: { type: "string" }, | |
| 122 | + }, | |
| 123 | + }, | |
| 124 | + }, | |
| 125 | + }, | |
| 126 | + }; | |
| 127 | + | |
| 128 | + const manifest = yield* extractOpenApiManifest("test-source", openApiSpec); | |
| 129 | + const tool = manifest.tools.find((candidate) => candidate.toolId === "getRepo"); | |
| 130 | + const repoSchemaJson = | |
| 131 | + '{"properties":{"id":{"type":"number"},"owner":{"$ref":"#/components/schemas/User"}},"type":"object"}'; | |
| 132 | + const userSchemaJson = | |
| 133 | + '{"properties":{"login":{"type":"string"}},"type":"object"}'; | |
| 134 | + | |
| 135 | + expect(tool?.typing?.inputSchemaJson).toBeDefined(); | |
| 136 | + expect(tool?.typing?.outputSchemaJson).toBeDefined(); | |
| 137 | + expect(tool?.typing?.refHintKeys).toEqual([ | |
| 138 | + "#/components/schemas/Repo", | |
| 139 | + ]); | |
| 140 | + expect(manifest.refHintTable).toEqual({ | |
| 141 | + "#/components/schemas/Repo": repoSchemaJson, | |
| 142 | + "#/components/schemas/User": userSchemaJson, | |
| 143 | + }); | |
| 144 | + }), | |
| 145 | + ); | |
| 73 | 146 | }); |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | OpenApiToolParameterSchema, |
| 14 | 14 | OpenApiToolRequestBodySchema, |
| 15 | 15 | ToolArtifactIdSchema, |
| 16 | + type DiscoveryTypingPayload, | |
| 16 | 17 | type OpenApiExtractedTool, |
| 17 | 18 | type OpenApiHttpMethod, |
| 18 | 19 | type OpenApiInvocationPayload, |
| @@ -56,6 +57,7 @@ type ExtractedToolRequestBody = OpenApiToolRequestBody; · 37 unmodified lines | ||
| 56 | 57 | type ExtractedToolInvocation = OpenApiInvocationPayload; |
| 57 | 58 | type ExtractedTool = OpenApiExtractedTool; |
| 58 | 59 | type ToolManifest = OpenApiToolManifest; |
| 60 | +type DiscoveryTyping = DiscoveryTypingPayload; | |
| 59 | 61 | |
| 60 | 62 | const ToolManifestFromJsonSchema = Schema.parseJson(ToolManifestSchema); |
| 61 | 63 | const encodeManifestToJson = Schema.encode(ToolManifestFromJsonSchema); |
| @@ -195,6 +197,266 @@ const buildInvocationMetadata = ( · 133 unmodified lines | ||
| 195 | 197 | requestBody: extractRequestBody(operation), |
| 196 | 198 | }); |
| 197 | 199 | |
| 200 | +const collectRefKeys = (value: unknown, refs: Set<string>): void => { | |
| 201 | + if (Array.isArray(value)) { | |
| 202 | + for (const item of value) { | |
| 203 | + collectRefKeys(item, refs); | |
| 204 | + } | |
| 205 | + return; | |
| 206 | + } | |
| 207 | + | |
| 208 | + if (!isUnknownRecord(value)) { | |
| 209 | + return; | |
| 210 | + } | |
| 211 | + | |
| 212 | + const reference = value.$ref; | |
| 213 | + if (typeof reference === "string" && reference.startsWith("#/")) { | |
| 214 | + refs.add(reference); | |
| 215 | + } | |
| 216 | + | |
| 217 | + for (const nestedValue of Object.values(value)) { | |
| 218 | + collectRefKeys(nestedValue, refs); | |
| 219 | + } | |
| 220 | +}; | |
| 221 | + | |
| 222 | +const resolveJsonPointer = ( | |
| 223 | + root: UnknownRecord, | |
| 224 | + pointer: string, | |
| 225 | +): unknown | null => { | |
| 226 | + if (!pointer.startsWith("#/")) { | |
| 227 | + return null; | |
| 228 | + } | |
| 229 | + | |
| 230 | + const parts = pointer | |
| 231 | + .slice(2) | |
| 232 | + .split("/") | |
| 233 | + .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")); | |
| 234 | + | |
| 235 | + let current: unknown = root; | |
| 236 | + | |
| 237 | + for (const part of parts) { | |
| 238 | + if (!isUnknownRecord(current)) { | |
| 239 | + return null; | |
| 240 | + } | |
| 241 | + | |
| 242 | + current = current[part]; | |
| 243 | + if (current === undefined) { | |
| 244 | + return null; | |
| 245 | + } | |
| 246 | + } | |
| 247 | + | |
| 248 | + return current; | |
| 249 | +}; | |
| 250 | + | |
| 251 | +const pickSchemaFromContent = (content: unknown): unknown | null => { | |
| 252 | + if (!isUnknownRecord(content)) { | |
| 253 | + return null; | |
| 254 | + } | |
| 255 | + | |
| 256 | + const preferred = ["application/json", ...Object.keys(content).sort()]; | |
| 257 | + const seen = new Set<string>(); | |
| 258 | + | |
| 259 | + for (const mediaType of preferred) { | |
| 260 | + if (seen.has(mediaType)) { | |
| 261 | + continue; | |
| 262 | + } | |
| 263 | + seen.add(mediaType); | |
| 264 | + | |
| 265 | + const mediaTypeValue = content[mediaType]; | |
| 266 | + if (!isUnknownRecord(mediaTypeValue)) { | |
| 267 | + continue; | |
| 268 | + } | |
| 269 | + | |
| 270 | + if (mediaTypeValue.schema !== undefined) { | |
| 271 | + return mediaTypeValue.schema; | |
| 272 | + } | |
| 273 | + } | |
| 274 | + | |
| 275 | + return null; | |
| 276 | +}; | |
| 277 | + | |
| 278 | +const extractRequestBodySchema = (operation: UnknownRecord): unknown | null => { | |
| 279 | + const requestBody = operation.requestBody; | |
| 280 | + if (!isOpenApiRequestBodyInput(requestBody)) { | |
| 281 | + return null; | |
| 282 | + } | |
| 283 | + | |
| 284 | + return pickSchemaFromContent(requestBody.content); | |
| 285 | +}; | |
| 286 | + | |
| 287 | +const responseStatusRank = (statusCode: string): number => { | |
| 288 | + if (/^2\d\d$/.test(statusCode)) { | |
| 289 | + return 0; | |
| 290 | + } | |
| 291 | + | |
| 292 | + if (statusCode === "default") { | |
| 293 | + return 1; | |
| 294 | + } | |
| 295 | + | |
| 296 | + return 2; | |
| 297 | +}; | |
| 298 | + | |
| 299 | +const extractResponseSchema = (operation: UnknownRecord): unknown | null => { | |
| 300 | + if (!isUnknownRecord(operation.responses)) { | |
| 301 | + return null; | |
| 302 | + } | |
| 303 | + | |
| 304 | + const responseCodes = Object.keys(operation.responses).sort( | |
| 305 | + (left, right) => responseStatusRank(left) - responseStatusRank(right), | |
| 306 | + ); | |
| 307 | + | |
| 308 | + for (const responseCode of responseCodes) { | |
| 309 | + const response = operation.responses[responseCode]; | |
| 310 | + if (!isUnknownRecord(response)) { | |
| 311 | + continue; | |
| 312 | + } | |
| 313 | + | |
| 314 | + const schema = pickSchemaFromContent(response.content); | |
| 315 | + if (schema !== null) { | |
| 316 | + return schema; | |
| 317 | + } | |
| 318 | + } | |
| 319 | + | |
| 320 | + return null; | |
| 321 | +}; | |
| 322 | + | |
| 323 | +const collectParameterSchemaByKey = ( | |
| 324 | + pathItem: UnknownRecord, | |
| 325 | + operation: UnknownRecord, | |
| 326 | +): Map<string, unknown> => { | |
| 327 | + const schemasByKey = new Map<string, unknown>(); | |
| 328 | + | |
| 329 | + const addParameters = (candidate: unknown) => { | |
| 330 | + if (!Array.isArray(candidate)) { | |
| 331 | + return; | |
| 332 | + } | |
| 333 | + | |
| 334 | + for (const item of candidate) { | |
| 335 | + if (!isUnknownRecord(item)) { | |
| 336 | + continue; | |
| 337 | + } | |
| 338 | + | |
| 339 | + const parameter = toExtractedToolParameter(item); | |
| 340 | + if (!parameter || item.schema === undefined) { | |
| 341 | + continue; | |
| 342 | + } | |
| 343 | + | |
| 344 | + schemasByKey.set(`${parameter.location}:${parameter.name}`, item.schema); | |
| 345 | + } | |
| 346 | + }; | |
| 347 | + | |
| 348 | + addParameters(pathItem.parameters); | |
| 349 | + addParameters(operation.parameters); | |
| 350 | + | |
| 351 | + return schemasByKey; | |
| 352 | +}; | |
| 353 | + | |
| 354 | +const buildInputSchema = ( | |
| 355 | + pathItem: UnknownRecord, | |
| 356 | + operation: UnknownRecord, | |
| 357 | + invocation: ExtractedToolInvocation, | |
| 358 | +): unknown | null => { | |
| 359 | + const parameterSchemaByKey = collectParameterSchemaByKey(pathItem, operation); | |
| 360 | + | |
| 361 | + const properties: Record<string, unknown> = {}; | |
| 362 | + const required = new Set<string>(); | |
| 363 | + | |
| 364 | + for (const parameter of invocation.parameters) { | |
| 365 | + const key = `${parameter.location}:${parameter.name}`; | |
| 366 | + properties[parameter.name] = parameterSchemaByKey.get(key) ?? { type: "string" }; | |
| 367 | + if (parameter.required) { | |
| 368 | + required.add(parameter.name); | |
| 369 | + } | |
| 370 | + } | |
| 371 | + | |
| 372 | + const requestBodySchema = extractRequestBodySchema(operation); | |
| 373 | + if (requestBodySchema !== null) { | |
| 374 | + properties.body = requestBodySchema; | |
| 375 | + if (invocation.requestBody?.required) { | |
| 376 | + required.add("body"); | |
| 377 | + } | |
| 378 | + } | |
| 379 | + | |
| 380 | + if (Object.keys(properties).length === 0) { | |
| 381 | + return null; | |
| 382 | + } | |
| 383 | + | |
| 384 | + return { | |
| 385 | + type: "object", | |
| 386 | + properties, | |
| 387 | + required: [...required].sort(), | |
| 388 | + additionalProperties: false, | |
| 389 | + }; | |
| 390 | +}; | |
| 391 | + | |
| 392 | +const encodeStableJson = (value: unknown): string => | |
| 393 | + JSON.stringify(toStableValue(value)); | |
| 394 | + | |
| 395 | +const buildToolTyping = ( | |
| 396 | + pathItem: UnknownRecord, | |
| 397 | + operation: UnknownRecord, | |
| 398 | + invocation: ExtractedToolInvocation, | |
| 399 | +): DiscoveryTyping | undefined => { | |
| 400 | + const inputSchema = buildInputSchema(pathItem, operation, invocation); | |
| 401 | + const outputSchema = extractResponseSchema(operation); | |
| 402 | + | |
| 403 | + if (inputSchema === null && outputSchema === null) { | |
| 404 | + return undefined; | |
| 405 | + } | |
| 406 | + | |
| 407 | + const refs = new Set<string>(); | |
| 408 | + if (inputSchema !== null) { | |
| 409 | + collectRefKeys(inputSchema, refs); | |
| 410 | + } | |
| 411 | + | |
| 412 | + if (outputSchema !== null) { | |
| 413 | + collectRefKeys(outputSchema, refs); | |
| 414 | + } | |
| 415 | + | |
| 416 | + const refHintKeys = [...refs].sort(); | |
| 417 | + | |
| 418 | + return { | |
| 419 | + inputSchemaJson: inputSchema ? encodeStableJson(inputSchema) : undefined, | |
| 420 | + outputSchemaJson: outputSchema ? encodeStableJson(outputSchema) : undefined, | |
| 421 | + refHintKeys: refHintKeys.length > 0 ? refHintKeys : undefined, | |
| 422 | + }; | |
| 423 | +}; | |
| 424 | + | |
| 425 | +const buildRefHintTable = ( | |
| 426 | + openApiSpec: UnknownRecord, | |
| 427 | + initialRefKeys: ReadonlyArray<string>, | |
| 428 | +): Record<string, string> => { | |
| 429 | + const queue = [...new Set(initialRefKeys)]; | |
| 430 | + const seen = new Set<string>(); | |
| 431 | + const table: Record<string, string> = {}; | |
| 432 | + | |
| 433 | + while (queue.length > 0) { | |
| 434 | + const refKey = queue.shift(); | |
| 435 | + if (!refKey || seen.has(refKey)) { | |
| 436 | + continue; | |
| 437 | + } | |
| 438 | + | |
| 439 | + seen.add(refKey); | |
| 440 | + | |
| 441 | + const resolved = resolveJsonPointer(openApiSpec, refKey); | |
| 442 | + if (resolved === null) { | |
| 443 | + continue; | |
| 444 | + } | |
| 445 | + | |
| 446 | + table[refKey] = encodeStableJson(resolved); | |
| 447 | + | |
| 448 | + const nested = new Set<string>(); | |
| 449 | + collectRefKeys(resolved, nested); | |
| 450 | + for (const nestedRef of nested) { | |
| 451 | + if (!seen.has(nestedRef)) { | |
| 452 | + queue.push(nestedRef); | |
| 453 | + } | |
| 454 | + } | |
| 455 | + } | |
| 456 | + | |
| 457 | + return table; | |
| 458 | +}; | |
| 459 | + | |
| 198 | 460 | const toStableValue = (value: unknown): unknown => { |
| 199 | 461 | if (Array.isArray(value)) { |
| 200 | 462 | return value.map(toStableValue); |
| @@ -348,6 +610,7 @@ export const extractOpenApiManifest = ( · 147 unmodified lines | ||
| 348 | 610 | pathItem, |
| 349 | 611 | operation, |
| 350 | 612 | ); |
| 613 | + const typing = buildToolTyping(pathItem, operation, invocation); | |
| 351 | 614 | |
| 352 | 615 | tools.push({ |
| 353 | 616 | toolId: buildToolId(method, pathValue, operation), |
| @@ -362,6 +625,7 @@ export const extractOpenApiManifest = ( · 8 unmodified lines | ||
| 362 | 625 | operation, |
| 363 | 626 | invocation, |
| 364 | 627 | }), |
| 628 | + typing, | |
| 365 | 629 | }); |
| 366 | 630 | } |
| 367 | 631 | } |
| @@ -369,10 +633,16 @@ export const extractOpenApiManifest = ( · 1 unmodified line | ||
| 369 | 633 | tools.sort((left, right) => left.toolId.localeCompare(right.toolId)); |
| 370 | 634 | yield* ensureUniqueToolIds(sourceName, tools); |
| 371 | 635 | |
| 636 | + const directRefKeys = tools.flatMap( | |
| 637 | + (tool) => tool.typing?.refHintKeys ?? [], | |
| 638 | + ); | |
| 639 | + const refHintTable = buildRefHintTable(specRecord, directRefKeys); | |
| 640 | + | |
| 372 | 641 | return { |
| 373 | 642 | version: 1 as const, |
| 374 | 643 | sourceHash: hashUnknown(openApiSpec), |
| 375 | 644 | tools, |
| 645 | + refHintTable: Object.keys(refHintTable).length > 0 ? refHintTable : undefined, | |
| 376 | 646 | }; |
| 377 | 647 | }).pipe(Effect.mapError((cause) => toExtractionError(sourceName, "extract", cause))); |
| 378 | 648 | |
| @@ -7,7 +7,9 @@ · 6 unmodified lines | ||
| 7 | 7 | }, |
| 8 | 8 | "scripts": { |
| 9 | 9 | "test": "bunx vitest run --no-watch src/*.integration.test.ts", |
| 10 | − "typecheck": "bunx tsc --noEmit -p tsconfig.json" | |
| 10 | + "typecheck": "bunx tsc --noEmit -p tsconfig.json", | |
| 11 | + "worker:dev": "bunx wrangler dev --local --config worker/wrangler.jsonc", | |
| 12 | + "worker:deploy": "bunx wrangler deploy --config worker/wrangler.jsonc" | |
| 11 | 13 | }, |
| 12 | 14 | "dependencies": { |
| 13 | 15 | "@effect/platform": "catalog:", |
| @@ -1,8 +1,13 @@ | ||
| 1 | 1 | import { spawn } from "node:child_process"; |
| 2 | −import { createServer } from "node:net"; | |
| 2 | +import { | |
| 3 | + createServer as createHttpServer, | |
| 4 | + type IncomingMessage, | |
| 5 | + type ServerResponse, | |
| 6 | +} from "node:http"; | |
| 7 | +import { createServer as createNetServer } from "node:net"; | |
| 3 | 8 | import { fileURLToPath } from "node:url"; |
| 4 | 9 | |
| 5 | −import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; | |
| 10 | +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "@effect/vitest"; | |
| 6 | 11 | import * as Effect from "effect/Effect"; |
| 7 | 12 | import * as Either from "effect/Either"; |
| 8 | 13 | |
| @@ -11,9 +16,19 @@ import { makeCloudflareWorkerLoaderRuntimeAdapter } from "./index"; · 2 unmodified lines | ||
| 11 | 16 | |
| 12 | 17 | type WorkerProcess = ReturnType<typeof spawn>; |
| 13 | 18 | |
| 19 | +type CallbackToolCall = { | |
| 20 | + runId: string; | |
| 21 | + callId: string; | |
| 22 | + toolPath: string; | |
| 23 | + input: unknown; | |
| 24 | + internalSecret: string | null; | |
| 25 | +}; | |
| 26 | + | |
| 27 | +const CALLBACK_SECRET = "internal-token"; | |
| 28 | + | |
| 14 | 29 | const getFreePort = async (): Promise<number> => |
| 15 | 30 | await new Promise<number>((resolve, reject) => { |
| 16 | − const server = createServer(); | |
| 31 | + const server = createNetServer(); | |
| 17 | 32 | |
| 18 | 33 | server.listen(0, "127.0.0.1", () => { |
| 19 | 34 | const address = server.address(); |
| @@ -83,16 +98,156 @@ const stopWorkerProcess = async (process: WorkerProcess): Promise<void> => · 63 unmodified lines | ||
| 83 | 98 | process.kill("SIGTERM"); |
| 84 | 99 | }); |
| 85 | 100 | |
| 101 | +const readIncomingBody = async (request: IncomingMessage): Promise<string> => | |
| 102 | + await new Promise<string>((resolve, reject) => { | |
| 103 | + let body = ""; | |
| 104 | + request.setEncoding("utf8"); | |
| 105 | + | |
| 106 | + request.on("data", (chunk) => { | |
| 107 | + body += chunk; | |
| 108 | + }); | |
| 109 | + | |
| 110 | + request.on("end", () => { | |
| 111 | + resolve(body); | |
| 112 | + }); | |
| 113 | + | |
| 114 | + request.on("error", (error) => { | |
| 115 | + reject(error); | |
| 116 | + }); | |
| 117 | + }); | |
| 118 | + | |
| 119 | +const writeJson = (response: ServerResponse, status: number, value: unknown): void => { | |
| 120 | + response.statusCode = status; | |
| 121 | + response.setHeader("content-type", "application/json"); | |
| 122 | + response.end(JSON.stringify(value)); | |
| 123 | +}; | |
| 124 | + | |
| 86 | 125 | describe("cloudflare worker loader runtime adapter integration", () => { |
| 87 | 126 | let workerProcess: WorkerProcess | null = null; |
| 88 | 127 | let workerPort = 0; |
| 89 | 128 | let workerBaseUrl = ""; |
| 90 | 129 | |
| 130 | + let callbackServer: ReturnType<typeof createHttpServer> | null = null; | |
| 131 | + let callbackBaseUrl = ""; | |
| 132 | + let callbackCalls: Array<CallbackToolCall> = []; | |
| 133 | + | |
| 91 | 134 | beforeAll(async () => { |
| 135 | + callbackServer = createHttpServer(async (request, response) => { | |
| 136 | + const url = new URL(request.url ?? "/", "http://127.0.0.1"); | |
| 137 | + if (url.pathname !== "/tool-call" || request.method !== "POST") { | |
| 138 | + response.statusCode = 404; | |
| 139 | + response.end("not_found"); | |
| 140 | + return; | |
| 141 | + } | |
| 142 | + | |
| 143 | + const bodyText = await readIncomingBody(request).catch(() => ""); | |
| 144 | + let payload: unknown = null; | |
| 145 | + try { | |
| 146 | + payload = bodyText.length > 0 ? JSON.parse(bodyText) : null; | |
| 147 | + } catch { | |
| 148 | + payload = null; | |
| 149 | + } | |
| 150 | + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { | |
| 151 | + writeJson(response, 200, { | |
| 152 | + ok: false, | |
| 153 | + kind: "failed", | |
| 154 | + error: "Invalid callback payload", | |
| 155 | + }); | |
| 156 | + return; | |
| 157 | + } | |
| 158 | + | |
| 159 | + const record = payload as Record<string, unknown>; | |
| 160 | + if ( | |
| 161 | + typeof record.runId !== "string" || | |
| 162 | + typeof record.callId !== "string" || | |
| 163 | + typeof record.toolPath !== "string" | |
| 164 | + ) { | |
| 165 | + writeJson(response, 200, { | |
| 166 | + ok: false, | |
| 167 | + kind: "failed", | |
| 168 | + error: "Missing callback fields", | |
| 169 | + }); | |
| 170 | + return; | |
| 171 | + } | |
| 172 | + | |
| 173 | + const headerValue = request.headers["x-internal-secret"]; | |
| 174 | + const internalSecret = | |
| 175 | + typeof headerValue === "string" | |
| 176 | + ? headerValue | |
| 177 | + : Array.isArray(headerValue) | |
| 178 | + ? (headerValue[0] ?? null) | |
| 179 | + : null; | |
| 180 | + | |
| 181 | + callbackCalls.push({ | |
| 182 | + runId: record.runId, | |
| 183 | + callId: record.callId, | |
| 184 | + toolPath: record.toolPath, | |
| 185 | + input: record.input, | |
| 186 | + internalSecret, | |
| 187 | + }); | |
| 188 | + | |
| 189 | + if (internalSecret !== CALLBACK_SECRET) { | |
| 190 | + writeJson(response, 200, { | |
| 191 | + ok: false, | |
| 192 | + kind: "failed", | |
| 193 | + error: "Invalid callback internal secret", | |
| 194 | + }); | |
| 195 | + return; | |
| 196 | + } | |
| 197 | + | |
| 198 | + if (record.toolPath === "math.add") { | |
| 199 | + const input = | |
| 200 | + record.input && typeof record.input === "object" && !Array.isArray(record.input) | |
| 201 | + ? (record.input as Record<string, unknown>) | |
| 202 | + : {}; | |
| 203 | + | |
| 204 | + const a = typeof input.a === "number" ? input.a : 0; | |
| 205 | + const b = typeof input.b === "number" ? input.b : 0; | |
| 206 | + | |
| 207 | + writeJson(response, 200, { | |
| 208 | + ok: true, | |
| 209 | + value: a + b, | |
| 210 | + }); | |
| 211 | + return; | |
| 212 | + } | |
| 213 | + | |
| 214 | + if (record.toolPath === "math.denied") { | |
| 215 | + writeJson(response, 200, { | |
| 216 | + ok: false, | |
| 217 | + kind: "denied", | |
| 218 | + error: "tool access denied by policy", | |
| 219 | + }); | |
| 220 | + return; | |
| 221 | + } | |
| 222 | + | |
| 223 | + writeJson(response, 200, { | |
| 224 | + ok: false, | |
| 225 | + kind: "failed", | |
| 226 | + error: `Unknown test tool path: ${record.toolPath}`, | |
| 227 | + }); | |
| 228 | + }); | |
| 229 | + | |
| 230 | + await new Promise<void>((resolve, reject) => { | |
| 231 | + callbackServer?.listen(0, "127.0.0.1", (error?: Error) => { | |
| 232 | + if (error) { | |
| 233 | + reject(error); | |
| 234 | + return; | |
| 235 | + } | |
| 236 | + | |
| 237 | + resolve(); | |
| 238 | + }); | |
| 239 | + }); | |
| 240 | + | |
| 241 | + const callbackAddress = callbackServer.address(); | |
| 242 | + if (!callbackAddress || typeof callbackAddress === "string") { | |
| 243 | + throw new Error("Failed to resolve callback server port"); | |
| 244 | + } | |
| 245 | + callbackBaseUrl = `http://127.0.0.1:${callbackAddress.port}`; | |
| 246 | + | |
| 92 | 247 | workerPort = await getFreePort(); |
| 93 | 248 | workerBaseUrl = `http://127.0.0.1:${workerPort}`; |
| 94 | 249 | |
| 95 | − const workerDir = fileURLToPath(new URL("../test-worker", import.meta.url)); | |
| 250 | + const workerDir = fileURLToPath(new URL("../worker", import.meta.url)); | |
| 96 | 251 | |
| 97 | 252 | const spawned = spawn( |
| 98 | 253 | "bunx", |
| @@ -116,7 +271,7 @@ describe("cloudflare worker loader runtime adapter integration", () => { · 17 unmodified lines | ||
| 116 | 271 | workerProcess = spawned; |
| 117 | 272 | |
| 118 | 273 | spawned.stdout?.on("data", () => { |
| 119 | − // Ignore stdout, but keep stream flowing. | |
| 274 | + // Keep stream flowing. | |
| 120 | 275 | }); |
| 121 | 276 | |
| 122 | 277 | spawned.stderr?.on("data", () => { |
| @@ -126,48 +281,79 @@ describe("cloudflare worker loader runtime adapter integration", () => { · 3 unmodified lines | ||
| 126 | 281 | await waitForWorker(workerBaseUrl, 45_000); |
| 127 | 282 | }); |
| 128 | 283 | |
| 284 | + beforeEach(() => { | |
| 285 | + callbackCalls = []; | |
| 286 | + }); | |
| 287 | + | |
| 129 | 288 | afterAll(async () => { |
| 289 | + if (callbackServer) { | |
| 290 | + await new Promise<void>((resolve, reject) => { | |
| 291 | + callbackServer?.close((error) => { | |
| 292 | + if (error) { | |
| 293 | + reject(error); | |
| 294 | + return; | |
| 295 | + } | |
| 296 | + | |
| 297 | + resolve(); | |
| 298 | + }); | |
| 299 | + }); | |
| 300 | + } | |
| 301 | + | |
| 130 | 302 | if (workerProcess) { |
| 131 | 303 | await stopWorkerProcess(workerProcess); |
| 132 | 304 | } |
| 133 | 305 | }); |
| 134 | 306 | |
| 135 | − it.effect("executes against a real local Cloudflare Worker process", () => | |
| 307 | + it.effect("executes user code in worker and resolves tool calls via callback bridge", () => | |
| 136 | 308 | Effect.gen(function* () { |
| 137 | 309 | const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({ |
| 138 | − runUrl: `${workerBaseUrl}/v1/runs?scenario=completed`, | |
| 310 | + runUrl: `${workerBaseUrl}/v1/runs`, | |
| 139 | 311 | authToken: "sandbox-token", |
| 140 | − callbackUrl: "https://callbacks.example.test/runtimeCallbacks", | |
| 141 | − callbackInternalSecret: "internal-token", | |
| 312 | + callbackUrl: `${callbackBaseUrl}/tool-call`, | |
| 313 | + callbackInternalSecret: CALLBACK_SECRET, | |
| 142 | 314 | }); |
| 143 | 315 | |
| 144 | 316 | const result = yield* adapter.execute({ |
| 145 | 317 | runId: "run_e2e_1", |
| 146 | − code: "return 42;", | |
| 318 | + code: "const left = await tools.math.add({ a: 2, b: 4 }); const right = await tools.math.add({ a: left, b: 3 }); return { left, right };", | |
| 147 | 319 | }); |
| 148 | 320 | |
| 149 | − expect(result).toMatchObject({ | |
| 150 | − echoedRunId: "run_e2e_1", | |
| 151 | − callback: { | |
| 152 | − url: "https://callbacks.example.test/runtimeCallbacks", | |
| 153 | − internalSecret: "internal-token", | |
| 154 | − }, | |
| 321 | + expect(result).toEqual({ | |
| 322 | + left: 6, | |
| 323 | + right: 9, | |
| 155 | 324 | }); |
| 325 | + | |
| 326 | + expect(callbackCalls).toHaveLength(2); | |
| 327 | + expect(callbackCalls[0]).toMatchObject({ | |
| 328 | + runId: "run_e2e_1", | |
| 329 | + toolPath: "math.add", | |
| 330 | + input: { a: 2, b: 4 }, | |
| 331 | + internalSecret: CALLBACK_SECRET, | |
| 332 | + }); | |
| 333 | + expect(callbackCalls[1]).toMatchObject({ | |
| 334 | + runId: "run_e2e_1", | |
| 335 | + toolPath: "math.add", | |
| 336 | + input: { a: 6, b: 3 }, | |
| 337 | + internalSecret: CALLBACK_SECRET, | |
| 338 | + }); | |
| 339 | + expect(callbackCalls[0]?.callId.startsWith("call_")).toBe(true); | |
| 340 | + expect(callbackCalls[1]?.callId.startsWith("call_")).toBe(true); | |
| 156 | 341 | }), |
| 157 | 342 | ); |
| 158 | 343 | |
| 159 | − it.effect("maps worker terminal failures across the HTTP boundary", () => | |
| 344 | + it.effect("maps denied tool callbacks to runtime terminal denied status", () => | |
| 160 | 345 | Effect.gen(function* () { |
| 161 | 346 | const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({ |
| 162 | − runUrl: `${workerBaseUrl}/v1/runs?scenario=failed`, | |
| 347 | + runUrl: `${workerBaseUrl}/v1/runs`, | |
| 163 | 348 | authToken: "sandbox-token", |
| 164 | − callbackUrl: "https://callbacks.example.test/runtimeCallbacks", | |
| 349 | + callbackUrl: `${callbackBaseUrl}/tool-call`, | |
| 350 | + callbackInternalSecret: CALLBACK_SECRET, | |
| 165 | 351 | }); |
| 166 | 352 | |
| 167 | 353 | const result = yield* Effect.either( |
| 168 | 354 | adapter.execute({ |
| 169 | 355 | runId: "run_e2e_2", |
| 170 | − code: "return 0;", | |
| 356 | + code: "await tools.math.denied({}); return 'unreachable';", | |
| 171 | 357 | }), |
| 172 | 358 | ); |
| 173 | 359 | |
| @@ -175,23 +361,75 @@ describe("cloudflare worker loader runtime adapter integration", () => { · 1 unmodified line | ||
| 175 | 361 | if (Either.isLeft(result)) { |
| 176 | 362 | expect(result.left).toBeInstanceOf(RuntimeAdapterError); |
| 177 | 363 | expect(result.left.operation).toBe("execute_terminal"); |
| 178 | − expect(result.left.message).toContain("failed"); | |
| 179 | − expect(result.left.details).toBe("tool call denied"); | |
| 364 | + expect(result.left.message).toContain("denied"); | |
| 365 | + expect(result.left.details).toBe("tool access denied by policy"); | |
| 180 | 366 | } |
| 181 | 367 | }), |
| 182 | 368 | ); |
| 183 | 369 | |
| 184 | − it.effect("surfaces non-200 responses as dispatch errors", () => | |
| 370 | + it.effect("maps thrown user code errors to runtime terminal failures", () => | |
| 185 | 371 | Effect.gen(function* () { |
| 186 | 372 | const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({ |
| 187 | − runUrl: `${workerBaseUrl}/v1/runs?scenario=http-500`, | |
| 373 | + runUrl: `${workerBaseUrl}/v1/runs`, | |
| 188 | 374 | authToken: "sandbox-token", |
| 189 | − callbackUrl: "https://callbacks.example.test/runtimeCallbacks", | |
| 375 | + callbackUrl: `${callbackBaseUrl}/tool-call`, | |
| 376 | + callbackInternalSecret: CALLBACK_SECRET, | |
| 190 | 377 | }); |
| 191 | 378 | |
| 192 | 379 | const result = yield* Effect.either( |
| 193 | 380 | adapter.execute({ |
| 194 | 381 | runId: "run_e2e_3", |
| 382 | + code: "throw new Error('boom from user code');", | |
| 383 | + }), | |
| 384 | + ); | |
| 385 | + | |
| 386 | + expect(Either.isLeft(result)).toBe(true); | |
| 387 | + if (Either.isLeft(result)) { | |
| 388 | + expect(result.left.operation).toBe("execute_terminal"); | |
| 389 | + expect(result.left.message).toContain("failed"); | |
| 390 | + expect(result.left.details).toContain("boom from user code"); | |
| 391 | + } | |
| 392 | + }), | |
| 393 | + ); | |
| 394 | + | |
| 395 | + it.effect("maps worker execution timeouts to runtime timeout terminal status", () => | |
| 396 | + Effect.gen(function* () { | |
| 397 | + const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({ | |
| 398 | + runUrl: `${workerBaseUrl}/v1/runs`, | |
| 399 | + authToken: "sandbox-token", | |
| 400 | + callbackUrl: `${callbackBaseUrl}/tool-call`, | |
| 401 | + callbackInternalSecret: CALLBACK_SECRET, | |
| 402 | + }); | |
| 403 | + | |
| 404 | + const result = yield* Effect.either( | |
| 405 | + adapter.execute({ | |
| 406 | + runId: "run_e2e_4", | |
| 407 | + timeoutMs: 50, | |
| 408 | + code: "await new Promise((resolve) => setTimeout(resolve, 200)); return 'late';", | |
| 409 | + }), | |
| 410 | + ); | |
| 411 | + | |
| 412 | + expect(Either.isLeft(result)).toBe(true); | |
| 413 | + if (Either.isLeft(result)) { | |
| 414 | + expect(result.left.operation).toBe("execute_terminal"); | |
| 415 | + expect(result.left.message).toContain("timed_out"); | |
| 416 | + expect(result.left.details).toContain("Execution timed out"); | |
| 417 | + } | |
| 418 | + }), | |
| 419 | + ); | |
| 420 | + | |
| 421 | + it.effect("surfaces non-200 worker responses as dispatch errors", () => | |
| 422 | + Effect.gen(function* () { | |
| 423 | + const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({ | |
| 424 | + runUrl: `${workerBaseUrl}/v1/runs`, | |
| 425 | + authToken: "wrong-token", | |
| 426 | + callbackUrl: `${callbackBaseUrl}/tool-call`, | |
| 427 | + callbackInternalSecret: CALLBACK_SECRET, | |
| 428 | + }); | |
| 429 | + | |
| 430 | + const result = yield* Effect.either( | |
| 431 | + adapter.execute({ | |
| 432 | + runId: "run_e2e_5", | |
| 195 | 433 | code: "return 0;", |
| 196 | 434 | }), |
| 197 | 435 | ); |
| @@ -199,22 +437,23 @@ describe("cloudflare worker loader runtime adapter integration", () => { · 1 unmodified line | ||
| 199 | 437 | expect(Either.isLeft(result)).toBe(true); |
| 200 | 438 | if (Either.isLeft(result)) { |
| 201 | 439 | expect(result.left.operation).toBe("dispatch"); |
| 202 | − expect(result.left.message).toContain("500"); | |
| 440 | + expect(result.left.message).toContain("401"); | |
| 203 | 441 | } |
| 204 | 442 | }), |
| 205 | 443 | ); |
| 206 | 444 | |
| 207 | − it.effect("surfaces invalid JSON from worker as decode errors", () => | |
| 445 | + it.effect("surfaces invalid worker JSON as decode errors", () => | |
| 208 | 446 | Effect.gen(function* () { |
| 209 | 447 | const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({ |
| 210 | − runUrl: `${workerBaseUrl}/v1/runs?scenario=invalid-json`, | |
| 448 | + runUrl: `${workerBaseUrl}/health`, | |
| 211 | 449 | authToken: "sandbox-token", |
| 212 | − callbackUrl: "https://callbacks.example.test/runtimeCallbacks", | |
| 450 | + callbackUrl: `${callbackBaseUrl}/tool-call`, | |
| 451 | + callbackInternalSecret: CALLBACK_SECRET, | |
| 213 | 452 | }); |
| 214 | 453 | |
| 215 | 454 | const result = yield* Effect.either( |
| 216 | 455 | adapter.execute({ |
| 217 | − runId: "run_e2e_4", | |
| 456 | + runId: "run_e2e_6", | |
| 218 | 457 | code: "return 0;", |
| 219 | 458 | }), |
| 220 | 459 | ); |
| @@ -1,118 +0,0 @@ | ||
| 1 | −const AUTH_TOKEN = "sandbox-token"; | |
| 2 | − | |
| 3 | −type CloudflareRunRequest = { | |
| 4 | − runId?: unknown; | |
| 5 | − taskId?: unknown; | |
| 6 | − code?: unknown; | |
| 7 | − timeoutMs?: unknown; | |
| 8 | − callback?: { | |
| 9 | − url?: unknown; | |
| 10 | − internalSecret?: unknown; | |
| 11 | − }; | |
| 12 | −}; | |
| 13 | − | |
| 14 | −const json = (value: unknown, status = 200): Response => | |
| 15 | − new Response(JSON.stringify(value), { | |
| 16 | − status, | |
| 17 | − headers: { | |
| 18 | − "content-type": "application/json", | |
| 19 | − }, | |
| 20 | − }); | |
| 21 | − | |
| 22 | −const isObjectRecord = (value: unknown): value is Record<string, unknown> => | |
| 23 | − typeof value === "object" && value !== null && !Array.isArray(value); | |
| 24 | − | |
| 25 | −const isValidRunRequest = (value: unknown): value is CloudflareRunRequest => { | |
| 26 | − if (!isObjectRecord(value)) { | |
| 27 | − return false; | |
| 28 | − } | |
| 29 | − | |
| 30 | − if (typeof value.runId !== "string") { | |
| 31 | − return false; | |
| 32 | − } | |
| 33 | − | |
| 34 | − if (typeof value.taskId !== "string") { | |
| 35 | − return false; | |
| 36 | − } | |
| 37 | − | |
| 38 | − if (typeof value.code !== "string") { | |
| 39 | − return false; | |
| 40 | − } | |
| 41 | − | |
| 42 | − if (!isObjectRecord(value.callback)) { | |
| 43 | − return false; | |
| 44 | − } | |
| 45 | − | |
| 46 | − if (typeof value.callback.url !== "string") { | |
| 47 | − return false; | |
| 48 | − } | |
| 49 | − | |
| 50 | − if ( | |
| 51 | − value.callback.internalSecret !== undefined && | |
| 52 | − typeof value.callback.internalSecret !== "string" | |
| 53 | − ) { | |
| 54 | − return false; | |
| 55 | − } | |
| 56 | − | |
| 57 | − return true; | |
| 58 | −}; | |
| 59 | − | |
| 60 | −export default { | |
| 61 | − async fetch(request: Request): Promise<Response> { | |
| 62 | − const url = new URL(request.url); | |
| 63 | − | |
| 64 | − if (url.pathname === "/health") { | |
| 65 | − return new Response("ok", { status: 200 }); | |
| 66 | − } | |
| 67 | − | |
| 68 | − if (url.pathname !== "/v1/runs" || request.method !== "POST") { | |
| 69 | − return json({ error: "not_found" }, 404); | |
| 70 | − } | |
| 71 | − | |
| 72 | − if (request.headers.get("authorization") !== `Bearer ${AUTH_TOKEN}`) { | |
| 73 | − return json({ error: "unauthorized" }, 401); | |
| 74 | − } | |
| 75 | − | |
| 76 | − let payload: unknown; | |
| 77 | − try { | |
| 78 | − payload = await request.json(); | |
| 79 | − } catch { | |
| 80 | − return json({ error: "invalid_json" }, 400); | |
| 81 | − } | |
| 82 | − | |
| 83 | − if (!isValidRunRequest(payload)) { | |
| 84 | − return json({ error: "invalid_payload" }, 400); | |
| 85 | − } | |
| 86 | − | |
| 87 | − const scenario = url.searchParams.get("scenario") ?? "completed"; | |
| 88 | − | |
| 89 | − if (scenario === "http-500") { | |
| 90 | − return json({ error: "host_failure" }, 500); | |
| 91 | − } | |
| 92 | − | |
| 93 | − if (scenario === "invalid-json") { | |
| 94 | − return new Response("{bad-json", { | |
| 95 | − status: 200, | |
| 96 | − headers: { | |
| 97 | − "content-type": "application/json", | |
| 98 | − }, | |
| 99 | − }); | |
| 100 | − } | |
| 101 | − | |
| 102 | − if (scenario === "failed") { | |
| 103 | − return json({ status: "failed", error: "tool call denied" }, 200); | |
| 104 | − } | |
| 105 | − | |
| 106 | − return json( | |
| 107 | − { | |
| 108 | − status: "completed", | |
| 109 | − result: { | |
| 110 | − echoedRunId: payload.runId, | |
| 111 | − callback: payload.callback, | |
| 112 | − }, | |
| 113 | − exitCode: 0, | |
| 114 | − }, | |
| 115 | − 200, | |
| 116 | − ); | |
| 117 | − }, | |
| 118 | −}; | |
| @@ -1,6 +0,0 @@ | ||
| 1 | −{ | |
| 2 | − "name": "executor-v2-runtime-cloudflare-test-worker", | |
| 3 | − "main": "src/index.ts", | |
| 4 | − "compatibility_date": "2025-06-01", | |
| 5 | − "compatibility_flags": ["nodejs_compat"] | |
| 6 | −} | |