History

feat: enhance cloudflare worker-loader with callback integration and schema extraction

- 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.

Rhys Sullivan committed 8 months ago

Commit ca7fca81 parent f202f1e12 files · +1194 −252Browse files

Unified · Split

12 files
  1. opencode.json +0 −5
  2. v2/packages/engine/src/index.ts +1 −0
  3. v2/packages/engine/src/source-tool-registry.test.ts +24 −4
  4. v2/packages/engine/src/source-tool-registry.ts +268 −62
  5. v2/packages/engine/src/tool-registry.test.ts +40 −1
  6. v2/packages/engine/src/tool-registry.ts +246 −25
  7. v2/packages/management-api/src/openapi-extraction.test.ts +73 −0
  8. v2/packages/management-api/src/openapi-extraction.ts +270 −0
  9. v2/packages/runtime-cloudflare-worker-loader/package.json +3 −1
  10. v2/packages/runtime-cloudflare-worker-loader/src/index.integration.test.ts +269 −30
  11. v2/packages/runtime-cloudflare-worker-loader/test-worker/src/index.ts +0 −118
  12. v2/packages/runtime-cloudflare-worker-loader/test-worker/wrangler.jsonc +0 −6
opencode.json+0−5
@@ -5,11 +5,6 @@ · 4 unmodified lines
55 "type": "remote",
66 "url": "https://www.answeroverflow.com/mcp",
77 "enabled": true
8− },
9− "oracle": {
10− "type": "local",
11− "command": ["npx", "-y", "@steipete/oracle", "oracle-mcp"],
12− "enabled": true
138 }
149 },
1510 "agent": {
v2/packages/engine/src/index.ts+1−0
@@ -18,6 +18,7 @@ export { · 17 unmodified lines
1818 } from "./run-client";
1919
2020 export {
21+ createRuntimeToolCallResultHandler,
2122 createRuntimeToolCallService,
2223 createStaticToolRegistry,
2324 type InMemorySandboxTool,
v2/packages/engine/src/source-tool-registry.test.ts+24−4
@@ -226,6 +226,23 @@ describe("source tool registry", () => { · 225 unmodified lines
226226
227227 expect(discovered.bestPath).not.toBeNull();
228228 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();
229246
230247 const bestPath = discovered.bestPath;
231248 if (!bestPath) {
@@ -243,10 +260,13 @@ describe("source tool registry", () => { · 11 unmodified lines
243260 });
244261
245262 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+ },
250270 },
251271 });
252272
v2/packages/engine/src/source-tool-registry.ts+268−62
@@ -4,9 +4,17 @@ import { · 3 unmodified lines
44 type ToolArtifactStore,
55 type ToolArtifactStoreError,
66 } 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";
815 import * as Effect from "effect/Effect";
916 import * as Option from "effect/Option";
17+import * as Schema from "effect/Schema";
1018
1119 import { openApiToolDescriptorsFromManifest } from "./openapi-provider";
1220 import {
@@ -14,7 +22,6 @@ import { · 1 unmodified line
1422 type RuntimeAdapterKind,
1523 } from "./runtime-adapters";
1624 import type {
17− CanonicalToolDescriptor,
1825 ToolProviderError,
1926 ToolProviderRegistry,
2027 ToolProviderRegistryError,
@@ -45,8 +52,18 @@ type SourceToolEntry = { · 24 unmodified lines
4552 namespace: string;
4653 source: Source;
4754 descriptor: CanonicalToolDescriptor;
55+ typing?: DiscoveryTypingPayload;
56+};
57+
58+type SourceToolSnapshot = {
59+ entries: ReadonlyArray<SourceToolEntry>;
60+ refHintTable: Record<string, string>;
4861 };
4962
63+const decodeOpenApiManifestJson = Schema.decodeUnknown(
64+ Schema.parseJson(OpenApiToolManifestSchema),
65+);
66+
5067 const toRuntimeAdapterError = (
5168 operation: string,
5269 message: string,
@@ -111,7 +128,64 @@ const sourceToolPath = ( · 58 unmodified lines
111128 descriptor: CanonicalToolDescriptor,
112129 ): string => `${sourceNamespace(source)}.${descriptor.toolId}`;
113130
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 => {
115189 if (query.length === 0) {
116190 return 1;
117191 }
@@ -120,6 +194,8 @@ const scoreSummary = (summary: ToolRegistryToolSummary, query: string): number = · 2 unmodified lines
120194 const lowerPath = summary.path.toLowerCase();
121195 const lowerSource = (summary.source ?? "").toLowerCase();
122196 const lowerDescription = (summary.description ?? "").toLowerCase();
197+ const lowerInputHint = (summary.inputHint ?? "").toLowerCase();
198+ const lowerOutputHint = (summary.outputHint ?? "").toLowerCase();
123199
124200 if (lowerPath === lowerQuery) {
125201 return 100;
@@ -141,16 +217,71 @@ const scoreSummary = (summary: ToolRegistryToolSummary, query: string): number = · 15 unmodified lines
141217 return 30;
142218 }
143219
220+ if (lowerInputHint.includes(lowerQuery) || lowerOutputHint.includes(lowerQuery)) {
221+ return 20;
222+ }
223+
144224 return 0;
145225 };
146226
147−const summaryFromEntry = (entry: SourceToolEntry): ToolRegistryToolSummary => ({
227+const summarizeEntry = (
228+ entry: SourceToolEntry,
229+ includeSchemas: boolean,
230+ compact: boolean,
231+): ToolRegistryToolSummary => ({
148232 path: entry.path,
149233 source: entry.source.name,
150234 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,
152243 });
153244
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+
154285 const normalizeToolCallInput = (
155286 input: unknown,
156287 ): Record<string, unknown> => {
@@ -175,7 +306,7 @@ const describeOutput = (value: unknown): string => { · 18 unmodified lines
175306
176307 const loadSourceEntries = (
177308 options: SourceToolRegistryOptions,
178−): Effect.Effect<ReadonlyArray<SourceToolEntry>, RuntimeAdapterError> =>
309+): Effect.Effect<SourceToolSnapshot, RuntimeAdapterError> =>
179310 Effect.gen(function* () {
180311 const workspaceSources = yield* options.sourceStore
181312 .listByWorkspace(options.workspaceId as WorkspaceId)
@@ -187,62 +318,128 @@ const loadSourceEntries = ( · 5 unmodified lines
187318
188319 const enabledSources = workspaceSources.filter((source) => source.enabled);
189320
190− const bySource = yield* Effect.forEach(enabledSources, (source) =>
321+ const loadedPerSource = yield* Effect.forEach(enabledSources, (source) =>
191322 Effect.gen(function* () {
192− if (source.kind !== "openapi") {
193− return [] as Array<SourceToolEntry>;
194− }
323+ const namespace = sourceNamespace(source);
195324
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(
199359 Effect.mapError((cause) =>
200− toolArtifactStoreErrorToRuntimeAdapterError("get_source_artifact", cause),
360+ toolProviderErrorToRuntimeAdapterError("decode_source_manifest", cause),
201361 ),
202362 );
203363
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+ };
206378 }
207379
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+ ),
217393 ),
218− ),
219− );
394+ Effect.either,
395+ );
220396
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+ }
222403
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+ };
229413 }),
230414 );
231415
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+ };
233427 });
234428
235429 const discoverTools = (
236− entries: ReadonlyArray<SourceToolEntry>,
430+ snapshot: SourceToolSnapshot,
237431 input: ToolRegistryDiscoverInput,
238432 ): ToolRegistryDiscoverOutput => {
239433 const limit = Math.max(1, Math.min(50, input.limit ?? 8));
240434 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),
246443 }))
247444 .filter((item) => item.score > 0)
248445 .sort((left, right) => right.score - left.score)
@@ -253,17 +450,18 @@ const discoverTools = ( · 4 unmodified lines
253450 bestPath: ranked[0]?.path ?? null,
254451 results: ranked,
255452 total: ranked.length,
453+ refHintTable: pickRefHintTable(snapshot, ranked, includeSchemas),
256454 };
257455 };
258456
259457 const catalogNamespaces = (
260− entries: ReadonlyArray<SourceToolEntry>,
458+ snapshot: SourceToolSnapshot,
261459 input: ToolRegistryCatalogNamespacesInput,
262460 ): ToolRegistryCatalogNamespacesOutput => {
263461 const limit = Math.max(1, Math.min(200, input.limit ?? 50));
264462 const grouped = new Map<string, Array<string>>();
265463
266− for (const entry of entries) {
464+ for (const entry of snapshot.entries) {
267465 const paths = grouped.get(entry.namespace) ?? [];
268466 paths.push(entry.path);
269467 grouped.set(entry.namespace, paths);
@@ -286,24 +484,27 @@ const catalogNamespaces = ( · 16 unmodified lines
286484 };
287485
288486 const catalogTools = (
289− entries: ReadonlyArray<SourceToolEntry>,
487+ snapshot: SourceToolSnapshot,
290488 input: ToolRegistryCatalogToolsInput,
291489 ): ToolRegistryCatalogToolsOutput => {
292490 const limit = Math.max(1, Math.min(200, input.limit ?? 50));
293491 const query = (input.query ?? "").trim().toLowerCase();
294492 const namespace = (input.namespace ?? "").trim().toLowerCase();
493+ const includeSchemas = input.includeSchemas === true;
494+ const compact = input.compact === true;
295495
296− const filtered = entries
496+ const filtered = snapshot.entries
297497 .filter((entry) =>
298498 namespace.length === 0 ? true : entry.namespace.toLowerCase() === namespace,
299499 )
300− .map(summaryFromEntry)
500+ .map((entry) => summarizeEntry(entry, includeSchemas, compact))
301501 .filter((summary) => scoreSummary(summary, query) > 0)
302502 .slice(0, limit);
303503
304504 return {
305505 results: filtered,
306506 total: filtered.length,
507+ refHintTable: pickRefHintTable(snapshot, filtered, includeSchemas),
307508 };
308509 };
309510
@@ -312,15 +513,17 @@ export const createSourceToolRegistry = ( · 2 unmodified lines
312513 ): ToolRegistry => ({
313514 callTool: (input: ToolRegistryCallInput) =>
314515 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+ );
317520
318521 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;
324527 }
325528
326529 const invocation = yield* options.toolProviderRegistry
@@ -338,28 +541,31 @@ export const createSourceToolRegistry = ( · 11 unmodified lines
338541 );
339542
340543 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;
346549 }
347550
348− return invocation.output;
551+ return {
552+ ok: true,
553+ value: invocation.output,
554+ } satisfies RuntimeToolCallResult;
349555 }),
350556
351557 discover: (input: ToolRegistryDiscoverInput) =>
352558 loadSourceEntries(options).pipe(
353− Effect.map((entries) => discoverTools(entries, input)),
559+ Effect.map((snapshot) => discoverTools(snapshot, input)),
354560 ),
355561
356562 catalogNamespaces: (input: ToolRegistryCatalogNamespacesInput) =>
357563 loadSourceEntries(options).pipe(
358− Effect.map((entries) => catalogNamespaces(entries, input)),
564+ Effect.map((snapshot) => catalogNamespaces(snapshot, input)),
359565 ),
360566
361567 catalogTools: (input: ToolRegistryCatalogToolsInput) =>
362568 loadSourceEntries(options).pipe(
363− Effect.map((entries) => catalogTools(entries, input)),
569+ Effect.map((snapshot) => catalogTools(snapshot, input)),
364570 ),
365571 });
v2/packages/engine/src/tool-registry.test.ts+40−1
@@ -9,6 +9,9 @@ import { · 8 unmodified lines
99 describe("tool registry", () => {
1010 it.effect("supports callTool plus discover and catalog", () =>
1111 Effect.gen(function* () {
12+ const repoRefSchemaJson =
13+ '{"properties":{"id":{"type":"number"}},"type":"object"}';
14+
1215 const registry = createStaticToolRegistry({
1316 tools: {
1417 search_docs: {
@@ -17,11 +20,21 @@ describe("tool registry", () => { · 2 unmodified lines
1720 },
1821 "github.repos.get": {
1922 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+ },
2030 execute: (input: { owner: string; repo: string }) => ({
2131 full_name: `${input.owner}/${input.repo}`,
2232 }),
2333 },
2434 },
35+ refHintTable: {
36+ "#/components/schemas/Repo": repoRefSchemaJson,
37+ },
2538 });
2639
2740 const discovered = yield* registry.discover({ query: "github", limit: 5 });
@@ -29,6 +42,29 @@ describe("tool registry", () => { · 1 unmodified line
2942 expect(discovered.results.some((entry) => entry.path === "github.repos.get")).toBe(
3043 true,
3144 );
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();
3268
3369 const namespaces = yield* registry.catalogNamespaces({});
3470 expect(namespaces.namespaces.map((namespace) => namespace.namespace)).toEqual([
@@ -50,7 +86,10 @@ describe("tool registry", () => { · 15 unmodified lines
5086 },
5187 });
5288 expect(callResult).toEqual({
53− full_name: "octocat/hello-world",
89+ ok: true,
90+ value: {
91+ full_name: "octocat/hello-world",
92+ },
5493 });
5594 }),
5695 );
v2/packages/engine/src/tool-registry.ts+246−25
@@ -1,3 +1,8 @@
1+import type {
2+ RuntimeToolCallRequest,
3+ RuntimeToolCallResult,
4+} from "@executor-v2/sdk";
5+import type { DiscoveryTypingPayload } from "@executor-v2/schema";
16 import * as Effect from "effect/Effect";
27
38 import {
@@ -36,12 +41,16 @@ export type ToolRegistryToolSummary = { · 32 unmodified lines
3641 source?: string;
3742 approval: "auto" | "required";
3843 description?: string;
44+ inputHint?: string;
45+ outputHint?: string;
46+ typing?: DiscoveryTypingPayload;
3947 };
4048
4149 export type ToolRegistryDiscoverOutput = {
4250 bestPath: string | null;
4351 results: Array<ToolRegistryToolSummary>;
4452 total: number;
53+ refHintTable?: Record<string, string>;
4554 };
4655
4756 export type ToolRegistryCatalogNamespacesOutput = {
@@ -56,10 +65,13 @@ export type ToolRegistryCatalogNamespacesOutput = { · 8 unmodified lines
5665 export type ToolRegistryCatalogToolsOutput = {
5766 results: Array<ToolRegistryToolSummary>;
5867 total: number;
68+ refHintTable?: Record<string, string>;
5969 };
6070
6171 export type ToolRegistry = {
62− callTool: (input: ToolRegistryCallInput) => Effect.Effect<unknown, RuntimeAdapterError>;
72+ callTool: (
73+ input: ToolRegistryCallInput,
74+ ) => Effect.Effect<RuntimeToolCallResult, RuntimeAdapterError>;
6375 discover: (
6476 input: ToolRegistryDiscoverInput,
6577 ) => Effect.Effect<ToolRegistryDiscoverOutput, RuntimeAdapterError>;
@@ -74,15 +86,18 @@ export type ToolRegistry = { · 8 unmodified lines
7486 export type InMemorySandboxTool = {
7587 description?: string | null;
7688 execute?: (...args: Array<any>) => Promise<any> | any;
89+ typing?: DiscoveryTypingPayload;
7790 };
7891
7992 export type InMemorySandboxToolMap = Record<string, InMemorySandboxTool>;
8093
8194 type StaticToolRegistryOptions = {
8295 tools: InMemorySandboxToolMap;
96+ refHintTable?: Record<string, string>;
8397 };
8498
8599 const staticToolRegistryRuntimeKind = "static-tool-registry";
100+const runtimeToolCallServiceRuntimeKind = "tool-registry-runtime-tool-call-service";
86101
87102 const toRuntimeAdapterError = (
88103 operation: string,
@@ -136,19 +151,149 @@ const normalizeCatalogToolsInput = (input: unknown): ToolRegistryCatalogToolsInp · 47 unmodified lines
136151 };
137152 };
138153
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> =>
142285 Object.entries(tools)
143286 .sort(([leftPath], [rightPath]) => leftPath.localeCompare(rightPath))
144287 .map(([path, tool]) => ({
145288 path,
146− source: "in-memory",
147− approval: "auto" as const,
148289 description: tool.description ?? undefined,
290+ typing: tool.typing,
149291 }));
150292
151−const scorePath = (entry: ToolRegistryToolSummary, query: string): number => {
293+const scorePath = (
294+ entry: { path: string; description?: string },
295+ query: string,
296+): number => {
152297 if (query.length === 0) {
153298 return 1;
154299 }
@@ -178,12 +323,15 @@ const scorePath = (entry: ToolRegistryToolSummary, query: string): number => { · 23 unmodified lines
178323
179324 const inMemoryDiscover = (
180325 tools: InMemorySandboxToolMap,
326+ refHintTable: Record<string, string> | undefined,
181327 input: ToolRegistryDiscoverInput,
182328 ): ToolRegistryDiscoverOutput => {
183329 const limit = Math.max(1, Math.min(50, input.limit ?? 8));
184330 const query = (input.query ?? "").trim().toLowerCase();
331+ const includeSchemas = input.includeSchemas === true;
332+ const compact = input.compact === true;
185333
186− const ranked = asToolSummaries(tools)
334+ const ranked = asStaticEntries(tools)
187335 .map((entry) => ({
188336 entry,
189337 score: scorePath(entry, query),
@@ -191,12 +339,22 @@ const inMemoryDiscover = ( · 1 unmodified line
191339 .filter((item) => item.score > 0)
192340 .sort((left, right) => right.score - left.score)
193341 .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+ );
195352
196353 return {
197354 bestPath: ranked[0]?.path ?? null,
198355 results: ranked,
199356 total: ranked.length,
357+ refHintTable: selectRefHintTable(refHintTable, ranked, includeSchemas),
200358 };
201359 };
202360
@@ -230,13 +388,16 @@ const inMemoryCatalogNamespaces = ( · 27 unmodified lines
230388
231389 const inMemoryCatalogTools = (
232390 tools: InMemorySandboxToolMap,
391+ refHintTable: Record<string, string> | undefined,
233392 input: ToolRegistryCatalogToolsInput,
234393 ): ToolRegistryCatalogToolsOutput => {
235394 const limit = Math.max(1, Math.min(200, input.limit ?? 50));
236395 const query = (input.query ?? "").trim().toLowerCase();
237396 const namespace = (input.namespace ?? "").trim().toLowerCase();
397+ const includeSchemas = input.includeSchemas === true;
398+ const compact = input.compact === true;
238399
239− const filtered = asToolSummaries(tools)
400+ const filtered = asStaticEntries(tools)
240401 .filter((entry) => {
241402 if (namespace.length > 0) {
242403 const entryNamespace = entry.path.split(".")[0]?.toLowerCase() ?? "";
@@ -247,11 +408,22 @@ const inMemoryCatalogTools = ( · 4 unmodified lines
247408
248409 return scorePath(entry, query) > 0;
249410 })
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+ );
251422
252423 return {
253424 results: filtered,
254425 total: filtered.length,
426+ refHintTable: selectRefHintTable(refHintTable, filtered, includeSchemas),
255427 };
256428 };
257429
@@ -261,20 +433,18 @@ export const createStaticToolRegistry = ( · 3 unmodified lines
261433 callTool: (input) => {
262434 const implementation = options.tools[input.toolPath];
263435 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}`,
269440 });
270441 }
271442
272443 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`,
278448 });
279449 }
280450
@@ -286,14 +456,63 @@ export const createStaticToolRegistry = ( · 5 unmodified lines
286456 `In-memory tool invocation failed: ${input.toolPath}`,
287457 String(cause),
288458 ),
289− });
459+ }).pipe(
460+ Effect.map(
461+ (value): RuntimeToolCallResult => ({
462+ ok: true,
463+ value,
464+ }),
465+ ),
466+ );
290467 },
291− discover: (input) => Effect.succeed(inMemoryDiscover(options.tools, input)),
468+ discover: (input) =>
469+ Effect.succeed(inMemoryDiscover(options.tools, options.refHintTable, input)),
292470 catalogNamespaces: (input) =>
293471 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)),
295474 });
296475
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+
297516 export const createRuntimeToolCallService = (
298517 toolRegistry: ToolRegistry,
299518 ): RuntimeToolCallService => ({
@@ -312,6 +531,8 @@ export const createRuntimeToolCallService = ( · 12 unmodified lines
312531 return toolRegistry.catalogTools(normalizeCatalogToolsInput(input.input));
313532 }
314533
315− return toolRegistry.callTool(input);
534+ return toolRegistry
535+ .callTool(input)
536+ .pipe(Effect.flatMap((result) => createRuntimeToolCallResultHandler(input, result)));
316537 },
317538 });
v2/packages/management-api/src/openapi-extraction.test.ts+73−0
@@ -70,4 +70,77 @@ describe("extractOpenApiManifest invocation metadata", () => { · 69 unmodified lines
7070 });
7171 }),
7272 );
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+ );
73146 });
v2/packages/management-api/src/openapi-extraction.ts+270−0
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 OpenApiToolParameterSchema,
1414 OpenApiToolRequestBodySchema,
1515 ToolArtifactIdSchema,
16+ type DiscoveryTypingPayload,
1617 type OpenApiExtractedTool,
1718 type OpenApiHttpMethod,
1819 type OpenApiInvocationPayload,
@@ -56,6 +57,7 @@ type ExtractedToolRequestBody = OpenApiToolRequestBody; · 37 unmodified lines
5657 type ExtractedToolInvocation = OpenApiInvocationPayload;
5758 type ExtractedTool = OpenApiExtractedTool;
5859 type ToolManifest = OpenApiToolManifest;
60+type DiscoveryTyping = DiscoveryTypingPayload;
5961
6062 const ToolManifestFromJsonSchema = Schema.parseJson(ToolManifestSchema);
6163 const encodeManifestToJson = Schema.encode(ToolManifestFromJsonSchema);
@@ -195,6 +197,266 @@ const buildInvocationMetadata = ( · 133 unmodified lines
195197 requestBody: extractRequestBody(operation),
196198 });
197199
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+
198460 const toStableValue = (value: unknown): unknown => {
199461 if (Array.isArray(value)) {
200462 return value.map(toStableValue);
@@ -348,6 +610,7 @@ export const extractOpenApiManifest = ( · 147 unmodified lines
348610 pathItem,
349611 operation,
350612 );
613+ const typing = buildToolTyping(pathItem, operation, invocation);
351614
352615 tools.push({
353616 toolId: buildToolId(method, pathValue, operation),
@@ -362,6 +625,7 @@ export const extractOpenApiManifest = ( · 8 unmodified lines
362625 operation,
363626 invocation,
364627 }),
628+ typing,
365629 });
366630 }
367631 }
@@ -369,10 +633,16 @@ export const extractOpenApiManifest = ( · 1 unmodified line
369633 tools.sort((left, right) => left.toolId.localeCompare(right.toolId));
370634 yield* ensureUniqueToolIds(sourceName, tools);
371635
636+ const directRefKeys = tools.flatMap(
637+ (tool) => tool.typing?.refHintKeys ?? [],
638+ );
639+ const refHintTable = buildRefHintTable(specRecord, directRefKeys);
640+
372641 return {
373642 version: 1 as const,
374643 sourceHash: hashUnknown(openApiSpec),
375644 tools,
645+ refHintTable: Object.keys(refHintTable).length > 0 ? refHintTable : undefined,
376646 };
377647 }).pipe(Effect.mapError((cause) => toExtractionError(sourceName, "extract", cause)));
378648
v2/packages/runtime-cloudflare-worker-loader/package.json+3−1
@@ -7,7 +7,9 @@ · 6 unmodified lines
77 },
88 "scripts": {
99 "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"
1113 },
1214 "dependencies": {
1315 "@effect/platform": "catalog:",
v2/packages/runtime-cloudflare-worker-loader/src/index.integration.test.ts+269−30
@@ -1,8 +1,13 @@
11 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";
38 import { fileURLToPath } from "node:url";
49
5−import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest";
10+import { afterAll, beforeAll, beforeEach, describe, expect, it } from "@effect/vitest";
611 import * as Effect from "effect/Effect";
712 import * as Either from "effect/Either";
813
@@ -11,9 +16,19 @@ import { makeCloudflareWorkerLoaderRuntimeAdapter } from "./index"; · 2 unmodified lines
1116
1217 type WorkerProcess = ReturnType<typeof spawn>;
1318
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+
1429 const getFreePort = async (): Promise<number> =>
1530 await new Promise<number>((resolve, reject) => {
16− const server = createServer();
31+ const server = createNetServer();
1732
1833 server.listen(0, "127.0.0.1", () => {
1934 const address = server.address();
@@ -83,16 +98,156 @@ const stopWorkerProcess = async (process: WorkerProcess): Promise<void> => · 63 unmodified lines
8398 process.kill("SIGTERM");
8499 });
85100
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+
86125 describe("cloudflare worker loader runtime adapter integration", () => {
87126 let workerProcess: WorkerProcess | null = null;
88127 let workerPort = 0;
89128 let workerBaseUrl = "";
90129
130+ let callbackServer: ReturnType<typeof createHttpServer> | null = null;
131+ let callbackBaseUrl = "";
132+ let callbackCalls: Array<CallbackToolCall> = [];
133+
91134 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+
92247 workerPort = await getFreePort();
93248 workerBaseUrl = `http://127.0.0.1:${workerPort}`;
94249
95− const workerDir = fileURLToPath(new URL("../test-worker", import.meta.url));
250+ const workerDir = fileURLToPath(new URL("../worker", import.meta.url));
96251
97252 const spawned = spawn(
98253 "bunx",
@@ -116,7 +271,7 @@ describe("cloudflare worker loader runtime adapter integration", () => { · 17 unmodified lines
116271 workerProcess = spawned;
117272
118273 spawned.stdout?.on("data", () => {
119− // Ignore stdout, but keep stream flowing.
274+ // Keep stream flowing.
120275 });
121276
122277 spawned.stderr?.on("data", () => {
@@ -126,48 +281,79 @@ describe("cloudflare worker loader runtime adapter integration", () => { · 3 unmodified lines
126281 await waitForWorker(workerBaseUrl, 45_000);
127282 });
128283
284+ beforeEach(() => {
285+ callbackCalls = [];
286+ });
287+
129288 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+
130302 if (workerProcess) {
131303 await stopWorkerProcess(workerProcess);
132304 }
133305 });
134306
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", () =>
136308 Effect.gen(function* () {
137309 const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({
138− runUrl: `${workerBaseUrl}/v1/runs?scenario=completed`,
310+ runUrl: `${workerBaseUrl}/v1/runs`,
139311 authToken: "sandbox-token",
140− callbackUrl: "https://callbacks.example.test/runtimeCallbacks",
141− callbackInternalSecret: "internal-token",
312+ callbackUrl: `${callbackBaseUrl}/tool-call`,
313+ callbackInternalSecret: CALLBACK_SECRET,
142314 });
143315
144316 const result = yield* adapter.execute({
145317 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 };",
147319 });
148320
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,
155324 });
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);
156341 }),
157342 );
158343
159− it.effect("maps worker terminal failures across the HTTP boundary", () =>
344+ it.effect("maps denied tool callbacks to runtime terminal denied status", () =>
160345 Effect.gen(function* () {
161346 const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({
162− runUrl: `${workerBaseUrl}/v1/runs?scenario=failed`,
347+ runUrl: `${workerBaseUrl}/v1/runs`,
163348 authToken: "sandbox-token",
164− callbackUrl: "https://callbacks.example.test/runtimeCallbacks",
349+ callbackUrl: `${callbackBaseUrl}/tool-call`,
350+ callbackInternalSecret: CALLBACK_SECRET,
165351 });
166352
167353 const result = yield* Effect.either(
168354 adapter.execute({
169355 runId: "run_e2e_2",
170− code: "return 0;",
356+ code: "await tools.math.denied({}); return 'unreachable';",
171357 }),
172358 );
173359
@@ -175,23 +361,75 @@ describe("cloudflare worker loader runtime adapter integration", () => { · 1 unmodified line
175361 if (Either.isLeft(result)) {
176362 expect(result.left).toBeInstanceOf(RuntimeAdapterError);
177363 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");
180366 }
181367 }),
182368 );
183369
184− it.effect("surfaces non-200 responses as dispatch errors", () =>
370+ it.effect("maps thrown user code errors to runtime terminal failures", () =>
185371 Effect.gen(function* () {
186372 const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({
187− runUrl: `${workerBaseUrl}/v1/runs?scenario=http-500`,
373+ runUrl: `${workerBaseUrl}/v1/runs`,
188374 authToken: "sandbox-token",
189− callbackUrl: "https://callbacks.example.test/runtimeCallbacks",
375+ callbackUrl: `${callbackBaseUrl}/tool-call`,
376+ callbackInternalSecret: CALLBACK_SECRET,
190377 });
191378
192379 const result = yield* Effect.either(
193380 adapter.execute({
194381 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",
195433 code: "return 0;",
196434 }),
197435 );
@@ -199,22 +437,23 @@ describe("cloudflare worker loader runtime adapter integration", () => { · 1 unmodified line
199437 expect(Either.isLeft(result)).toBe(true);
200438 if (Either.isLeft(result)) {
201439 expect(result.left.operation).toBe("dispatch");
202− expect(result.left.message).toContain("500");
440+ expect(result.left.message).toContain("401");
203441 }
204442 }),
205443 );
206444
207− it.effect("surfaces invalid JSON from worker as decode errors", () =>
445+ it.effect("surfaces invalid worker JSON as decode errors", () =>
208446 Effect.gen(function* () {
209447 const adapter = makeCloudflareWorkerLoaderRuntimeAdapter({
210− runUrl: `${workerBaseUrl}/v1/runs?scenario=invalid-json`,
448+ runUrl: `${workerBaseUrl}/health`,
211449 authToken: "sandbox-token",
212− callbackUrl: "https://callbacks.example.test/runtimeCallbacks",
450+ callbackUrl: `${callbackBaseUrl}/tool-call`,
451+ callbackInternalSecret: CALLBACK_SECRET,
213452 });
214453
215454 const result = yield* Effect.either(
216455 adapter.execute({
217− runId: "run_e2e_4",
456+ runId: "run_e2e_6",
218457 code: "return 0;",
219458 }),
220459 );
v2/packages/runtime-cloudflare-worker-loader/test-worker/src/index.ts+0−118
@@ -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−};
v2/packages/runtime-cloudflare-worker-loader/test-worker/wrangler.jsonc+0−6
@@ -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−}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t