- 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 | "type": "remote", | 5 | "type": "remote", |
| 6 | "url": "https://www.answeroverflow.com/mcp", | 6 | "url": "https://www.answeroverflow.com/mcp", |
| 7 | "enabled": true | 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 | "agent": { | 10 | "agent": { |
| @@ -18,6 +18,7 @@ export { · 17 unmodified lines | |||
| 18 | } from "./run-client"; | 18 | } from "./run-client"; |
| 19 | 19 | ||
| 20 | export { | 20 | export { |
| 21 | createRuntimeToolCallResultHandler, | ||
| 21 | createRuntimeToolCallService, | 22 | createRuntimeToolCallService, |
| 22 | createStaticToolRegistry, | 23 | createStaticToolRegistry, |
| 23 | type InMemorySandboxTool, | 24 | type InMemorySandboxTool, |
| @@ -226,6 +226,23 @@ describe("source tool registry", () => { · 225 unmodified lines | |||
| 226 | 226 | ||
| 227 | expect(discovered.bestPath).not.toBeNull(); | 227 | expect(discovered.bestPath).not.toBeNull(); |
| 228 | expect(discovered.results.length).toBeGreaterThan(0); | 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 | const bestPath = discovered.bestPath; | 247 | const bestPath = discovered.bestPath; |
| 231 | if (!bestPath) { | 248 | if (!bestPath) { |
| @@ -243,10 +260,13 @@ describe("source tool registry", () => { · 11 unmodified lines | |||
| 243 | }); | 260 | }); |
| 244 | 261 | ||
| 245 | expect(invocationResult).toMatchObject({ | 262 | expect(invocationResult).toMatchObject({ |
| 246 | status: 200, | 263 | ok: true, |
| 247 | body: { | 264 | value: { |
| 248 | full_name: "octocat/hello-world", | 265 | status: 200, |
| 249 | stargazers_count: 42, | 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 | type ToolArtifactStore, | 4 | type ToolArtifactStore, |
| 5 | type ToolArtifactStoreError, | 5 | type ToolArtifactStoreError, |
| 6 | } from "@executor-v2/persistence-ports"; | 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 | 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"; |
| 17 | import * as Schema from "effect/Schema"; | ||
| 10 | 18 | ||
| 11 | import { openApiToolDescriptorsFromManifest } from "./openapi-provider"; | 19 | import { openApiToolDescriptorsFromManifest } from "./openapi-provider"; |
| 12 | import { | 20 | import { |
| @@ -14,7 +22,6 @@ import { · 1 unmodified line | |||
| 14 | type RuntimeAdapterKind, | 22 | type RuntimeAdapterKind, |
| 15 | } from "./runtime-adapters"; | 23 | } from "./runtime-adapters"; |
| 16 | import type { | 24 | import type { |
| 17 | CanonicalToolDescriptor, | ||
| 18 | ToolProviderError, | 25 | ToolProviderError, |
| 19 | ToolProviderRegistry, | 26 | ToolProviderRegistry, |
| 20 | ToolProviderRegistryError, | 27 | ToolProviderRegistryError, |
| @@ -45,8 +52,18 @@ type SourceToolEntry = { · 24 unmodified lines | |||
| 45 | namespace: string; | 52 | namespace: string; |
| 46 | source: Source; | 53 | source: Source; |
| 47 | descriptor: CanonicalToolDescriptor; | 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 | const toRuntimeAdapterError = ( | 67 | const toRuntimeAdapterError = ( |
| 51 | operation: string, | 68 | operation: string, |
| 52 | message: string, | 69 | message: string, |
| @@ -111,7 +128,64 @@ const sourceToolPath = ( · 58 unmodified lines | |||
| 111 | descriptor: CanonicalToolDescriptor, | 128 | descriptor: CanonicalToolDescriptor, |
| 112 | ): string => `${sourceNamespace(source)}.${descriptor.toolId}`; | 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 | if (query.length === 0) { | 189 | if (query.length === 0) { |
| 116 | return 1; | 190 | return 1; |
| 117 | } | 191 | } |
| @@ -120,6 +194,8 @@ const scoreSummary = (summary: ToolRegistryToolSummary, query: string): number = · 2 unmodified lines | |||
| 120 | const lowerPath = summary.path.toLowerCase(); | 194 | const lowerPath = summary.path.toLowerCase(); |
| 121 | const lowerSource = (summary.source ?? "").toLowerCase(); | 195 | const lowerSource = (summary.source ?? "").toLowerCase(); |
| 122 | const lowerDescription = (summary.description ?? "").toLowerCase(); | 196 | const lowerDescription = (summary.description ?? "").toLowerCase(); |
| 197 | const lowerInputHint = (summary.inputHint ?? "").toLowerCase(); | ||
| 198 | const lowerOutputHint = (summary.outputHint ?? "").toLowerCase(); | ||
| 123 | 199 | ||
| 124 | if (lowerPath === lowerQuery) { | 200 | if (lowerPath === lowerQuery) { |
| 125 | return 100; | 201 | return 100; |
| @@ -141,16 +217,71 @@ const scoreSummary = (summary: ToolRegistryToolSummary, query: string): number = · 15 unmodified lines | |||
| 141 | return 30; | 217 | return 30; |
| 142 | } | 218 | } |
| 143 | 219 | ||
| 220 | if (lowerInputHint.includes(lowerQuery) || lowerOutputHint.includes(lowerQuery)) { | ||
| 221 | return 20; | ||
| 222 | } | ||
| 223 | |||
| 144 | return 0; | 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 | path: entry.path, | 232 | path: entry.path, |
| 149 | source: entry.source.name, | 233 | source: entry.source.name, |
| 150 | approval: "auto", | 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 | const normalizeToolCallInput = ( | 285 | const normalizeToolCallInput = ( |
| 155 | input: unknown, | 286 | input: unknown, |
| 156 | ): Record<string, unknown> => { | 287 | ): Record<string, unknown> => { |
| @@ -175,7 +306,7 @@ const describeOutput = (value: unknown): string => { · 18 unmodified lines | |||
| 175 | 306 | ||
| 176 | const loadSourceEntries = ( | 307 | const loadSourceEntries = ( |
| 177 | options: SourceToolRegistryOptions, | 308 | options: SourceToolRegistryOptions, |
| 178 | ): Effect.Effect<ReadonlyArray<SourceToolEntry>, RuntimeAdapterError> => | 309 | ): Effect.Effect<SourceToolSnapshot, RuntimeAdapterError> => |
| 179 | Effect.gen(function* () { | 310 | Effect.gen(function* () { |
| 180 | const workspaceSources = yield* options.sourceStore | 311 | const workspaceSources = yield* options.sourceStore |
| 181 | .listByWorkspace(options.workspaceId as WorkspaceId) | 312 | .listByWorkspace(options.workspaceId as WorkspaceId) |
| @@ -187,62 +318,128 @@ const loadSourceEntries = ( · 5 unmodified lines | |||
| 187 | 318 | ||
| 188 | const enabledSources = workspaceSources.filter((source) => source.enabled); | 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 | Effect.gen(function* () { | 322 | Effect.gen(function* () { |
| 192 | if (source.kind !== "openapi") { | 323 | const namespace = sourceNamespace(source); |
| 193 | return [] as Array<SourceToolEntry>; | ||
| 194 | } | ||
| 195 | 324 | ||
| 196 | const artifactOption = yield* options.toolArtifactStore | 325 | if (source.kind === "openapi") { |
| 197 | .getBySource(source.workspaceId, source.id) | 326 | const artifactOption = yield* options.toolArtifactStore |
| 198 | .pipe( | 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 | Effect.mapError((cause) => | 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)) { | 364 | const toolTypingById = new Map( |
| 205 | return [] as Array<SourceToolEntry>; | 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; | 380 | const discovered = yield* options.toolProviderRegistry |
| 209 | const descriptors = yield* openApiToolDescriptorsFromManifest( | 381 | .discoverFromSource(source) |
| 210 | source, | 382 | .pipe( |
| 211 | artifact.manifestJson, | 383 | Effect.mapError((cause) => |
| 212 | ).pipe( | 384 | cause._tag === "ToolProviderError" |
| 213 | Effect.mapError((cause) => | 385 | ? toolProviderErrorToRuntimeAdapterError( |
| 214 | toolProviderErrorToRuntimeAdapterError( | 386 | "discover_source_tools", |
| 215 | "decode_source_manifest", | 387 | cause, |
| 216 | cause, | 388 | ) |
| 389 | : toolProviderRegistryErrorToRuntimeAdapterError( | ||
| 390 | "discover_source_tools", | ||
| 391 | cause, | ||
| 392 | ), | ||
| 217 | ), | 393 | ), |
| 218 | ), | 394 | Effect.either, |
| 219 | ); | 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) => ({ | 404 | return { |
| 224 | path: sourceToolPath(source, descriptor), | 405 | entries: discovered.right.tools.map((descriptor) => ({ |
| 225 | namespace, | 406 | path: sourceToolPath(source, descriptor), |
| 226 | source, | 407 | namespace, |
| 227 | descriptor, | 408 | source, |
| 228 | })); | 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 | const discoverTools = ( | 429 | const discoverTools = ( |
| 236 | entries: ReadonlyArray<SourceToolEntry>, | 430 | snapshot: SourceToolSnapshot, |
| 237 | input: ToolRegistryDiscoverInput, | 431 | input: ToolRegistryDiscoverInput, |
| 238 | ): ToolRegistryDiscoverOutput => { | 432 | ): ToolRegistryDiscoverOutput => { |
| 239 | const limit = Math.max(1, Math.min(50, input.limit ?? 8)); | 433 | const limit = Math.max(1, Math.min(50, input.limit ?? 8)); |
| 240 | const query = (input.query ?? "").trim().toLowerCase(); | 434 | const query = (input.query ?? "").trim().toLowerCase(); |
| 241 | 435 | const includeSchemas = input.includeSchemas === true; | |
| 242 | const ranked = entries | 436 | const compact = input.compact === true; |
| 243 | .map((entry) => ({ | 437 | |
| 244 | summary: summaryFromEntry(entry), | 438 | const ranked = snapshot.entries |
| 245 | score: scoreSummary(summaryFromEntry(entry), query), | 439 | .map((entry) => summarizeEntry(entry, includeSchemas, compact)) |
| 440 | .map((summary) => ({ | ||
| 441 | summary, | ||
| 442 | score: scoreSummary(summary, query), | ||
| 246 | })) | 443 | })) |
| 247 | .filter((item) => item.score > 0) | 444 | .filter((item) => item.score > 0) |
| 248 | .sort((left, right) => right.score - left.score) | 445 | .sort((left, right) => right.score - left.score) |
| @@ -253,17 +450,18 @@ const discoverTools = ( · 4 unmodified lines | |||
| 253 | bestPath: ranked[0]?.path ?? null, | 450 | bestPath: ranked[0]?.path ?? null, |
| 254 | results: ranked, | 451 | results: ranked, |
| 255 | total: ranked.length, | 452 | total: ranked.length, |
| 453 | refHintTable: pickRefHintTable(snapshot, ranked, includeSchemas), | ||
| 256 | }; | 454 | }; |
| 257 | }; | 455 | }; |
| 258 | 456 | ||
| 259 | const catalogNamespaces = ( | 457 | const catalogNamespaces = ( |
| 260 | entries: ReadonlyArray<SourceToolEntry>, | 458 | snapshot: SourceToolSnapshot, |
| 261 | input: ToolRegistryCatalogNamespacesInput, | 459 | input: ToolRegistryCatalogNamespacesInput, |
| 262 | ): ToolRegistryCatalogNamespacesOutput => { | 460 | ): ToolRegistryCatalogNamespacesOutput => { |
| 263 | const limit = Math.max(1, Math.min(200, input.limit ?? 50)); | 461 | const limit = Math.max(1, Math.min(200, input.limit ?? 50)); |
| 264 | const grouped = new Map<string, Array<string>>(); | 462 | const grouped = new Map<string, Array<string>>(); |
| 265 | 463 | ||
| 266 | for (const entry of entries) { | 464 | for (const entry of snapshot.entries) { |
| 267 | const paths = grouped.get(entry.namespace) ?? []; | 465 | const paths = grouped.get(entry.namespace) ?? []; |
| 268 | paths.push(entry.path); | 466 | paths.push(entry.path); |
| 269 | grouped.set(entry.namespace, paths); | 467 | grouped.set(entry.namespace, paths); |
| @@ -286,24 +484,27 @@ const catalogNamespaces = ( · 16 unmodified lines | |||
| 286 | }; | 484 | }; |
| 287 | 485 | ||
| 288 | const catalogTools = ( | 486 | const catalogTools = ( |
| 289 | entries: ReadonlyArray<SourceToolEntry>, | 487 | snapshot: SourceToolSnapshot, |
| 290 | input: ToolRegistryCatalogToolsInput, | 488 | input: ToolRegistryCatalogToolsInput, |
| 291 | ): ToolRegistryCatalogToolsOutput => { | 489 | ): ToolRegistryCatalogToolsOutput => { |
| 292 | const limit = Math.max(1, Math.min(200, input.limit ?? 50)); | 490 | const limit = Math.max(1, Math.min(200, input.limit ?? 50)); |
| 293 | const query = (input.query ?? "").trim().toLowerCase(); | 491 | const query = (input.query ?? "").trim().toLowerCase(); |
| 294 | const namespace = (input.namespace ?? "").trim().toLowerCase(); | 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 | .filter((entry) => | 497 | .filter((entry) => |
| 298 | namespace.length === 0 ? true : entry.namespace.toLowerCase() === namespace, | 498 | namespace.length === 0 ? true : entry.namespace.toLowerCase() === namespace, |
| 299 | ) | 499 | ) |
| 300 | .map(summaryFromEntry) | 500 | .map((entry) => summarizeEntry(entry, includeSchemas, compact)) |
| 301 | .filter((summary) => scoreSummary(summary, query) > 0) | 501 | .filter((summary) => scoreSummary(summary, query) > 0) |
| 302 | .slice(0, limit); | 502 | .slice(0, limit); |
| 303 | 503 | ||
| 304 | return { | 504 | return { |
| 305 | results: filtered, | 505 | results: filtered, |
| 306 | total: filtered.length, | 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 | ): ToolRegistry => ({ | 513 | ): ToolRegistry => ({ |
| 313 | callTool: (input: ToolRegistryCallInput) => | 514 | callTool: (input: ToolRegistryCallInput) => |
| 314 | Effect.gen(function* () { | 515 | Effect.gen(function* () { |
| 315 | const entries = yield* loadSourceEntries(options); | 516 | const snapshot = yield* loadSourceEntries(options); |
| 316 | const entry = entries.find((candidate) => candidate.path === input.toolPath); | 517 | const entry = snapshot.entries.find( |
| 518 | (candidate) => candidate.path === input.toolPath, | ||
| 519 | ); | ||
| 317 | 520 | ||
| 318 | if (!entry) { | 521 | if (!entry) { |
| 319 | return yield* toRuntimeAdapterError( | 522 | return { |
| 320 | "call_tool", | 523 | ok: false, |
| 321 | `Unknown tool path: ${input.toolPath}`, | 524 | kind: "failed", |
| 322 | "Use tools.discover({ query }) or tools.catalog.tools({ namespace }) to find available tool paths.", | 525 | error: `Unknown tool path: ${input.toolPath}. Use tools.discover({ query }) or tools.catalog.tools({ namespace }) to find available tool paths.`, |
| 323 | ); | 526 | } satisfies RuntimeToolCallResult; |
| 324 | } | 527 | } |
| 325 | 528 | ||
| 326 | const invocation = yield* options.toolProviderRegistry | 529 | const invocation = yield* options.toolProviderRegistry |
| @@ -338,28 +541,31 @@ export const createSourceToolRegistry = ( · 11 unmodified lines | |||
| 338 | ); | 541 | ); |
| 339 | 542 | ||
| 340 | if (invocation.isError) { | 543 | if (invocation.isError) { |
| 341 | return yield* toRuntimeAdapterError( | 544 | return { |
| 342 | "call_tool", | 545 | ok: false, |
| 343 | `Tool call returned provider error: ${input.toolPath}`, | 546 | kind: "failed", |
| 344 | describeOutput(invocation.output), | 547 | error: describeOutput(invocation.output), |
| 345 | ); | 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 | discover: (input: ToolRegistryDiscoverInput) => | 557 | discover: (input: ToolRegistryDiscoverInput) => |
| 352 | loadSourceEntries(options).pipe( | 558 | loadSourceEntries(options).pipe( |
| 353 | Effect.map((entries) => discoverTools(entries, input)), | 559 | Effect.map((snapshot) => discoverTools(snapshot, input)), |
| 354 | ), | 560 | ), |
| 355 | 561 | ||
| 356 | catalogNamespaces: (input: ToolRegistryCatalogNamespacesInput) => | 562 | catalogNamespaces: (input: ToolRegistryCatalogNamespacesInput) => |
| 357 | loadSourceEntries(options).pipe( | 563 | loadSourceEntries(options).pipe( |
| 358 | Effect.map((entries) => catalogNamespaces(entries, input)), | 564 | Effect.map((snapshot) => catalogNamespaces(snapshot, input)), |
| 359 | ), | 565 | ), |
| 360 | 566 | ||
| 361 | catalogTools: (input: ToolRegistryCatalogToolsInput) => | 567 | catalogTools: (input: ToolRegistryCatalogToolsInput) => |
| 362 | loadSourceEntries(options).pipe( | 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 | describe("tool registry", () => { | 9 | describe("tool registry", () => { |
| 10 | it.effect("supports callTool plus discover and catalog", () => | 10 | it.effect("supports callTool plus discover and catalog", () => |
| 11 | Effect.gen(function* () { | 11 | Effect.gen(function* () { |
| 12 | const repoRefSchemaJson = | ||
| 13 | '{"properties":{"id":{"type":"number"}},"type":"object"}'; | ||
| 14 | |||
| 12 | const registry = createStaticToolRegistry({ | 15 | const registry = createStaticToolRegistry({ |
| 13 | tools: { | 16 | tools: { |
| 14 | search_docs: { | 17 | search_docs: { |
| @@ -17,11 +20,21 @@ describe("tool registry", () => { · 2 unmodified lines | |||
| 17 | }, | 20 | }, |
| 18 | "github.repos.get": { | 21 | "github.repos.get": { |
| 19 | description: "Get repository", | 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 | execute: (input: { owner: string; repo: string }) => ({ | 30 | execute: (input: { owner: string; repo: string }) => ({ |
| 21 | full_name: `${input.owner}/${input.repo}`, | 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 | const discovered = yield* registry.discover({ query: "github", limit: 5 }); | 40 | const discovered = yield* registry.discover({ query: "github", limit: 5 }); |
| @@ -29,6 +42,29 @@ describe("tool registry", () => { · 1 unmodified line | |||
| 29 | expect(discovered.results.some((entry) => entry.path === "github.repos.get")).toBe( | 42 | expect(discovered.results.some((entry) => entry.path === "github.repos.get")).toBe( |
| 30 | true, | 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 | const namespaces = yield* registry.catalogNamespaces({}); | 69 | const namespaces = yield* registry.catalogNamespaces({}); |
| 34 | expect(namespaces.namespaces.map((namespace) => namespace.namespace)).toEqual([ | 70 | expect(namespaces.namespaces.map((namespace) => namespace.namespace)).toEqual([ |
| @@ -50,7 +86,10 @@ describe("tool registry", () => { · 15 unmodified lines | |||
| 50 | }, | 86 | }, |
| 51 | }); | 87 | }); |
| 52 | expect(callResult).toEqual({ | 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 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 2 | 7 | ||
| 3 | import { | 8 | import { |
| @@ -36,12 +41,16 @@ export type ToolRegistryToolSummary = { · 32 unmodified lines | |||
| 36 | source?: string; | 41 | source?: string; |
| 37 | approval: "auto" | "required"; | 42 | approval: "auto" | "required"; |
| 38 | description?: string; | 43 | description?: string; |
| 44 | inputHint?: string; | ||
| 45 | outputHint?: string; | ||
| 46 | typing?: DiscoveryTypingPayload; | ||
| 39 | }; | 47 | }; |
| 40 | 48 | ||
| 41 | export type ToolRegistryDiscoverOutput = { | 49 | export type ToolRegistryDiscoverOutput = { |
| 42 | bestPath: string | null; | 50 | bestPath: string | null; |
| 43 | results: Array<ToolRegistryToolSummary>; | 51 | results: Array<ToolRegistryToolSummary>; |
| 44 | total: number; | 52 | total: number; |
| 53 | refHintTable?: Record<string, string>; | ||
| 45 | }; | 54 | }; |
| 46 | 55 | ||
| 47 | export type ToolRegistryCatalogNamespacesOutput = { | 56 | export type ToolRegistryCatalogNamespacesOutput = { |
| @@ -56,10 +65,13 @@ export type ToolRegistryCatalogNamespacesOutput = { · 8 unmodified lines | |||
| 56 | export type ToolRegistryCatalogToolsOutput = { | 65 | export type ToolRegistryCatalogToolsOutput = { |
| 57 | results: Array<ToolRegistryToolSummary>; | 66 | results: Array<ToolRegistryToolSummary>; |
| 58 | total: number; | 67 | total: number; |
| 68 | refHintTable?: Record<string, string>; | ||
| 59 | }; | 69 | }; |
| 60 | 70 | ||
| 61 | export type ToolRegistry = { | 71 | export type ToolRegistry = { |
| 62 | callTool: (input: ToolRegistryCallInput) => Effect.Effect<unknown, RuntimeAdapterError>; | 72 | callTool: ( |
| 73 | input: ToolRegistryCallInput, | ||
| 74 | ) => Effect.Effect<RuntimeToolCallResult, RuntimeAdapterError>; | ||
| 63 | discover: ( | 75 | discover: ( |
| 64 | input: ToolRegistryDiscoverInput, | 76 | input: ToolRegistryDiscoverInput, |
| 65 | ) => Effect.Effect<ToolRegistryDiscoverOutput, RuntimeAdapterError>; | 77 | ) => Effect.Effect<ToolRegistryDiscoverOutput, RuntimeAdapterError>; |
| @@ -74,15 +86,18 @@ export type ToolRegistry = { · 8 unmodified lines | |||
| 74 | export type InMemorySandboxTool = { | 86 | export type InMemorySandboxTool = { |
| 75 | description?: string | null; | 87 | description?: string | null; |
| 76 | execute?: (...args: Array<any>) => Promise<any> | any; | 88 | execute?: (...args: Array<any>) => Promise<any> | any; |
| 89 | typing?: DiscoveryTypingPayload; | ||
| 77 | }; | 90 | }; |
| 78 | 91 | ||
| 79 | export type InMemorySandboxToolMap = Record<string, InMemorySandboxTool>; | 92 | export type InMemorySandboxToolMap = Record<string, InMemorySandboxTool>; |
| 80 | 93 | ||
| 81 | type StaticToolRegistryOptions = { | 94 | type StaticToolRegistryOptions = { |
| 82 | tools: InMemorySandboxToolMap; | 95 | tools: InMemorySandboxToolMap; |
| 96 | refHintTable?: Record<string, string>; | ||
| 83 | }; | 97 | }; |
| 84 | 98 | ||
| 85 | const staticToolRegistryRuntimeKind = "static-tool-registry"; | 99 | const staticToolRegistryRuntimeKind = "static-tool-registry"; |
| 100 | const runtimeToolCallServiceRuntimeKind = "tool-registry-runtime-tool-call-service"; | ||
| 86 | 101 | ||
| 87 | const toRuntimeAdapterError = ( | 102 | const toRuntimeAdapterError = ( |
| 88 | operation: string, | 103 | operation: string, |
| @@ -136,19 +151,149 @@ const normalizeCatalogToolsInput = (input: unknown): ToolRegistryCatalogToolsInp · 47 unmodified lines | |||
| 136 | }; | 151 | }; |
| 137 | }; | 152 | }; |
| 138 | 153 | ||
| 139 | const asToolSummaries = ( | 154 | const parseJson = (value: string): unknown | null => { |
| 140 | tools: InMemorySandboxToolMap, | 155 | try { |
| 141 | ): Array<ToolRegistryToolSummary> => | 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 | Object.entries(tools) | 285 | Object.entries(tools) |
| 143 | .sort(([leftPath], [rightPath]) => leftPath.localeCompare(rightPath)) | 286 | .sort(([leftPath], [rightPath]) => leftPath.localeCompare(rightPath)) |
| 144 | .map(([path, tool]) => ({ | 287 | .map(([path, tool]) => ({ |
| 145 | path, | 288 | path, |
| 146 | source: "in-memory", | ||
| 147 | approval: "auto" as const, | ||
| 148 | description: tool.description ?? undefined, | 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 | if (query.length === 0) { | 297 | if (query.length === 0) { |
| 153 | return 1; | 298 | return 1; |
| 154 | } | 299 | } |
| @@ -178,12 +323,15 @@ const scorePath = (entry: ToolRegistryToolSummary, query: string): number => { · 23 unmodified lines | |||
| 178 | 323 | ||
| 179 | const inMemoryDiscover = ( | 324 | const inMemoryDiscover = ( |
| 180 | tools: InMemorySandboxToolMap, | 325 | tools: InMemorySandboxToolMap, |
| 326 | refHintTable: Record<string, string> | undefined, | ||
| 181 | input: ToolRegistryDiscoverInput, | 327 | input: ToolRegistryDiscoverInput, |
| 182 | ): ToolRegistryDiscoverOutput => { | 328 | ): ToolRegistryDiscoverOutput => { |
| 183 | const limit = Math.max(1, Math.min(50, input.limit ?? 8)); | 329 | const limit = Math.max(1, Math.min(50, input.limit ?? 8)); |
| 184 | const query = (input.query ?? "").trim().toLowerCase(); | 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 | .map((entry) => ({ | 335 | .map((entry) => ({ |
| 188 | entry, | 336 | entry, |
| 189 | score: scorePath(entry, query), | 337 | score: scorePath(entry, query), |
| @@ -191,12 +339,22 @@ const inMemoryDiscover = ( · 1 unmodified line | |||
| 191 | .filter((item) => item.score > 0) | 339 | .filter((item) => item.score > 0) |
| 192 | .sort((left, right) => right.score - left.score) | 340 | .sort((left, right) => right.score - left.score) |
| 193 | .slice(0, limit) | 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 | return { | 353 | return { |
| 197 | bestPath: ranked[0]?.path ?? null, | 354 | bestPath: ranked[0]?.path ?? null, |
| 198 | results: ranked, | 355 | results: ranked, |
| 199 | total: ranked.length, | 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 | const inMemoryCatalogTools = ( | 389 | const inMemoryCatalogTools = ( |
| 232 | tools: InMemorySandboxToolMap, | 390 | tools: InMemorySandboxToolMap, |
| 391 | refHintTable: Record<string, string> | undefined, | ||
| 233 | input: ToolRegistryCatalogToolsInput, | 392 | input: ToolRegistryCatalogToolsInput, |
| 234 | ): ToolRegistryCatalogToolsOutput => { | 393 | ): ToolRegistryCatalogToolsOutput => { |
| 235 | const limit = Math.max(1, Math.min(200, input.limit ?? 50)); | 394 | const limit = Math.max(1, Math.min(200, input.limit ?? 50)); |
| 236 | const query = (input.query ?? "").trim().toLowerCase(); | 395 | const query = (input.query ?? "").trim().toLowerCase(); |
| 237 | const namespace = (input.namespace ?? "").trim().toLowerCase(); | 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 | .filter((entry) => { | 401 | .filter((entry) => { |
| 241 | if (namespace.length > 0) { | 402 | if (namespace.length > 0) { |
| 242 | const entryNamespace = entry.path.split(".")[0]?.toLowerCase() ?? ""; | 403 | const entryNamespace = entry.path.split(".")[0]?.toLowerCase() ?? ""; |
| @@ -247,11 +408,22 @@ const inMemoryCatalogTools = ( · 4 unmodified lines | |||
| 247 | 408 | ||
| 248 | return scorePath(entry, query) > 0; | 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 | return { | 423 | return { |
| 253 | results: filtered, | 424 | results: filtered, |
| 254 | total: filtered.length, | 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 | callTool: (input) => { | 433 | callTool: (input) => { |
| 262 | const implementation = options.tools[input.toolPath]; | 434 | const implementation = options.tools[input.toolPath]; |
| 263 | if (!implementation) { | 435 | if (!implementation) { |
| 264 | return new RuntimeAdapterError({ | 436 | return Effect.succeed<RuntimeToolCallResult>({ |
| 265 | operation: "call_tool", | 437 | ok: false, |
| 266 | runtimeKind: staticToolRegistryRuntimeKind, | 438 | kind: "failed", |
| 267 | message: `Unknown in-memory tool: ${input.toolPath}`, | 439 | error: `Unknown in-memory tool: ${input.toolPath}`, |
| 268 | details: null, | ||
| 269 | }); | 440 | }); |
| 270 | } | 441 | } |
| 271 | 442 | ||
| 272 | if (!implementation.execute) { | 443 | if (!implementation.execute) { |
| 273 | return new RuntimeAdapterError({ | 444 | return Effect.succeed<RuntimeToolCallResult>({ |
| 274 | operation: "call_tool", | 445 | ok: false, |
| 275 | runtimeKind: staticToolRegistryRuntimeKind, | 446 | kind: "failed", |
| 276 | message: `In-memory tool '${input.toolPath}' has no execute function`, | 447 | error: `In-memory tool '${input.toolPath}' has no execute function`, |
| 277 | details: null, | ||
| 278 | }); | 448 | }); |
| 279 | } | 449 | } |
| 280 | 450 | ||
| @@ -286,14 +456,63 @@ export const createStaticToolRegistry = ( · 5 unmodified lines | |||
| 286 | `In-memory tool invocation failed: ${input.toolPath}`, | 456 | `In-memory tool invocation failed: ${input.toolPath}`, |
| 287 | String(cause), | 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 | catalogNamespaces: (input) => | 470 | catalogNamespaces: (input) => |
| 293 | Effect.succeed(inMemoryCatalogNamespaces(options.tools, input)), | 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 | export const createRuntimeToolCallService = ( | 516 | export const createRuntimeToolCallService = ( |
| 298 | toolRegistry: ToolRegistry, | 517 | toolRegistry: ToolRegistry, |
| 299 | ): RuntimeToolCallService => ({ | 518 | ): RuntimeToolCallService => ({ |
| @@ -312,6 +531,8 @@ export const createRuntimeToolCallService = ( · 12 unmodified lines | |||
| 312 | return toolRegistry.catalogTools(normalizeCatalogToolsInput(input.input)); | 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 | OpenApiToolParameterSchema, | 13 | OpenApiToolParameterSchema, |
| 14 | OpenApiToolRequestBodySchema, | 14 | OpenApiToolRequestBodySchema, |
| 15 | ToolArtifactIdSchema, | 15 | ToolArtifactIdSchema, |
| 16 | type DiscoveryTypingPayload, | ||
| 16 | type OpenApiExtractedTool, | 17 | type OpenApiExtractedTool, |
| 17 | type OpenApiHttpMethod, | 18 | type OpenApiHttpMethod, |
| 18 | type OpenApiInvocationPayload, | 19 | type OpenApiInvocationPayload, |
| @@ -56,6 +57,7 @@ type ExtractedToolRequestBody = OpenApiToolRequestBody; · 37 unmodified lines | |||
| 56 | type ExtractedToolInvocation = OpenApiInvocationPayload; | 57 | type ExtractedToolInvocation = OpenApiInvocationPayload; |
| 57 | type ExtractedTool = OpenApiExtractedTool; | 58 | type ExtractedTool = OpenApiExtractedTool; |
| 58 | type ToolManifest = OpenApiToolManifest; | 59 | type ToolManifest = OpenApiToolManifest; |
| 60 | type DiscoveryTyping = DiscoveryTypingPayload; | ||
| 59 | 61 | ||
| 60 | const ToolManifestFromJsonSchema = Schema.parseJson(ToolManifestSchema); | 62 | const ToolManifestFromJsonSchema = Schema.parseJson(ToolManifestSchema); |
| 61 | const encodeManifestToJson = Schema.encode(ToolManifestFromJsonSchema); | 63 | const encodeManifestToJson = Schema.encode(ToolManifestFromJsonSchema); |
| @@ -195,6 +197,266 @@ const buildInvocationMetadata = ( · 133 unmodified lines | |||
| 195 | requestBody: extractRequestBody(operation), | 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 | const toStableValue = (value: unknown): unknown => { | 460 | const toStableValue = (value: unknown): unknown => { |
| 199 | if (Array.isArray(value)) { | 461 | if (Array.isArray(value)) { |
| 200 | return value.map(toStableValue); | 462 | return value.map(toStableValue); |
| @@ -348,6 +610,7 @@ export const extractOpenApiManifest = ( · 147 unmodified lines | |||
| 348 | pathItem, | 610 | pathItem, |
| 349 | operation, | 611 | operation, |
| 350 | ); | 612 | ); |
| 613 | const typing = buildToolTyping(pathItem, operation, invocation); | ||
| 351 | 614 | ||
| 352 | tools.push({ | 615 | tools.push({ |
| 353 | toolId: buildToolId(method, pathValue, operation), | 616 | toolId: buildToolId(method, pathValue, operation), |
| @@ -362,6 +625,7 @@ export const extractOpenApiManifest = ( · 8 unmodified lines | |||
| 362 | operation, | 625 | operation, |
| 363 | invocation, | 626 | invocation, |
| 364 | }), | 627 | }), |
| 628 | typing, | ||
| 365 | }); | 629 | }); |
| 366 | } | 630 | } |
| 367 | } | 631 | } |
| @@ -369,10 +633,16 @@ export const extractOpenApiManifest = ( · 1 unmodified line | |||
| 369 | tools.sort((left, right) => left.toolId.localeCompare(right.toolId)); | 633 | tools.sort((left, right) => left.toolId.localeCompare(right.toolId)); |
| 370 | yield* ensureUniqueToolIds(sourceName, tools); | 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 | return { | 641 | return { |
| 373 | version: 1 as const, | 642 | version: 1 as const, |
| 374 | sourceHash: hashUnknown(openApiSpec), | 643 | sourceHash: hashUnknown(openApiSpec), |
| 375 | tools, | 644 | tools, |
| 645 | refHintTable: Object.keys(refHintTable).length > 0 ? refHintTable : undefined, | ||
| 376 | }; | 646 | }; |
| 377 | }).pipe(Effect.mapError((cause) => toExtractionError(sourceName, "extract", cause))); | 647 | }).pipe(Effect.mapError((cause) => toExtractionError(sourceName, "extract", cause))); |
| 378 | 648 | ||
| @@ -7,7 +7,9 @@ · 6 unmodified lines | |||
| 7 | }, | 7 | }, |
| 8 | "scripts": { | 8 | "scripts": { |
| 9 | "test": "bunx vitest run --no-watch src/*.integration.test.ts", | 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 | "dependencies": { | 14 | "dependencies": { |
| 13 | "@effect/platform": "catalog:", | 15 | "@effect/platform": "catalog:", |
| @@ -1,8 +1,13 @@ | |||
| 1 | import { spawn } from "node:child_process"; | 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 | import { fileURLToPath } from "node:url"; | 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 | import * as Effect from "effect/Effect"; | 11 | import * as Effect from "effect/Effect"; |
| 7 | import * as Either from "effect/Either"; | 12 | import * as Either from "effect/Either"; |
| 8 | 13 | ||
| @@ -11,9 +16,19 @@ import { makeCloudflareWorkerLoaderRuntimeAdapter } from "./index"; · 2 unmodified lines | |||
| 11 | 16 | ||
| 12 | type WorkerProcess = ReturnType<typeof spawn>; | 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 | const getFreePort = async (): Promise<number> => | 29 | const getFreePort = async (): Promise<number> => |
| 15 | await new Promise<number>((resolve, reject) => { | 30 | await new Promise<number>((resolve, reject) => { |
| 16 | const server = createServer(); | 31 | const server = createNetServer(); |
| 17 | 32 | ||
| 18 | server.listen(0, "127.0.0.1", () => { | 33 | server.listen(0, "127.0.0.1", () => { |
| 19 | const address = server.address(); | 34 | const address = server.address(); |
| @@ -83,16 +98,156 @@ const stopWorkerProcess = async (process: WorkerProcess): Promise<void> => · 63 unmodified lines | |||
| 83 | process.kill("SIGTERM"); | 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 | describe("cloudflare worker loader runtime adapter integration", () => { | 125 | describe("cloudflare worker loader runtime adapter integration", () => { |
| 87 | let workerProcess: WorkerProcess | null = null; | 126 | let workerProcess: WorkerProcess | null = null; |
| 88 | let workerPort = 0; | 127 | let workerPort = 0; |
| 89 | let workerBaseUrl = ""; | 128 | let workerBaseUrl = ""; |
| 90 | 129 | ||
| 130 | let callbackServer: ReturnType<typeof createHttpServer> | null = null; | ||
| 131 | let callbackBaseUrl = ""; | ||
| 132 | let callbackCalls: Array<CallbackToolCall> = []; | ||
| 133 | |||
| 91 | beforeAll(async () => { | 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 | workerPort = await getFreePort(); | 247 | workerPort = await getFreePort(); |
| 93 | workerBaseUrl = `http://127.0.0.1:${workerPort}`; | 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 | const spawned = spawn( | 252 | const spawned = spawn( |
| 98 | "bunx", | 253 | "bunx", |
| @@ -116,7 +271,7 @@ describe("cloudflare worker loader runtime adapter integration", () => { · 17 unmodified lines | |||
| 116 | workerProcess = spawned; | 271 | workerProcess = spawned; |
| 117 | 272 | ||
| 118 | spawned.stdout?.on("data", () => { | 273 | spawned.stdout?.on("data", () => { |
| 119 | // Ignore stdout, but keep stream flowing. | 274 | // Keep stream flowing. |
| 120 | }); | 275 | }); |
| 121 | 276 | ||
| 122 | spawned.stderr?.on("data", () => { | 277 | spawned.stderr?.on("data", () => { |
| @@ -126,48 +281,79 @@ describe("cloudflare worker loader runtime adapter integration", () => { · 3 unmodified lines | |||
| 126 | await waitForWorker(workerBaseUrl, 45_000); | 281 | await waitForWorker(workerBaseUrl, 45_000); |
| 127 | }); | 282 | }); |
| 128 | 283 | ||
| 284 | beforeEach(() => { | ||
| 285 | callbackCalls = []; | ||
| 286 | }); | ||
| 287 | |||
| 129 | afterAll(async () => { | 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 | if (workerProcess) { | 302 | if (workerProcess) { |
| 131 | await stopWorkerProcess(workerProcess); | 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 | Effect.gen(function* () { | 308 | Effect.gen(function* () { |
| 137 | const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({ | 309 | const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({ |
| 138 | runUrl: `${workerBaseUrl}/v1/runs?scenario=completed`, | 310 | runUrl: `${workerBaseUrl}/v1/runs`, |
| 139 | authToken: "sandbox-token", | 311 | authToken: "sandbox-token", |
| 140 | callbackUrl: "https://callbacks.example.test/runtimeCallbacks", | 312 | callbackUrl: `${callbackBaseUrl}/tool-call`, |
| 141 | callbackInternalSecret: "internal-token", | 313 | callbackInternalSecret: CALLBACK_SECRET, |
| 142 | }); | 314 | }); |
| 143 | 315 | ||
| 144 | const result = yield* adapter.execute({ | 316 | const result = yield* adapter.execute({ |
| 145 | runId: "run_e2e_1", | 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({ | 321 | expect(result).toEqual({ |
| 150 | echoedRunId: "run_e2e_1", | 322 | left: 6, |
| 151 | callback: { | 323 | right: 9, |
| 152 | url: "https://callbacks.example.test/runtimeCallbacks", | ||
| 153 | internalSecret: "internal-token", | ||
| 154 | }, | ||
| 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 | Effect.gen(function* () { | 345 | Effect.gen(function* () { |
| 161 | const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({ | 346 | const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({ |
| 162 | runUrl: `${workerBaseUrl}/v1/runs?scenario=failed`, | 347 | runUrl: `${workerBaseUrl}/v1/runs`, |
| 163 | authToken: "sandbox-token", | 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 | const result = yield* Effect.either( | 353 | const result = yield* Effect.either( |
| 168 | adapter.execute({ | 354 | adapter.execute({ |
| 169 | runId: "run_e2e_2", | 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 | if (Either.isLeft(result)) { | 361 | if (Either.isLeft(result)) { |
| 176 | expect(result.left).toBeInstanceOf(RuntimeAdapterError); | 362 | expect(result.left).toBeInstanceOf(RuntimeAdapterError); |
| 177 | expect(result.left.operation).toBe("execute_terminal"); | 363 | expect(result.left.operation).toBe("execute_terminal"); |
| 178 | expect(result.left.message).toContain("failed"); | 364 | expect(result.left.message).toContain("denied"); |
| 179 | expect(result.left.details).toBe("tool call 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 | Effect.gen(function* () { | 371 | Effect.gen(function* () { |
| 186 | const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({ | 372 | const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({ |
| 187 | runUrl: `${workerBaseUrl}/v1/runs?scenario=http-500`, | 373 | runUrl: `${workerBaseUrl}/v1/runs`, |
| 188 | authToken: "sandbox-token", | 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 | const result = yield* Effect.either( | 379 | const result = yield* Effect.either( |
| 193 | adapter.execute({ | 380 | adapter.execute({ |
| 194 | runId: "run_e2e_3", | 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 | code: "return 0;", | 433 | code: "return 0;", |
| 196 | }), | 434 | }), |
| 197 | ); | 435 | ); |
| @@ -199,22 +437,23 @@ describe("cloudflare worker loader runtime adapter integration", () => { · 1 unmodified line | |||
| 199 | expect(Either.isLeft(result)).toBe(true); | 437 | expect(Either.isLeft(result)).toBe(true); |
| 200 | if (Either.isLeft(result)) { | 438 | if (Either.isLeft(result)) { |
| 201 | expect(result.left.operation).toBe("dispatch"); | 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 | Effect.gen(function* () { | 446 | Effect.gen(function* () { |
| 209 | const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({ | 447 | const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({ |
| 210 | runUrl: `${workerBaseUrl}/v1/runs?scenario=invalid-json`, | 448 | runUrl: `${workerBaseUrl}/health`, |
| 211 | authToken: "sandbox-token", | 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 | const result = yield* Effect.either( | 454 | const result = yield* Effect.either( |
| 216 | adapter.execute({ | 455 | adapter.execute({ |
| 217 | runId: "run_e2e_4", | 456 | runId: "run_e2e_6", |
| 218 | code: "return 0;", | 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 | } | ||