History

Merge pull request #5 from RhysSullivan/feat/schema-first-tool-typing

Schema-first tool typing + workspace Monaco type bundle

Rhys Sullivan committed 8 months ago

Commit 4dbbe282 parents c5d4efc ae17ce374 files · +4234 −3765Browse files

Unified · Split

74 files
  1. executor/apps/web/src/app/layout.tsx +4 −4
  2. executor/apps/web/src/components/tasks/code-editor.tsx +56 −98
  3. executor/apps/web/src/components/tasks/code/editor-types.ts +0 −157
  4. executor/apps/web/src/components/tasks/task-composer.tsx +3 −23
  5. executor/apps/web/src/components/tools/add/source-dialog.tsx +47 −2
  6. executor/apps/web/src/components/tools/explorer-groups.tsx +3 −4
  7. executor/apps/web/src/components/tools/explorer-rows.tsx +1 −10
  8. executor/apps/web/src/components/tools/explorer.tsx +8 −24
  9. executor/apps/web/src/components/tools/explorer/tool-detail.tsx +14 −91
  10. executor/apps/web/src/components/tools/explorer/type-signature.tsx +11 −18
  11. executor/apps/web/src/components/tools/setup-card.tsx +18 −33
  12. executor/apps/web/src/components/tools/source-favicon.tsx +6 −8
  13. executor/apps/web/src/components/tools/view.tsx +7 −4
  14. executor/apps/web/src/hooks/use/workspace-tools.ts +7 −105
  15. executor/apps/web/src/lib/convex-provider.tsx +22 −39
  16. executor/apps/web/src/lib/types.ts +13 −6
  17. executor/packages/convex/_generated/api.d.ts +114 −12
  18. executor/packages/convex/access-controls.test.ts +1 −0
  19. executor/packages/convex/accounts.ts +2 −4
  20. executor/packages/convex/database.test.ts +1 −0
  21. executor/packages/convex/executor-mcp.e2e.test.ts +39 −70
  22. executor/packages/convex/executor.ts +6 −5
  23. executor/packages/convex/executorNode.github-profile.e2e.test.ts +7 −18
  24. executor/packages/convex/executorNode.ts +6 −35
  25. executor/packages/convex/http/mcp_handler.ts +55 −35
  26. executor/packages/convex/lib/scheduler.ts +26 −0
  27. executor/packages/convex/mcp/run/code-oom.e2e.test.ts +38 −6
  28. executor/packages/convex/migrations.ts +21 −9
  29. executor/packages/convex/openApiSpecCache.test.ts +1 −0
  30. executor/packages/convex/runtime/tool_call_resolution.ts +84 −53
  31. executor/packages/convex/runtime/tool_descriptors.test.ts +26 −10
  32. executor/packages/convex/runtime/tool_descriptors.ts +30 −22
  33. executor/packages/convex/runtime/tool_invocation.ts +128 −6
  34. executor/packages/convex/runtime/tool_paths.ts +7 −7
  35. executor/packages/convex/runtime/tool_source_loading.ts +4 −4
  36. executor/packages/convex/runtime/workspace_tools.ts +396 −225
  37. executor/packages/convex/schema.ts +182 −3
  38. executor/packages/convex/test-fixtures/executorNode.oomFixture.ts +13 −3
  39. executor/packages/convex/toolRegistry.ts +331 −0
  40. executor/packages/convex/workspace.ts +25 −16
  41. executor/packages/convex/workspaceToolCache.ts +13 −9
  42. executor/packages/core/src/declaration-generation.ts +2 −2
  43. executor/packages/core/src/dts-loader.test.ts +0 −64
  44. executor/packages/core/src/dts-loader.ts +0 −37
  45. executor/packages/core/src/mcp-server.ts +6 −7
  46. executor/packages/core/src/mcp/server-approval.ts +26 −1
  47. executor/packages/core/src/openapi-cache.test.ts +0 −1335
  48. executor/packages/core/src/openapi-compaction.ts +25 −34
  49. executor/packages/core/src/openapi-typing.test.ts +130 −0
  50. executor/packages/core/src/openapi/real-specs.test.ts +25 −261
  51. executor/packages/core/src/openapi/schema-hints.test.ts +305 −0
  52. executor/packages/core/src/openapi/schema-hints.ts +540 −10
  53. executor/packages/core/src/openapi/tool-builder.ts +28 −75
  54. executor/packages/core/src/tool-discovery.test.ts +120 −122
  55. executor/packages/core/src/tool-discovery.ts +55 −28
  56. executor/packages/core/src/tool-discovery/formatting.ts +8 −18
  57. executor/packages/core/src/tool-discovery/indexing.ts +39 −31
  58. executor/packages/core/src/tool-discovery/schema-registry.ts +0 −420
  59. executor/packages/core/src/tool-discovery/types.ts +7 −8
  60. executor/packages/core/src/tool-source-loaders/graphql-loader.ts +136 −18
  61. executor/packages/core/src/tool-source-loaders/mcp-loader.ts +8 −13
  62. executor/packages/core/src/tool-source-loaders/openapi-loader.ts +14 −10
  63. executor/packages/core/src/tool-sources.test.ts +10 −4
  64. executor/packages/core/src/tool-typing/agent-signature-typebundle.e2e.test.ts +540 −0
  65. executor/packages/core/src/tool-typing/convex-sanitize.ts +32 −0
  66. executor/packages/core/src/tool-typing/env-types.ts +18 −0
  67. executor/packages/core/src/tool-typing/schema-utils.ts +55 −0
  68. executor/packages/core/src/tool-typing/typebundle.ts +189 −0
  69. executor/packages/core/src/tool/source-serialization.ts +4 −4
  70. executor/packages/core/src/types.ts +44 −28
  71. executor/packages/core/src/workspace/tool-cache.test.ts +24 −19
  72. executor/scripts/dev/wipe/convex-db.ts +7 −0
  73. executor/scripts/e2e/install.sandbox.e2e.test.ts +60 −37
  74. package.json +1 −1
executor/apps/web/src/app/layout.tsx+4−4
@@ -38,13 +38,13 @@ export default function RootLayout({ · 37 unmodified lines
38 enableSystem38 enableSystem
39 enableColorScheme39 enableColorScheme
40 >40 >
41 <AppConvexProvider>41 <QueryProvider>
42 <QueryProvider>42 <AppConvexProvider>
43 <SessionProvider>43 <SessionProvider>
44 {children}44 {children}
45 </SessionProvider>45 </SessionProvider>
46 </QueryProvider>46 </AppConvexProvider>
47 </AppConvexProvider>47 </QueryProvider>
48 <Toaster position="bottom-right" />48 <Toaster position="bottom-right" />
49 </ThemeProvider>49 </ThemeProvider>
50 </body>50 </body>
executor/apps/web/src/components/tasks/code-editor.tsx+56−98
@@ -1,16 +1,12 @@
1"use client";1"use client";
2 2
3import { useRef, useEffect, useMemo, useState } from "react";3import { useEffect, useRef } from "react";
4import { useQuery as useTanstackQuery } from "@tanstack/react-query";
4import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-editor/react";5import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-editor/react";
5import { Loader2 } from "lucide-react";6import { Loader2 } from "lucide-react";
6import { useTheme } from "next-themes";7import { useTheme } from "next-themes";
7import type { ToolDescriptor } from "@/lib/types";8import type { ToolDescriptor } from "@/lib/types";
8import { cn } from "@/lib/utils";9import { cn } from "@/lib/utils";
9import {
10 BASE_ENVIRONMENT_DTS,
11 OPENAPI_HELPER_TYPES,
12 generateToolsDts,
13} from "./code/editor-types";
14import {10import {
15 CODE_EDITOR_OPTIONS,11 CODE_EDITOR_OPTIONS,
16 configureJavascriptDefaults,12 configureJavascriptDefaults,
@@ -18,13 +14,14 @@ import { · 1 unmodified line
18 setDiagnosticsOptions,14 setDiagnosticsOptions,
19} from "./code/editor-monaco";15} from "./code/editor-monaco";
20 16
17const PLACEHOLDER_TYPES_DTS = "declare const tools: any;\n";
18
21interface CodeEditorProps {19interface CodeEditorProps {
22 value: string;20 value: string;
23 onChange: (value: string) => void;21 onChange: (value: string) => void;
24 tools: ToolDescriptor[];22 tools: ToolDescriptor[];
25 /** Per-source .d.ts download URLs for OpenAPI IntelliSense. Keyed by source key. */23 /** Workspace-wide Monaco `.d.ts` bundle URL. */
26 dtsUrls?: Record<string, string>;24 typesUrl?: string;
27 typesLoading?: boolean;
28 className?: string;25 className?: string;
29 height?: string;26 height?: string;
30}27}
@@ -33,152 +30,113 @@ export function CodeEditor({ · 2 unmodified lines
33 value,30 value,
34 onChange,31 onChange,
35 tools,32 tools,
36 dtsUrls,33 typesUrl,
37 typesLoading = false,
38 className,34 className,
39 height = "400px",35 height = "400px",
40}: CodeEditorProps) {36}: CodeEditorProps) {
41 const { resolvedTheme } = useTheme();37 const { resolvedTheme } = useTheme();
42 const [dtsHydrating, setDtsHydrating] = useState(false);
43 const editorRef = useRef<Parameters<OnMount>[0] | null>(null);38 const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
44 const monacoRef = useRef<Monaco | null>(null);39 const monacoRef = useRef<Monaco | null>(null);
45 const envLibDisposable = useRef<{ dispose: () => void } | null>(null);40 const envLibDisposable = useRef<{ dispose: () => void } | null>(null);
46 const toolsLibDisposable = useRef<{ dispose: () => void } | null>(null);41 const toolsLibDisposable = useRef<{ dispose: () => void } | null>(null);
47 const dtsLibDisposables = useRef<{ dispose: () => void }[]>([]);
48 const toolsLibVersion = useRef(0);42 const toolsLibVersion = useRef(0);
49 const fetchedDtsUrls = useRef<string>("");
50 const dtsSources = useMemo(() => new Set(Object.keys(dtsUrls ?? {})), [dtsUrls]);
51 const toolTypesLoading = typesLoading || dtsHydrating;
52 43
53 // Fetch and register .d.ts blobs from OpenAPI sources44 const typesBundleQuery = useTanstackQuery<string>({
45 queryKey: ["executor-tool-types", typesUrl],
46 queryFn: async ({ signal }) => {
47 if (!typesUrl) {
48 throw new Error("No types URL provided");
49 }
50
51 const resp = await fetch(typesUrl, { signal });
52 if (!resp.ok) {
53 throw new Error(`Failed to load tool types: ${resp.status}`);
54 }
55
56 return resp.text();
57 },
58 enabled: Boolean(typesUrl),
59 retry: false,
60 staleTime: Infinity,
61 });
62
63 const typesHydrating = typesBundleQuery.isLoading || typesBundleQuery.isFetching;
54 useEffect(() => {64 useEffect(() => {
55 if (!dtsUrls || Object.keys(dtsUrls).length === 0) {
56 fetchedDtsUrls.current = "";
57 for (const d of dtsLibDisposables.current) d.dispose();
58 dtsLibDisposables.current = [];
59 void Promise.resolve().then(() => {
60 setDtsHydrating(false);
61 });
62 return;
63 }
64 const m = monacoRef.current;65 const m = monacoRef.current;
65 if (!m) return;66 if (!m) return;
66 67
67 const jsDefaults = m.languages.typescript.javascriptDefaults;68 const jsDefaults = m.languages.typescript.javascriptDefaults;
68 69
69 // Skip if URLs haven't changed70 // Dispose previous tool type declarations
70 const urlsKey = JSON.stringify(dtsUrls);71 toolsLibDisposable.current?.dispose();
71 if (urlsKey === fetchedDtsUrls.current) return;
72 fetchedDtsUrls.current = urlsKey;
73
74 // Dispose previous .d.ts libs
75 for (const d of dtsLibDisposables.current) d.dispose();
76 dtsLibDisposables.current = [];
77
78 let cancelled = false;
79 const entries = Object.entries(dtsUrls);
80
81 void Promise.resolve()
82 .then(async () => {
83 setDtsHydrating(true);
84 const results = await Promise.all(
85 entries.map(async ([sourceKey, url]) => {
86 try {
87 const resp = await fetch(url);
88 if (!resp.ok || cancelled) return null;
89 const content = await resp.text();
90 return { sourceKey, content };
91 } catch {
92 return null;
93 }
94 }),
95 );
96
97 if (cancelled) return;
98
99 // Build the helper types + .d.ts declarations
100 let helperDts = OPENAPI_HELPER_TYPES + "\n";
101 for (const result of results) {
102 if (!result) continue;
103 // Strip 'export' keywords so types are ambient in Monaco
104 const ambient = result.content.replace(/^export /gm, "");
105 helperDts += ambient + "\n";
106 }
107
108 const version = ++toolsLibVersion.current;
109 const disposable = jsDefaults.addExtraLib(
110 helperDts,
111 `file:///node_modules/@types/executor-openapi/v${version}.d.ts`,
112 );
113 dtsLibDisposables.current.push(disposable);
114 })
115 .finally(() => {
116 if (!cancelled) {
117 setDtsHydrating(false);
118 }
119 });
120 72
121 return () => {73 // Placeholder types until we fetch the workspace bundle.
122 cancelled = true;74 const version = ++toolsLibVersion.current;
123 };75 toolsLibDisposable.current = jsDefaults.addExtraLib(
124 }, [dtsUrls]);76 PLACEHOLDER_TYPES_DTS,
77 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,
78 );
79 }, [tools]);
125 80
126 // Update types when tools change (or on first mount)81 // Register fetched workspace type bundle.
127 useEffect(() => {82 useEffect(() => {
83 if (!typesBundleQuery.data) {
84 return;
85 }
86
128 const m = monacoRef.current;87 const m = monacoRef.current;
129 if (!m) return;88 if (!m) return;
130 89
131 const jsDefaults = m.languages.typescript.javascriptDefaults;90 const jsDefaults = m.languages.typescript.javascriptDefaults;
132 91
133 // Dispose previous tool type declarations
134 toolsLibDisposable.current?.dispose();92 toolsLibDisposable.current?.dispose();
135
136 const dts = generateToolsDts(tools, dtsSources);
137
138 // Use a versioned filename — disposing + re-adding the same filename
139 // can cause the TS worker to serve stale completions from its cache.
140 const version = ++toolsLibVersion.current;93 const version = ++toolsLibVersion.current;
141 toolsLibDisposable.current = jsDefaults.addExtraLib(94 toolsLibDisposable.current = jsDefaults.addExtraLib(
142 dts,95 typesBundleQuery.data,
143 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,96 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,
144 );97 );
145 }, [tools, dtsSources]);98 }, [typesBundleQuery.data]);
146 99
147 // Avoid transient semantic errors while tool metadata is still loading.100 // Avoid transient semantic errors while tool metadata is still loading.
148 useEffect(() => {101 useEffect(() => {
149 const m = monacoRef.current;102 const m = monacoRef.current;
150 if (!m) return;103 if (!m) return;
151 setDiagnosticsOptions(m, toolTypesLoading);104 setDiagnosticsOptions(m, typesHydrating);
152 }, [toolTypesLoading]);105 }, [typesHydrating]);
153 106
154 // Clean up on unmount107 // Clean up on unmount
155 useEffect(() => {108 useEffect(() => {
156 return () => {109 return () => {
157 envLibDisposable.current?.dispose();110 envLibDisposable.current?.dispose();
158 toolsLibDisposable.current?.dispose();111 toolsLibDisposable.current?.dispose();
159 for (const d of dtsLibDisposables.current) d.dispose();
160 };112 };
161 }, []);113 }, []);
162 114
163 const handleBeforeMount: BeforeMount = (monaco) => {115 const handleBeforeMount: BeforeMount = (monaco) => {
164 monacoRef.current = monaco;116 monacoRef.current = monaco;
165 117
166 const ts = configureJavascriptDefaults(monaco, toolTypesLoading);118 const ts = configureJavascriptDefaults(monaco, typesHydrating);
167 119
168 // Add stable environment declarations (once)120 // Add stable environment declarations (once)
121 // Keep a stable environment declaration (console/timers).
169 envLibDisposable.current?.dispose();122 envLibDisposable.current?.dispose();
170 envLibDisposable.current = ts.javascriptDefaults.addExtraLib(123 envLibDisposable.current = ts.javascriptDefaults.addExtraLib(
171 BASE_ENVIRONMENT_DTS,124 // Minimal env while the workspace bundle is loading.
125 `interface Console { log(...args: any[]): void; error(...args: any[]): void; warn(...args: any[]): void; info(...args: any[]): void; debug(...args: any[]): void; }\n` +
126 "declare var console: Console;\n" +
127 "declare function setTimeout(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;\n" +
128 "declare function clearTimeout(id: number): void;\n" +
129 "declare function setInterval(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;\n" +
130 "declare function clearInterval(id: number): void;\n",
172 "file:///node_modules/@types/executor-env/index.d.ts",131 "file:///node_modules/@types/executor-env/index.d.ts",
173 );132 );
174 133
175 // Add initial tool type declarations134 // Add initial tool type declarations
176 // (will be replaced by useEffect when tools load from the API)135 // (will be replaced by useEffect when tools load from the API)
177 toolsLibDisposable.current?.dispose();136 toolsLibDisposable.current?.dispose();
178 const dts = generateToolsDts(tools, dtsSources);
179 const version = ++toolsLibVersion.current;137 const version = ++toolsLibVersion.current;
180 toolsLibDisposable.current = ts.javascriptDefaults.addExtraLib(138 toolsLibDisposable.current = ts.javascriptDefaults.addExtraLib(
181 dts,139 PLACEHOLDER_TYPES_DTS,
182 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,140 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,
183 );141 );
184 142
@@ -203,7 +161,7 @@ export function CodeEditor({ · 18 unmodified lines
203 161
204 return (162 return (
205 <div className={cn("relative", className)}>163 <div className={cn("relative", className)}>
206 {toolTypesLoading ? (164 {typesHydrating ? (
207 <div className="pointer-events-none absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-md border border-border/80 bg-background/85 px-2 py-1 text-[10px] font-mono text-muted-foreground backdrop-blur-sm">165 <div className="pointer-events-none absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-md border border-border/80 bg-background/85 px-2 py-1 text-[10px] font-mono text-muted-foreground backdrop-blur-sm">
208 <Loader2 className="h-3 w-3 animate-spin" />166 <Loader2 className="h-3 w-3 animate-spin" />
209 {tools.length > 0 ? "Loading type definitions..." : "Loading tool metadata..."}167 {tools.length > 0 ? "Loading type definitions..." : "Loading tool metadata..."}
executor/apps/web/src/components/tasks/code/editor-types.ts+0−157
@@ -1,157 +0,0 @@
1import type { ToolDescriptor } from "@/lib/types";
2export { OPENAPI_HELPER_TYPES } from "@executor/core/openapi/helper-types";
3import { toolDisplaySegment } from "@/lib/tool/explorer-grouping";
4
5interface NamespaceNode {
6 children: Map<string, NamespaceNode>;
7 tools: ToolDescriptor[];
8}
9
10function buildTree(tools: ToolDescriptor[]): NamespaceNode {
11 const root: NamespaceNode = { children: new Map(), tools: [] };
12 for (const tool of tools) {
13 const parts = tool.path.split(".");
14 if (parts.length === 1) {
15 root.tools.push(tool);
16 } else {
17 let node = root;
18 for (let i = 0; i < parts.length - 1; i++) {
19 if (!node.children.has(parts[i])) {
20 node.children.set(parts[i], { children: new Map(), tools: [] });
21 }
22 node = node.children.get(parts[i])!;
23 }
24 node.tools.push(tool);
25 }
26 }
27 return root;
28}
29
30const memberNameRegex = /^[$A-Z_][0-9A-Z_$]*$/i;
31
32function emitMemberName(name: string): string {
33 if (memberNameRegex.test(name)) {
34 return name;
35 }
36
37 return JSON.stringify(name);
38}
39
40function countAllTools(node: NamespaceNode): number {
41 let count = node.tools.length;
42 for (const child of node.children.values()) {
43 count += countAllTools(child);
44 }
45 return count;
46}
47
48function emitToolMethod(tool: ToolDescriptor, dtsSources: Set<string>): string {
49 const funcName = emitMemberName(toolDisplaySegment(tool.path.split(".").pop()!));
50 const approvalNote =
51 tool.approval === "required"
52 ? " **Requires approval** - execution will pause until approved."
53 : "";
54 const desc = tool.description
55 ? `${tool.description}${approvalNote}`
56 : approvalNote || "Call this tool.";
57
58 const hasSourceDts = Boolean(tool.source && dtsSources.has(tool.source));
59 if (tool.operationId && hasSourceDts) {
60 const opKey = JSON.stringify(tool.operationId);
61 return ` /**
62 * ${desc}
63 *${tool.source ? ` @source ${tool.source}` : ""}
64 */
65 ${funcName}(input: ToolInput<operations[${opKey}]>): Promise<ToolOutput<operations[${opKey}]>>;`;
66 }
67
68 const strictArgsType = tool.strictArgsType?.trim();
69 const strictReturnsType = tool.strictReturnsType?.trim();
70 const fallbackArgsType = tool.argsType?.trim();
71 const fallbackReturnsType = tool.returnsType?.trim();
72 const hasArgsType = Boolean(strictArgsType || fallbackArgsType);
73 const argsType = strictArgsType || fallbackArgsType || "Record<string, unknown>";
74 const returnsType = strictReturnsType || fallbackReturnsType || "unknown";
75 const inputParam = !hasArgsType || argsType === "{}" ? `input?: ${argsType}` : `input: ${argsType}`;
76
77 return ` /**
78 * ${desc}
79 *${tool.source ? ` @source ${tool.source}` : ""}
80 */
81 ${funcName}(${inputParam}): Promise<${returnsType}>;`;
82}
83
84function emitNamespaceInterface(
85 name: string,
86 node: NamespaceNode,
87 dtsSources: Set<string>,
88 out: string[],
89): void {
90 for (const [childName, childNode] of node.children) {
91 emitNamespaceInterface(`${name}_${childName}`, childNode, dtsSources, out);
92 }
93
94 const members: string[] = [];
95
96 for (const [childName, childNode] of node.children) {
97 const toolCount = childNode.tools.length + countAllTools(childNode);
98 members.push(` /** ${toolCount} tool${toolCount !== 1 ? "s" : ""} in the \`${childName}\` namespace */
99 readonly ${emitMemberName(toolDisplaySegment(childName))}: ToolNS_${name}_${childName};`);
100 }
101
102 for (const tool of node.tools) {
103 members.push(emitToolMethod(tool, dtsSources));
104 }
105
106 out.push(`interface ToolNS_${name} {\n${members.join("\n\n")}\n}`);
107}
108
109export function generateToolsDts(tools: ToolDescriptor[], dtsSources: Set<string>): string {
110 const root = buildTree(tools);
111
112 const interfaces: string[] = [];
113 for (const [name, node] of root.children) {
114 emitNamespaceInterface(name, node, dtsSources, interfaces);
115 }
116
117 const rootMembers: string[] = [];
118 for (const [name] of root.children) {
119 rootMembers.push(` readonly ${emitMemberName(toolDisplaySegment(name))}: ToolNS_${name};`);
120 }
121 for (const tool of root.tools) {
122 rootMembers.push(emitToolMethod(tool, dtsSources));
123 }
124
125 let dts = `
126/**
127 * The \`tools\` object is a proxy that lets you call registered executor tools.
128 * Each call returns a Promise with the tool's result.
129 * Tools marked with "approval: required" will pause execution until approved.
130 */
131`;
132
133 dts += interfaces.join("\n\n") + "\n\n";
134 dts += `interface ToolsProxy {\n${rootMembers.join("\n\n")}\n}\n\n`;
135 dts += "declare const tools: ToolsProxy;\n";
136
137 return dts;
138}
139
140export const BASE_ENVIRONMENT_DTS = `
141interface Console {
142 /** Console output is discarded; use explicit return values for results. */
143 log(...args: any[]): void;
144 /** Console output is discarded; use explicit return values for results. */
145 error(...args: any[]): void;
146 /** Console output is discarded; use explicit return values for results. */
147 warn(...args: any[]): void;
148 info(...args: any[]): void;
149 debug(...args: any[]): void;
150}
151declare var console: Console;
152
153declare function setTimeout(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
154declare function clearTimeout(id: number): void;
155declare function setInterval(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
156declare function clearInterval(id: number): void;
157`;
executor/apps/web/src/components/tasks/task-composer.tsx+3−23
@@ -57,7 +57,7 @@ export function TaskComposer() { · 56 unmodified lines
57 57
58 const runtimes = useQuery(convexApi.workspace.listRuntimeTargets, {});58 const runtimes = useQuery(convexApi.workspace.listRuntimeTargets, {});
59 const createTask = useAction(convexApi.executor.createTask);59 const createTask = useAction(convexApi.executor.createTask);
60 const { tools, dtsUrls, loadingTools, loadingTypes } = useWorkspaceTools(context ?? null);60 const { tools, typesUrl, loadingTools } = useWorkspaceTools(context ?? null);
61 const runtimeTargets = useMemo(() => runtimes ?? [], [runtimes]);61 const runtimeTargets = useMemo(() => runtimes ?? [], [runtimes]);
62 const effectiveRuntimeId = runtimeTargets.some((runtime: RuntimeTargetDescriptor) => runtime.id === runtimeId)62 const effectiveRuntimeId = runtimeTargets.some((runtime: RuntimeTargetDescriptor) => runtime.id === runtimeId)
63 ? runtimeId63 ? runtimeId
@@ -141,35 +141,15 @@ export function TaskComposer() { · 77 unmodified lines
141 <span className="text-[10px] font-mono text-muted-foreground">141 <span className="text-[10px] font-mono text-muted-foreground">
142 {loadingTools142 {loadingTools
143 ? "Loading tool inventory..."143 ? "Loading tool inventory..."
144 : loadingTypes144 : `${tools.length} tool${tools.length === 1 ? "" : "s"} loaded${typesUrl ? ", type defs ready" : ""}`}
145 ? `${tools.length} tool${tools.length === 1 ? "" : "s"} loaded, type defs warming...`
146 : `${tools.length} tool${tools.length === 1 ? "" : "s"} ready`}
147 </span>145 </span>
148 </div>146 </div>
149 {!loadingTools && loadingTypes && tools.length > 0 && (
150 <div className="flex flex-wrap gap-1.5 rounded-md border border-border/50 bg-muted/20 px-2 py-1.5">
151 {tools.slice(0, 8).map((tool) => (
152 <span
153 key={tool.path}
154 className="rounded bg-background px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground"
155 >
156 {tool.path}
157 </span>
158 ))}
159 {tools.length > 8 && (
160 <span className="rounded bg-background px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
161 +{tools.length - 8} more
162 </span>
163 )}
164 </div>
165 )}
166 <div className="rounded-md border border-border">147 <div className="rounded-md border border-border">
167 <CodeEditor148 <CodeEditor
168 value={code}149 value={code}
169 onChange={setCode}150 onChange={setCode}
170 tools={tools}151 tools={tools}
171 dtsUrls={dtsUrls}152 typesUrl={typesUrl}
172 typesLoading={loadingTypes}
173 height="400px"153 height="400px"
174 />154 />
175 </div>155 </div>
executor/apps/web/src/components/tools/add/source-dialog.tsx+47−2
@@ -1,7 +1,7 @@
1"use client";1"use client";
2 2
3import { useState, type ReactNode } from "react";3import { useState, type ReactNode } from "react";
4import { AlertTriangle, Plus } from "lucide-react";4import { AlertTriangle, Plus, Trash2 } from "lucide-react";
5import { useAction, useMutation, useQuery } from "convex/react";5import { useAction, useMutation, useQuery } from "convex/react";
6import { Result } from "better-result";6import { Result } from "better-result";
7import { toast } from "sonner";7import { toast } from "sonner";
@@ -82,6 +82,7 @@ function normalizeEndpoint(value: string): string { · 74 unmodified lines
82export function AddSourceDialog({82export function AddSourceDialog({
83 existingSourceNames,83 existingSourceNames,
84 onSourceAdded,84 onSourceAdded,
85 onSourceDeleted,
85 sourceToEdit,86 sourceToEdit,
86 sourceDialogMeta,87 sourceDialogMeta,
87 sourceAuthProfiles,88 sourceAuthProfiles,
@@ -89,6 +90,7 @@ export function AddSourceDialog({ · 1 unmodified line
89}: {90}: {
90 existingSourceNames: Set<string>;91 existingSourceNames: Set<string>;
91 onSourceAdded?: (source: ToolSourceRecord, options?: { connected?: boolean }) => void;92 onSourceAdded?: (source: ToolSourceRecord, options?: { connected?: boolean }) => void;
93 onSourceDeleted?: (sourceName: string) => void;
92 sourceToEdit?: ToolSourceRecord;94 sourceToEdit?: ToolSourceRecord;
93 sourceDialogMeta?: SourceDialogMeta;95 sourceDialogMeta?: SourceDialogMeta;
94 sourceAuthProfiles?: Record<string, SourceAuthProfile>;96 sourceAuthProfiles?: Record<string, SourceAuthProfile>;
@@ -96,6 +98,7 @@ export function AddSourceDialog({ · 1 unmodified line
96}) {98}) {
97 const { context } = useSession();99 const { context } = useSession();
98 const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);100 const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);
101 const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);
99 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);102 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
100 const credentials = useQuery(103 const credentials = useQuery(
101 convexApi.workspace.listCredentials,104 convexApi.workspace.listCredentials,
@@ -106,6 +109,7 @@ export function AddSourceDialog({ · 4 unmodified lines
106 const [open, setOpen] = useState(false);109 const [open, setOpen] = useState(false);
107 const [submitting, setSubmitting] = useState(false);110 const [submitting, setSubmitting] = useState(false);
108 const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false);111 const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false);
112 const [deleting, setDeleting] = useState(false);
109 113
110 const sourceDialogHeader = sourceToEdit ? "Edit Tool Source" : "Add Tool Source";114 const sourceDialogHeader = sourceToEdit ? "Edit Tool Source" : "Add Tool Source";
111 const sourceDialogDescription = sourceToEdit115 const sourceDialogDescription = sourceToEdit
@@ -256,6 +260,33 @@ export function AddSourceDialog({ · 144 unmodified lines
256 ? "Add Source + Save Credentials"260 ? "Add Source + Save Credentials"
257 : "Add Source";261 : "Add Source";
258 262
263 const handleDeleteSource = async () => {
264 if (!sourceToEdit || !context) {
265 return;
266 }
267
268 const confirmed = window.confirm(`Remove source "${sourceToEdit.name}" and all related tools from this workspace?`);
269 if (!confirmed) {
270 return;
271 }
272
273 setDeleting(true);
274 try {
275 await deleteToolSource({
276 workspaceId: context.workspaceId,
277 sessionId: context.sessionId,
278 sourceId: sourceToEdit.id,
279 });
280 toast.success(`Removed "${sourceToEdit.name}"`);
281 onSourceDeleted?.(sourceToEdit.name);
282 setOpen(false);
283 } catch (err) {
284 toast.error(err instanceof Error ? err.message : "Failed to remove source");
285 } finally {
286 setDeleting(false);
287 }
288 };
289
259 return (290 return (
260 <Dialog open={open} onOpenChange={handleOpenChange}>291 <Dialog open={open} onOpenChange={handleOpenChange}>
261 <DialogTrigger asChild>292 <DialogTrigger asChild>
@@ -338,13 +369,27 @@ export function AddSourceDialog({ · 76 unmodified lines
338 369
339 {editSourceWarnings.length > 0 ? (370 {editSourceWarnings.length > 0 ? (
340 <div className="mt-2 rounded-sm border border-terminal-amber/30 bg-terminal-amber/5 px-2 py-1.5">371 <div className="mt-2 rounded-sm border border-terminal-amber/30 bg-terminal-amber/5 px-2 py-1.5">
341 {editSourceWarnings.map((warning: string, index: number) => (372 {editSourceWarnings.map((warning: string, index: number) => (
342 <p key={`${sourceToEdit.name}-warning-${index}`} className="text-[10px] text-terminal-amber/90">373 <p key={`${sourceToEdit.name}-warning-${index}`} className="text-[10px] text-terminal-amber/90">
343 {warning}374 {warning}
344 </p>375 </p>
345 ))}376 ))}
346 </div>377 </div>
347 ) : null}378 ) : null}
379
380 <div className="mt-3 flex justify-end">
381 <Button
382 type="button"
383 variant="outline"
384 size="sm"
385 className="h-7 text-[11px] border-terminal-red/40 text-terminal-red hover:bg-terminal-red/10"
386 onClick={handleDeleteSource}
387 disabled={deleting}
388 >
389 <Trash2 className="h-3.5 w-3.5 mr-1.5" />
390 {deleting ? "Removing..." : "Remove source"}
391 </Button>
392 </div>
348 </div>393 </div>
349 </div>394 </div>
350 ) : null}395 ) : null}
executor/apps/web/src/components/tools/explorer-groups.tsx+3−4
@@ -42,7 +42,6 @@ export function GroupNode({ · 41 unmodified lines
42 onSelectTool,42 onSelectTool,
43 onExpandedChange,43 onExpandedChange,
44 detailLoadingPaths,44 detailLoadingPaths,
45 sourceSchemasBySource,
46 search,45 search,
47 source,46 source,
48}: {47}: {
@@ -55,7 +54,6 @@ export function GroupNode({ · 6 unmodified lines
55 onSelectTool: (path: string, e: React.MouseEvent) => void;54 onSelectTool: (path: string, e: React.MouseEvent) => void;
56 onExpandedChange?: (tool: ToolDescriptor, expanded: boolean) => void;55 onExpandedChange?: (tool: ToolDescriptor, expanded: boolean) => void;
57 detailLoadingPaths?: Set<string>;56 detailLoadingPaths?: Set<string>;
58 sourceSchemasBySource?: Record<string, Record<string, string>>;
59 search: string;57 search: string;
60 source?: ToolSourceRecord;58 source?: ToolSourceRecord;
61}) {59}) {
@@ -190,7 +188,6 @@ export function GroupNode({ · 128 unmodified lines
190 onSelectTool={onSelectTool}188 onSelectTool={onSelectTool}
191 onExpandedChange={onExpandedChange}189 onExpandedChange={onExpandedChange}
192 detailLoadingPaths={detailLoadingPaths}190 detailLoadingPaths={detailLoadingPaths}
193 sourceSchemasBySource={sourceSchemasBySource}
194 search={search}191 search={search}
195 />192 />
196 );193 );
@@ -206,7 +203,6 @@ export function GroupNode({ · 9 unmodified lines
206 onSelectTool={onSelectTool}203 onSelectTool={onSelectTool}
207 onExpandedChange={onExpandedChange}204 onExpandedChange={onExpandedChange}
208 detailLoading={detailLoadingPaths?.has(child.path)}205 detailLoading={detailLoadingPaths?.has(child.path)}
209 sourceSchemas={child.source ? sourceSchemasBySource?.[child.source] : undefined}
210 />206 />
211 );207 );
212 })208 })
@@ -234,6 +230,7 @@ export function SourceSidebar({ · 21 unmodified lines
234 sourceDialogMeta,230 sourceDialogMeta,
235 existingSourceNames,231 existingSourceNames,
236 sourceAuthProfiles,232 sourceAuthProfiles,
233 onSourceDeleted,
237}: {234}: {
238 sources: ToolSourceRecord[];235 sources: ToolSourceRecord[];
239 sourceCounts: Record<string, number>;236 sourceCounts: Record<string, number>;
@@ -244,6 +241,7 @@ export function SourceSidebar({ · 4 unmodified lines
244 sourceDialogMeta?: Record<string, SourceDialogMeta>;241 sourceDialogMeta?: Record<string, SourceDialogMeta>;
245 existingSourceNames: Set<string>;242 existingSourceNames: Set<string>;
246 sourceAuthProfiles?: Record<string, SourceAuthProfile>;243 sourceAuthProfiles?: Record<string, SourceAuthProfile>;
244 onSourceDeleted?: (sourceName: string) => void;
247}) {245}) {
248 const warningCountsBySource = useMemo(() => {246 const warningCountsBySource = useMemo(() => {
249 const counts: Record<string, number> = {};247 const counts: Record<string, number> = {};
@@ -353,6 +351,7 @@ export function SourceSidebar({ · 103 unmodified lines
353 sourceToEdit={g.source}351 sourceToEdit={g.source}
354 sourceDialogMeta={editMeta}352 sourceDialogMeta={editMeta}
355 sourceAuthProfiles={sourceAuthProfiles}353 sourceAuthProfiles={sourceAuthProfiles}
354 onSourceDeleted={onSourceDeleted}
356 trigger={355 trigger={
357 <Button356 <Button
358 variant="ghost"357 variant="ghost"
executor/apps/web/src/components/tools/explorer-rows.tsx+1−10
@@ -26,7 +26,6 @@ const ToolRow = memo(function ToolRow({ · 25 unmodified lines
26 onSelect,26 onSelect,
27 onExpandedChange,27 onExpandedChange,
28 detailLoading,28 detailLoading,
29 sourceSchemas,
30}: {29}: {
31 tool: ToolDescriptor;30 tool: ToolDescriptor;
32 label: string;31 label: string;
@@ -35,7 +34,6 @@ const ToolRow = memo(function ToolRow({ · 2 unmodified lines
35 onSelect: (e: React.MouseEvent) => void;34 onSelect: (e: React.MouseEvent) => void;
36 onExpandedChange?: (tool: ToolDescriptor, expanded: boolean) => void;35 onExpandedChange?: (tool: ToolDescriptor, expanded: boolean) => void;
37 detailLoading?: boolean;36 detailLoading?: boolean;
38 sourceSchemas?: Record<string, string>;
39}) {37}) {
40 const [expanded, setExpanded] = useState(false);38 const [expanded, setExpanded] = useState(false);
41 39
@@ -101,7 +99,7 @@ const ToolRow = memo(function ToolRow({ · 59 unmodified lines
101 </CollapsibleTrigger>99 </CollapsibleTrigger>
102 100
103 <CollapsibleContent>101 <CollapsibleContent>
104 <ToolDetail tool={tool} depth={depth} loading={detailLoading} sourceSchemas={sourceSchemas} />102 <ToolDetail tool={tool} depth={depth} loading={detailLoading} />
105 </CollapsibleContent>103 </CollapsibleContent>
106 </Collapsible>104 </Collapsible>
107 );105 );
@@ -115,7 +113,6 @@ export const SelectableToolRow = memo(function SelectableToolRow({ · 7 unmodified lines
115 onSelectTool,113 onSelectTool,
116 onExpandedChange,114 onExpandedChange,
117 detailLoading,115 detailLoading,
118 sourceSchemas,
119}: {116}: {
120 tool: ToolDescriptor;117 tool: ToolDescriptor;
121 label: string;118 label: string;
@@ -124,7 +121,6 @@ export const SelectableToolRow = memo(function SelectableToolRow({ · 2 unmodified lines
124 onSelectTool: (path: string, e: React.MouseEvent) => void;121 onSelectTool: (path: string, e: React.MouseEvent) => void;
125 onExpandedChange?: (tool: ToolDescriptor, expanded: boolean) => void;122 onExpandedChange?: (tool: ToolDescriptor, expanded: boolean) => void;
126 detailLoading?: boolean;123 detailLoading?: boolean;
127 sourceSchemas?: Record<string, string>;
128}) {124}) {
129 const selected = selectedKeys.has(tool.path);125 const selected = selectedKeys.has(tool.path);
130 const handleSelect = useCallback(126 const handleSelect = useCallback(
@@ -141,7 +137,6 @@ export const SelectableToolRow = memo(function SelectableToolRow({ · 10 unmodified lines
141 onSelect={handleSelect}137 onSelect={handleSelect}
142 onExpandedChange={onExpandedChange}138 onExpandedChange={onExpandedChange}
143 detailLoading={detailLoading}139 detailLoading={detailLoading}
144 sourceSchemas={sourceSchemas}
145 />140 />
146 );141 );
147},142},
@@ -150,7 +145,6 @@ export const SelectableToolRow = memo(function SelectableToolRow({ · 2 unmodified lines
150 prev.label === next.label &&145 prev.label === next.label &&
151 prev.depth === next.depth &&146 prev.depth === next.depth &&
152 prev.detailLoading === next.detailLoading &&147 prev.detailLoading === next.detailLoading &&
153 prev.sourceSchemas === next.sourceSchemas &&
154 prev.selectedKeys.has(prev.tool.path) === next.selectedKeys.has(next.tool.path),148 prev.selectedKeys.has(prev.tool.path) === next.selectedKeys.has(next.tool.path),
155);149);
156 150
@@ -161,7 +155,6 @@ export function VirtualFlatList({ · 4 unmodified lines
161 onExpandedChange,155 onExpandedChange,
162 detailLoadingPaths,156 detailLoadingPaths,
163 loadingRows,157 loadingRows,
164 sourceSchemasBySource,
165 scrollContainerRef,158 scrollContainerRef,
166}: {159}: {
167 tools: ToolDescriptor[];160 tools: ToolDescriptor[];
@@ -170,7 +163,6 @@ export function VirtualFlatList({ · 2 unmodified lines
170 onExpandedChange?: (tool: ToolDescriptor, expanded: boolean) => void;163 onExpandedChange?: (tool: ToolDescriptor, expanded: boolean) => void;
171 detailLoadingPaths?: Set<string>;164 detailLoadingPaths?: Set<string>;
172 loadingRows?: { source: string; count: number }[];165 loadingRows?: { source: string; count: number }[];
173 sourceSchemasBySource?: Record<string, Record<string, string>>;
174 scrollContainerRef: React.RefObject<HTMLDivElement | null>;166 scrollContainerRef: React.RefObject<HTMLDivElement | null>;
175}) {167}) {
176 return (168 return (
@@ -189,7 +181,6 @@ export function VirtualFlatList({ · 12 unmodified lines
189 onSelectTool={onSelectTool}181 onSelectTool={onSelectTool}
190 onExpandedChange={onExpandedChange}182 onExpandedChange={onExpandedChange}
191 detailLoading={detailLoadingPaths?.has(tool.path)}183 detailLoading={detailLoadingPaths?.has(tool.path)}
192 sourceSchemas={tool.source ? sourceSchemasBySource?.[tool.source] : undefined}
193 />184 />
194 ))}185 ))}
195 186
executor/apps/web/src/components/tools/explorer.tsx+8−24
@@ -50,17 +50,7 @@ interface ToolExplorerProps { · 49 unmodified lines
50 sourceDialogMeta?: Record<string, SourceDialogMeta>;50 sourceDialogMeta?: Record<string, SourceDialogMeta>;
51 sourceAuthProfiles?: Record<string, SourceAuthProfile>;51 sourceAuthProfiles?: Record<string, SourceAuthProfile>;
52 existingSourceNames?: Set<string>;52 existingSourceNames?: Set<string>;
53 sourceSchemas?: Record<string, Record<string, string>>;53 onSourceDeleted?: (sourceName: string) => void;
54}
55
56function hasSchemaReference(tool: ToolDescriptor): boolean {
57 const values = [
58 tool.argsType,
59 tool.returnsType,
60 tool.strictArgsType,
61 tool.strictReturnsType,
62 ];
63 return values.some((value) => value?.includes('components["schemas"]['));
64}54}
65 55
66export function ToolExplorer({56export function ToolExplorer({
@@ -78,7 +68,7 @@ export function ToolExplorer({ · 11 unmodified lines
78 sourceDialogMeta,68 sourceDialogMeta,
79 sourceAuthProfiles,69 sourceAuthProfiles,
80 existingSourceNames,70 existingSourceNames,
81 sourceSchemas = {},71 onSourceDeleted,
82}: ToolExplorerProps) {72}: ToolExplorerProps) {
83 const [searchInput, setSearchInput] = useState("");73 const [searchInput, setSearchInput] = useState("");
84 const search = useDeferredValue(searchInput);74 const search = useDeferredValue(searchInput);
@@ -308,19 +298,14 @@ export function ToolExplorer({ · 223 unmodified lines
308 return;298 return;
309 }299 }
310 300
311 const sourceSchemasForTool = tool.source ? sourceSchemas[tool.source] : undefined;
312 const needsSchemaHydration = hasSchemaReference(tool)
313 && (!sourceSchemasForTool || Object.keys(sourceSchemasForTool).length === 0);
314
315 const hasDetails = Boolean(301 const hasDetails = Boolean(
316 tool.description302 tool.description
317 || tool.strictArgsType303 || tool.display?.input
318 || tool.strictReturnsType304 || tool.display?.output
319 || tool.argsType305 || (tool.typing?.requiredInputKeys?.length ?? 0) > 0,
320 || tool.returnsType,
321 );306 );
322 307
323 if ((hasDetails || toolDetailsByPath[tool.path]) && !needsSchemaHydration) {308 if (hasDetails || toolDetailsByPath[tool.path]) {
324 return;309 return;
325 }310 }
326 311
@@ -347,7 +332,7 @@ export function ToolExplorer({ · 20 unmodified lines
347 return next;332 return next;
348 });333 });
349 }334 }
350 }, [loadingDetailPaths, onLoadToolDetails, sourceSchemas, toolDetailsByPath]);335 }, [loadingDetailPaths, onLoadToolDetails, toolDetailsByPath]);
351 336
352 const flatLoadingRows = useMemo(() => {337 const flatLoadingRows = useMemo(() => {
353 if (search.length > 0 || viewMode !== "flat") {338 if (search.length > 0 || viewMode !== "flat") {
@@ -407,6 +392,7 @@ export function ToolExplorer({ · 53 unmodified lines
407 sourceDialogMeta={sourceDialogMeta}392 sourceDialogMeta={sourceDialogMeta}
408 sourceAuthProfiles={sourceAuthProfiles}393 sourceAuthProfiles={sourceAuthProfiles}
409 existingSourceNames={sidebarExistingSourceNames}394 existingSourceNames={sidebarExistingSourceNames}
395 onSourceDeleted={onSourceDeleted}
410 />396 />
411 ) : null}397 ) : null}
412 398
@@ -461,7 +447,6 @@ export function ToolExplorer({ · 48 unmodified lines
461 onSelectTool={toggleSelectTool}447 onSelectTool={toggleSelectTool}
462 onExpandedChange={maybeLoadToolDetails}448 onExpandedChange={maybeLoadToolDetails}
463 detailLoadingPaths={loadingDetailPaths}449 detailLoadingPaths={loadingDetailPaths}
464 sourceSchemasBySource={sourceSchemas}
465 scrollContainerRef={flatListRef}450 scrollContainerRef={flatListRef}
466 loadingRows={flatLoadingRows}451 loadingRows={flatLoadingRows}
467 />452 />
@@ -491,7 +476,6 @@ export function ToolExplorer({ · 23 unmodified lines
491 onSelectTool={toggleSelectTool}476 onSelectTool={toggleSelectTool}
492 onExpandedChange={maybeLoadToolDetails}477 onExpandedChange={maybeLoadToolDetails}
493 detailLoadingPaths={loadingDetailPaths}478 detailLoadingPaths={loadingDetailPaths}
494 sourceSchemasBySource={sourceSchemas}
495 source={group.type === "source" ? sourceByName.get(group.label) : undefined}479 source={group.type === "source" ? sourceByName.get(group.label) : undefined}
496 search={search}480 search={search}
497 />481 />
executor/apps/web/src/components/tools/explorer/tool-detail.tsx+14−91
@@ -10,89 +10,18 @@ const codePlugin = createCodePlugin({ · 9 unmodified lines
10 themes: ["github-light", "github-dark"],10 themes: ["github-light", "github-dark"],
11});11});
12 12
13function extractSchemaRefTokens(typeExpression: string): string[] {
14 const tokens = new Set<string>();
15 const pattern = /components\["schemas"\]\["((?:\\.|[^"\\])*)"\]/g;
16
17 for (const match of typeExpression.matchAll(pattern)) {
18 const raw = match[1];
19 if (!raw) continue;
20
21 let schemaKey = raw;
22 try {
23 schemaKey = JSON.parse(`"${raw}"`);
24 } catch {
25 // Preserve original key when escape decoding fails.
26 }
27
28 tokens.add(`components["schemas"][${JSON.stringify(schemaKey)}]`);
29 }
30
31 return [...tokens];
32}
33
34function shouldUseStrictType(displayType?: string): boolean {
35 if (!displayType) return false;
36 return displayType.includes("...");
37}
38
39function escapeRegExp(value: string): string {
40 return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
41}
42
43function inlineSchemaRefs(typeExpression: string, sourceSchemas?: Record<string, string>): string {
44 if (!sourceSchemas || Object.keys(sourceSchemas).length === 0) {
45 return typeExpression;
46 }
47
48 let inlined = typeExpression;
49 for (const schemaRef of extractSchemaRefTokens(typeExpression)) {
50 const schemaType = sourceSchemas[schemaRef];
51 if (!schemaType) continue;
52 const pattern = new RegExp(escapeRegExp(schemaRef), "g");
53 inlined = inlined.replace(pattern, `(${schemaType})`);
54 }
55
56 return inlined;
57}
58
59export function ToolDetail({13export function ToolDetail({
60 tool,14 tool,
61 depth,15 depth,
62 loading: _loading = false,
63 sourceSchemas,
64}: {16}: {
65 tool: ToolDescriptor;17 tool: ToolDescriptor;
66 depth: number;18 depth: number;
67 loading?: boolean;
68 sourceSchemas?: Record<string, string>;
69}) {19}) {
70 const insetLeft = depth * 20 + 8 + 16 + 8;20 const insetLeft = depth * 20 + 8 + 16 + 8;
71 const displayArgsType = tool.argsType?.trim();21 const inputHint = tool.display?.input?.trim();
72 const displayReturnsType = tool.returnsType?.trim();22 const outputHint = tool.display?.output?.trim();
73 const strictArgsType = tool.strictArgsType?.trim();23 const required = tool.typing?.requiredInputKeys ?? [];
74 const strictReturnsType = tool.strictReturnsType?.trim();24 const hasDetails = Boolean(tool.description || inputHint || outputHint || required.length > 0);
75 const argsType = shouldUseStrictType(displayArgsType)
76 ? (strictArgsType || displayArgsType)
77 : (displayArgsType || strictArgsType);
78 const returnsType = shouldUseStrictType(displayReturnsType)
79 ? (strictReturnsType || displayReturnsType)
80 : (displayReturnsType || strictReturnsType);
81 const resolvedArgsType = argsType ? inlineSchemaRefs(argsType, sourceSchemas) : undefined;
82 const resolvedReturnsType = returnsType ? inlineSchemaRefs(returnsType, sourceSchemas) : undefined;
83 const schemaTypes = [
84 resolvedArgsType,
85 resolvedReturnsType,
86 ]
87 .filter((value): value is string => Boolean(value?.trim()))
88 .flatMap((value) => extractSchemaRefTokens(value));
89 const schemaEntries = [...new Set(schemaTypes)]
90 .map((schemaRef) => ({
91 schemaRef,
92 schemaType: sourceSchemas?.[schemaRef],
93 }))
94 .filter((entry): entry is { schemaRef: string; schemaType: string } => Boolean(entry.schemaType));
95 const hasDetails = Boolean(tool.description || resolvedArgsType || resolvedReturnsType);
96 25
97 return (26 return (
98 <div className="space-y-2.5 pb-3 pt-1 pr-2" style={{ paddingLeft: insetLeft }}>27 <div className="space-y-2.5 pb-3 pt-1 pr-2" style={{ paddingLeft: insetLeft }}>
@@ -122,25 +51,19 @@ export function ToolDetail({ · 23 unmodified lines
122 </div>51 </div>
123 )}52 )}
124 53
125 {resolvedArgsType && <TypeSignature raw={resolvedArgsType} label="Arguments" />}54 {inputHint && <TypeSignature raw={inputHint} label="Arguments" />}
126 {resolvedReturnsType && <TypeSignature raw={resolvedReturnsType} label="Returns" />}55 {outputHint && <TypeSignature raw={outputHint} label="Returns" />}
127 {schemaEntries.length > 0 && (56
128 <div className="space-y-2">57 {required.length > 0 ? (
58 <div className="space-y-1">
129 <p className="text-[9px] font-mono uppercase tracking-wider text-muted-foreground/50">59 <p className="text-[9px] font-mono uppercase tracking-wider text-muted-foreground/50">
130 Schemas60 Required Keys
61 </p>
62 <p className="text-[11px] font-mono text-muted-foreground break-words">
63 {required.join(", ")}
131 </p>64 </p>
132 {schemaEntries.map(({ schemaRef, schemaType }) => (
133 <div key={schemaRef} className="space-y-1">
134 <p className="text-[10px] font-mono text-muted-foreground break-all">
135 {schemaRef}
136 </p>
137 <pre className="text-[11px] font-mono leading-relaxed text-foreground/80 bg-muted/40 border border-border/40 rounded-md px-2.5 py-2 overflow-x-auto whitespace-pre-wrap break-all">
138 {schemaType}
139 </pre>
140 </div>
141 ))}
142 </div>65 </div>
143 )}66 ) : null}
144 </div>67 </div>
145 );68 );
146}69}
executor/apps/web/src/components/tools/explorer/type-signature.tsx+11−18
@@ -1,6 +1,7 @@
1"use client";1"use client";
2 2
3import { useEffect, useMemo, useState } from "react";3import { useMemo } from "react";
4import { useQuery as useTanstackQuery } from "@tanstack/react-query";
4 5
5function prettyType(raw: string): string {6function prettyType(raw: string): string {
6 const trimmed = raw.trim();7 const trimmed = raw.trim();
@@ -48,26 +49,18 @@ function prettyType(raw: string): string { · 41 unmodified lines
48 49
49export function TypeSignature({ raw, label }: { raw: string; label: string }) {50export function TypeSignature({ raw, label }: { raw: string; label: string }) {
50 const formatted = useMemo(() => prettyType(raw), [raw]);51 const formatted = useMemo(() => prettyType(raw), [raw]);
51 const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);
52 52
53 useEffect(() => {53 const { data: highlightedHtml } = useTanstackQuery<string>({
54 let cancelled = false;54 queryKey: ["type-signature", formatted],
55 55 queryFn: async () => {
56 import("shiki").then(({ codeToHtml }) =>56 const { codeToHtml } = await import("shiki");
57 codeToHtml(formatted, {57 return codeToHtml(formatted, {
58 lang: "typescript",58 lang: "typescript",
59 themes: { light: "github-light", dark: "github-dark" },59 themes: { light: "github-light", dark: "github-dark" },
60 }).then((html) => {60 });
61 if (!cancelled) {61 },
62 setHighlightedHtml(html);62 staleTime: Infinity,
63 }63 });
64 }),
65 );
66
67 return () => {
68 cancelled = true;
69 };
70 }, [formatted]);
71 64
72 return (65 return (
73 <div>66 <div>
executor/apps/web/src/components/tools/setup-card.tsx+18−33
@@ -1,8 +1,9 @@
1"use client";1"use client";
2 2
3import { useEffect, useMemo, useState } from "react";3import { useMemo, useState } from "react";
4import { Check, ChevronRight, Copy } from "lucide-react";4import { Check, ChevronRight, Copy } from "lucide-react";
5import Image from "next/image";5import Image from "next/image";
6import { useQuery as useTanstackQuery } from "@tanstack/react-query";
6import { Button } from "@/components/ui/button";7import { Button } from "@/components/ui/button";
7import {8import {
8 Collapsible,9 Collapsible,
@@ -64,8 +65,6 @@ export function McpSetupCard({ · 55 unmodified lines
64 const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code");65 const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code");
65 const [copiedKey, setCopiedKey] = useState<string | null>(null);66 const [copiedKey, setCopiedKey] = useState<string | null>(null);
66 const [headersOpen, setHeadersOpen] = useState(false);67 const [headersOpen, setHeadersOpen] = useState(false);
67 const [anonymousAccessToken, setAnonymousAccessToken] = useState<string | null>(null);
68 const [anonymousTokenError, setAnonymousTokenError] = useState<string | null>(null);
69 68
70 const origin = useMemo(() => {69 const origin = useMemo(() => {
71 if (typeof window === "undefined") return "";70 if (typeof window === "undefined") return "";
@@ -86,36 +85,22 @@ export function McpSetupCard({ · 14 unmodified lines
86 85
87 const isAnonymousSession = isAnonymousSessionId(sessionId);86 const isAnonymousSession = isAnonymousSessionId(sessionId);
88 87
89 useEffect(() => {88 const anonymousTokenQuery = useTanstackQuery<{ accessToken: string; actorId: string; expiresAtMs: number } | null>({
90 let cancelled = false;89 queryKey: ["mcp-anonymous-token", sessionId],
91 90 queryFn: async () => {
92 if (!isAnonymousSession) {91 const token = await getAnonymousAuthToken();
93 setAnonymousAccessToken(null);92 return token;
94 setAnonymousTokenError(null);93 },
95 return;94 enabled: isAnonymousSession,
96 }95 retry: false,
97 96 });
98 const hydrateToken = async () => {97
99 try {98 const anonymousAccessToken = anonymousTokenQuery.data?.accessToken ?? null;
100 const token = await getAnonymousAuthToken();99 const anonymousTokenError = anonymousTokenQuery.isError
101 if (!cancelled) {100 ? anonymousTokenQuery.error instanceof Error
102 setAnonymousAccessToken(token.accessToken);101 ? anonymousTokenQuery.error.message
103 setAnonymousTokenError(null);102 : "Failed to get anonymous token"
104 }103 : null;
105 } catch (error) {
106 if (!cancelled) {
107 setAnonymousAccessToken(null);
108 setAnonymousTokenError(error instanceof Error ? error.message : "Failed to get anonymous token");
109 }
110 }
111 };
112
113 void hydrateToken();
114
115 return () => {
116 cancelled = true;
117 };
118 }, [isAnonymousSession, sessionId]);
119 104
120 const headerLines = useMemo(() => {105 const headerLines = useMemo(() => {
121 return [106 return [
executor/apps/web/src/components/tools/source-favicon.tsx+6−8
@@ -1,6 +1,6 @@
1"use client";1"use client";
2 2
3import { useEffect, useState } from "react";3import { useState } from "react";
4import { Layers, Globe, Server } from "lucide-react";4import { Layers, Globe, Server } from "lucide-react";
5import Image from "next/image";5import Image from "next/image";
6import type { ToolSourceRecord } from "@/lib/types";6import type { ToolSourceRecord } from "@/lib/types";
@@ -38,19 +38,17 @@ export function SourceFavicon({ · 31 unmodified lines
38 : source38 : source
39 ? getSourceFavicon(source)39 ? getSourceFavicon(source)
40 : null;40 : null;
41 const [failed, setFailed] = useState(false);41 const [failedSrc, setFailedSrc] = useState<string | null>(null);
42 const isFailed = Boolean(sourceFavicon && failedSrc === sourceFavicon);
42 43
43 useEffect(() => {44 if (!sourceFavicon || isFailed) {
44 setFailed(false);
45 }, [sourceFavicon]);
46
47 if (!sourceFavicon || failed) {
48 const sourceType = fallbackType ?? source?.type ?? "openapi";45 const sourceType = fallbackType ?? source?.type ?? "openapi";
49 return <DefaultSourceIcon type={sourceType} className={iconClassName} />;46 return <DefaultSourceIcon type={sourceType} className={iconClassName} />;
50 }47 }
51 48
52 return (49 return (
53 <Image50 <Image
51 key={sourceFavicon}
54 src={sourceFavicon}52 src={sourceFavicon}
55 alt=""53 alt=""
56 width={imageSize}54 width={imageSize}
@@ -58,7 +56,7 @@ export function SourceFavicon({ · 1 unmodified line
58 className={imageClassName ?? "w-full h-full object-contain"}56 className={imageClassName ?? "w-full h-full object-contain"}
59 loading="lazy"57 loading="lazy"
60 unoptimized58 unoptimized
61 onError={() => setFailed(true)}59 onError={() => setFailedSrc(sourceFavicon)}
62 />60 />
63 );61 );
64}62}
executor/apps/web/src/components/tools/view.tsx+7−4
@@ -1,6 +1,6 @@
1"use client";1"use client";
2 2
3import { useMemo, useState } from "react";3import { useCallback, useMemo, useState } from "react";
4import {4import {
5 Plus,5 Plus,
6} from "lucide-react";6} from "lucide-react";
@@ -76,12 +76,11 @@ export function ToolsView({ · 69 unmodified lines
76 warnings,76 warnings,
77 sourceQuality,77 sourceQuality,
78 sourceAuthProfiles,78 sourceAuthProfiles,
79 sourceSchemas,
80 loadingSources,79 loadingSources,
81 loadingTools,80 loadingTools,
82 refreshingTools,81 refreshingTools,
83 loadToolDetails,82 loadToolDetails,
84 } = useWorkspaceTools(context ?? null, { includeDetails: false, includeDtsUrls: false });83 } = useWorkspaceTools(context ?? null, { includeDetails: false });
85 const existingSourceNames = useMemo(() => new Set(sourceItems.map((source) => source.name)), [sourceItems]);84 const existingSourceNames = useMemo(() => new Set(sourceItems.map((source) => source.name)), [sourceItems]);
86 const toolSourceNames = useMemo(85 const toolSourceNames = useMemo(
87 () => new Set(tools.map((tool) => sourceLabel(tool.source))),86 () => new Set(tools.map((tool) => sourceLabel(tool.source))),
@@ -104,6 +103,10 @@ export function ToolsView({ · 16 unmodified lines
104 && (sourceItems.some((source) => source.name === selectedSource) || toolSourceNames.has(selectedSource))103 && (sourceItems.some((source) => source.name === selectedSource) || toolSourceNames.has(selectedSource))
105 ? selectedSource104 ? selectedSource
106 : null;105 : null;
106
107 const handleSourceDeleted = useCallback((sourceName: string) => {
108 setSelectedSource((current) => (current === sourceName ? null : current));
109 }, []);
107 const openConnectionCreate = (sourceKey?: string) => {110 const openConnectionCreate = (sourceKey?: string) => {
108 setConnectionDialogEditing(null);111 setConnectionDialogEditing(null);
109 setConnectionDialogSourceKey(sourceKey ?? null);112 setConnectionDialogSourceKey(sourceKey ?? null);
@@ -196,8 +199,8 @@ export function ToolsView({ · 86 unmodified lines
196 loading={loadingTools}199 loading={loadingTools}
197 sourceDialogMeta={sourceDialogMeta}200 sourceDialogMeta={sourceDialogMeta}
198 sourceAuthProfiles={sourceAuthProfiles}201 sourceAuthProfiles={sourceAuthProfiles}
199 sourceSchemas={sourceSchemas}
200 existingSourceNames={existingSourceNames}202 existingSourceNames={existingSourceNames}
203 onSourceDeleted={handleSourceDeleted}
201 onLoadToolDetails={loadToolDetails}204 onLoadToolDetails={loadToolDetails}
202 warnings={warnings}205 warnings={warnings}
203 initialSource={initialSource}206 initialSource={initialSource}
executor/apps/web/src/hooks/use/workspace-tools.ts+7−105
@@ -1,10 +1,10 @@
1"use client";1"use client";
2 2
3import { useCallback, useEffect, useRef, useState } from "react";3import { useCallback, useEffect, useRef } from "react";
4import { useQuery as useTanstackQuery } from "@tanstack/react-query";4import { useQuery as useTanstackQuery } from "@tanstack/react-query";
5import { useAction, useQuery as useConvexQuery } from "convex/react";5import { useAction, useQuery as useConvexQuery } from "convex/react";
6import { convexApi } from "@/lib/convex-api";6import { convexApi } from "@/lib/convex-api";
7import type { OpenApiSourceQuality, SourceAuthProfile, ToolDescriptor, ToolSourceRecord } from "@/lib/types";7import type { OpenApiSourceQuality, SourceAuthProfile, ToolDescriptor } from "@/lib/types";
8import type { Id } from "@executor/convex/_generated/dataModel";8import type { Id } from "@executor/convex/_generated/dataModel";
9 9
10interface WorkspaceContext {10interface WorkspaceContext {
@@ -19,7 +19,8 @@ interface WorkspaceToolsQueryResult { · 8 unmodified lines
19 warnings: string[];19 warnings: string[];
20 sourceQuality: Record<string, OpenApiSourceQuality>;20 sourceQuality: Record<string, OpenApiSourceQuality>;
21 sourceAuthProfiles: Record<string, SourceAuthProfile>;21 sourceAuthProfiles: Record<string, SourceAuthProfile>;
22 sourceSchemas: Record<string, Record<string, string>>;22 /** URL to a workspace-wide Monaco `.d.ts` bundle (may be undefined). */
23 typesUrl?: string;
23 debug?: {24 debug?: {
24 mode: "cache-fresh" | "cache-stale" | "rebuild";25 mode: "cache-fresh" | "cache-stale" | "rebuild";
25 includeDts: boolean;26 includeDts: boolean;
@@ -35,13 +36,8 @@ interface WorkspaceToolsQueryResult { · 9 unmodified lines
35 };36 };
36}37}
37 38
38interface WorkspaceToolDtsResult {
39 dtsUrls: Record<string, string>;
40}
41
42interface UseWorkspaceToolsOptions {39interface UseWorkspaceToolsOptions {
43 includeDetails?: boolean;40 includeDetails?: boolean;
44 includeDtsUrls?: boolean;
45}41}
46 42
47/**43/**
@@ -55,12 +51,9 @@ export function useWorkspaceTools( · 7 unmodified lines
55 options: UseWorkspaceToolsOptions = {},51 options: UseWorkspaceToolsOptions = {},
56) {52) {
57 const includeDetails = options.includeDetails ?? true;53 const includeDetails = options.includeDetails ?? true;
58 const includeDtsUrls = options.includeDtsUrls ?? true;
59 const listToolsWithWarningsRaw = useAction(convexApi.executorNode.listToolsWithWarnings);54 const listToolsWithWarningsRaw = useAction(convexApi.executorNode.listToolsWithWarnings);
60 const listToolDtsUrls = useAction(convexApi.executorNode.listToolDtsUrls);
61 const listToolsWithWarnings = listToolsWithWarningsRaw as unknown as (args: Record<string, unknown>) => Promise<WorkspaceToolsQueryResult>;55 const listToolsWithWarnings = listToolsWithWarningsRaw as unknown as (args: Record<string, unknown>) => Promise<WorkspaceToolsQueryResult>;
62 const detailsCacheRef = useRef<Map<string, ToolDescriptor>>(new Map());56 const detailsCacheRef = useRef<Map<string, ToolDescriptor>>(new Map());
63 const [sourceSchemasBySource, setSourceSchemasBySource] = useState<Record<string, Record<string, string>>>({});
64 57
65 // Watch tool sources reactively so we invalidate when sources change58 // Watch tool sources reactively so we invalidate when sources change
66 const toolSources = useConvexQuery(59 const toolSources = useConvexQuery(
@@ -88,7 +81,7 @@ export function useWorkspaceTools( · 21 unmodified lines
88 warnings: [],81 warnings: [],
89 sourceQuality: {},82 sourceQuality: {},
90 sourceAuthProfiles: {},83 sourceAuthProfiles: {},
91 sourceSchemas: {},84 typesUrl: undefined,
92 };85 };
93 }86 }
94 return await listToolsWithWarnings({87 return await listToolsWithWarnings({
@@ -97,7 +90,6 @@ export function useWorkspaceTools( · 2 unmodified lines
97 ...(context.clientId && { clientId: context.clientId }),90 ...(context.clientId && { clientId: context.clientId }),
98 ...(context.sessionId && { sessionId: context.sessionId }),91 ...(context.sessionId && { sessionId: context.sessionId }),
99 includeDetails,92 includeDetails,
100 includeSchemaRegistry: false,
101 });93 });
102 },94 },
103 enabled: !!context,95 enabled: !!context,
@@ -133,39 +125,12 @@ export function useWorkspaceTools( · 29 unmodified lines
133 ...(context.sessionId && { sessionId: context.sessionId }),125 ...(context.sessionId && { sessionId: context.sessionId }),
134 includeDetails: true,126 includeDetails: true,
135 includeSourceMeta: false,127 includeSourceMeta: false,
136 includeSchemaRegistry: true,
137 toolPaths: missing,128 toolPaths: missing,
138 });129 });
139 130
140 for (const tool of detailedInventory.tools) {131 for (const tool of detailedInventory.tools) {
141 cache.set(tool.path, tool);132 cache.set(tool.path, tool);
142 }133 }
143
144 if (Object.keys(detailedInventory.sourceSchemas ?? {}).length > 0) {
145 setSourceSchemasBySource((prev) => {
146 let changed = false;
147 const next: Record<string, Record<string, string>> = { ...prev };
148
149 for (const [source, schemas] of Object.entries(detailedInventory.sourceSchemas ?? {})) {
150 const current = next[source] ?? {};
151 let sourceChanged = false;
152 const merged = { ...current };
153
154 for (const [schemaRef, schemaType] of Object.entries(schemas)) {
155 if (merged[schemaRef] === schemaType) continue;
156 merged[schemaRef] = schemaType;
157 sourceChanged = true;
158 }
159
160 if (sourceChanged || !next[source]) {
161 next[source] = merged;
162 changed = true;
163 }
164 }
165
166 return changed ? next : prev;
167 });
168 }
169 }134 }
170 135
171 const result: Record<string, ToolDescriptor> = {};136 const result: Record<string, ToolDescriptor> = {};
@@ -182,10 +147,6 @@ export function useWorkspaceTools( · 10 unmodified lines
182 detailsCacheRef.current.clear();147 detailsCacheRef.current.clear();
183 }, [context?.workspaceId, context?.actorId, context?.clientId, context?.sessionId]);148 }, [context?.workspaceId, context?.actorId, context?.clientId, context?.sessionId]);
184 149
185 useEffect(() => {
186 setSourceSchemasBySource({});
187 }, [context?.workspaceId, context?.actorId, context?.clientId, context?.sessionId]);
188
189 useEffect(() => {150 useEffect(() => {
190 if (!inventoryData || !includeDetails) {151 if (!inventoryData || !includeDetails) {
191 return;152 return;
@@ -196,77 +157,18 @@ export function useWorkspaceTools( · 4 unmodified lines
196 }157 }
197 }, [inventoryData, includeDetails]);158 }, [inventoryData, includeDetails]);
198 159
199 useEffect(() => {
200 const nextSchemas = inventoryData?.sourceSchemas ?? {};
201 if (Object.keys(nextSchemas).length === 0) {
202 return;
203 }
204
205 setSourceSchemasBySource((prev) => {
206 let changed = false;
207 const next: Record<string, Record<string, string>> = { ...prev };
208
209 for (const [source, schemas] of Object.entries(nextSchemas)) {
210 const current = next[source] ?? {};
211 let sourceChanged = false;
212 const merged = { ...current };
213
214 for (const [schemaRef, schemaType] of Object.entries(schemas)) {
215 if (merged[schemaRef] === schemaType) continue;
216 merged[schemaRef] = schemaType;
217 sourceChanged = true;
218 }
219
220 if (sourceChanged || !next[source]) {
221 next[source] = merged;
222 changed = true;
223 }
224 }
225
226 return changed ? next : prev;
227 });
228 }, [inventoryData?.sourceSchemas]);
229
230 const hasOpenApiSource = (toolSources ?? []).some(
231 (source: ToolSourceRecord) => source.type === "openapi" && source.enabled,
232 );
233
234 const { data: dtsData, isLoading: dtsLoading } = useTanstackQuery({
235 queryKey: [
236 "workspace-tools-dts",
237 context?.workspaceId,
238 context?.actorId,
239 includeDtsUrls,
240 toolSources,
241 ],
242 queryFn: async (): Promise<WorkspaceToolDtsResult> => {
243 if (!context) {
244 return { dtsUrls: {} };
245 }
246 return await listToolDtsUrls({
247 workspaceId: context.workspaceId,
248 ...(context.actorId && { actorId: context.actorId }),
249 ...(context.sessionId && { sessionId: context.sessionId }),
250 });
251 },
252 enabled: !!context && !!inventoryData && hasOpenApiSource && includeDtsUrls,
253 placeholderData: (previousData) => previousData,
254 });
255
256 return {160 return {
257 tools: inventoryData?.tools ?? [],161 tools: inventoryData?.tools ?? [],
258 warnings: inventoryData?.warnings ?? [],162 warnings: inventoryData?.warnings ?? [],
259 /** Per-source .d.ts download URLs for Monaco IntelliSense. Keyed by source key (e.g. "openapi:cloudflare"). */163 /** Workspace-wide Monaco `.d.ts` bundle URL (may be undefined). */
260 dtsUrls: dtsData?.dtsUrls ?? {},164 typesUrl: inventoryData?.typesUrl,
261 /** Per-source OpenAPI quality metrics (unknown/fallback type rates). */165 /** Per-source OpenAPI quality metrics (unknown/fallback type rates). */
262 sourceQuality: inventoryData?.sourceQuality ?? {},166 sourceQuality: inventoryData?.sourceQuality ?? {},
263 sourceAuthProfiles: inventoryData?.sourceAuthProfiles ?? {},167 sourceAuthProfiles: inventoryData?.sourceAuthProfiles ?? {},
264 sourceSchemas: sourceSchemasBySource,
265 debug: inventoryData?.debug,168 debug: inventoryData?.debug,
266 loadingSources: inventoryData?.debug?.timedOutSources ?? [],169 loadingSources: inventoryData?.debug?.timedOutSources ?? [],
267 loadingTools: !!context && toolsLoading,170 loadingTools: !!context && toolsLoading,
268 refreshingTools: !!context && toolsFetching,171 refreshingTools: !!context && toolsFetching,
269 loadingTypes: !!context && includeDtsUrls && hasOpenApiSource && !!inventoryData && dtsLoading,
270 // Backward compatibility for callers that still use a single loading state.172 // Backward compatibility for callers that still use a single loading state.
271 loading: !!context && toolsLoading,173 loading: !!context && toolsLoading,
272 loadToolDetails,174 loadToolDetails,
executor/apps/web/src/lib/convex-provider.tsx+22−39
@@ -1,6 +1,6 @@
1"use client";1"use client";
2 2
3import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";3import { createContext, useCallback, useContext, useMemo } from "react";
4import { ConvexProviderWithAuth } from "convex/react";4import { ConvexProviderWithAuth } from "convex/react";
5import {5import {
6 AuthKitProvider,6 AuthKitProvider,
@@ -9,6 +9,7 @@ import { · 2 unmodified lines
9} from "@workos-inc/authkit-nextjs/components";9} from "@workos-inc/authkit-nextjs/components";
10import { ConvexReactClient } from "convex/react";10import { ConvexReactClient } from "convex/react";
11import type { ReactNode } from "react";11import type { ReactNode } from "react";
12import { useQueryClient, useQuery as useTanstackQuery } from "@tanstack/react-query";
12import {13import {
13 getAnonymousAuthToken,14 getAnonymousAuthToken,
14 readStoredAnonymousAuthToken,15 readStoredAnonymousAuthToken,
@@ -34,58 +35,40 @@ export function useWorkosAuthState() { · 19 unmodified lines
34}35}
35 36
36function useConvexAuthFromAnonymous() {37function useConvexAuthFromAnonymous() {
37 const [loading, setLoading] = useState(() => readStoredAnonymousAuthToken() === null);38 const queryClient = useQueryClient();
38 const [token, setToken] = useState<string | null>(() => readStoredAnonymousAuthToken()?.accessToken ?? null);39 const tokenQuery = useTanstackQuery<string | null>({
39 40 queryKey: ["anonymous-auth-token"],
40 useEffect(() => {41 queryFn: async () => {
41 let cancelled = false;42 const auth = await getAnonymousAuthToken();
42 43 return auth.accessToken;
43 const bootstrap = async () => {44 },
44 try {45 initialData: () => readStoredAnonymousAuthToken()?.accessToken ?? null,
45 const auth = await getAnonymousAuthToken();46 retry: false,
46 if (!cancelled) {47 });
47 setToken(auth.accessToken);
48 }
49 } catch {
50 if (!cancelled) {
51 setToken(null);
52 }
53 } finally {
54 if (!cancelled) {
55 setLoading(false);
56 }
57 }
58 };
59
60 void bootstrap();
61
62 return () => {
63 cancelled = true;
64 };
65 }, []);
66 48
67 const fetchAccessToken = useCallback(async () => {49 const fetchAccessToken = useCallback(async () => {
68 const stored = readStoredAnonymousAuthToken();50 const stored = readStoredAnonymousAuthToken();
69 if (stored) {51 if (stored) {
70 if (stored.accessToken !== token) {52 if (stored.accessToken !== tokenQuery.data) {
71 setToken(stored.accessToken);53 queryClient.setQueryData(["anonymous-auth-token"], stored.accessToken);
54 return stored.accessToken;
72 }55 }
73 return stored.accessToken;56 queryClient.setQueryData(["anonymous-auth-token"], tokenQuery.data);
57 return tokenQuery.data;
74 }58 }
75 59
76 const refreshed = await getAnonymousAuthToken(true);60 const refreshed = await getAnonymousAuthToken(true);
77 setToken(refreshed.accessToken);61 queryClient.setQueryData(["anonymous-auth-token"], refreshed.accessToken);
78 setLoading(false);
79 return refreshed.accessToken;62 return refreshed.accessToken;
80 }, [token]);63 }, [queryClient, tokenQuery.data]);
81 64
82 return useMemo(65 return useMemo(
83 () => ({66 () => ({
84 isLoading: loading,67 isLoading: tokenQuery.isPending,
85 isAuthenticated: Boolean(token),68 isAuthenticated: Boolean(tokenQuery.data),
86 fetchAccessToken,69 fetchAccessToken,
87 }),70 }),
88 [fetchAccessToken, loading, token],71 [fetchAccessToken, tokenQuery.isPending, tokenQuery.data],
89 );72 );
90}73}
91 74
executor/apps/web/src/lib/types.ts+13−6
@@ -106,12 +106,19 @@ export interface ToolDescriptor { · 105 unmodified lines
106 description: string;106 description: string;
107 approval: ToolApprovalMode;107 approval: ToolApprovalMode;
108 source?: string;108 source?: string;
109 argsType?: string;109 typing?: {
110 returnsType?: string;110 requiredInputKeys?: string[];
111 strictArgsType?: string;111 previewInputKeys?: string[];
112 strictReturnsType?: string;112 typedRef?: {
113 argPreviewKeys?: string[];113 kind: "openapi_operation";
114 operationId?: string;114 sourceKey: string;
115 operationId: string;
116 };
117 };
118 display?: {
119 input?: string;
120 output?: string;
121 };
115}122}
116 123
117export interface OpenApiSourceQuality {124export interface OpenApiSourceQuality {
executor/packages/convex/_generated/api.d.ts+114−12
@@ -130,12 +130,6 @@ export declare const api: { · 129 unmodified lines
130 >;130 >;
131 };131 };
132 executorNode: {132 executorNode: {
133 listToolDtsUrls: FunctionReference<
134 "action",
135 "public",
136 { actorId?: string; sessionId?: string; workspaceId: Id<"workspaces"> },
137 any
138 >;
139 listToolsWithWarnings: FunctionReference<133 listToolsWithWarnings: FunctionReference<
140 "action",134 "action",
141 "public",135 "public",
@@ -143,7 +137,6 @@ export declare const api: { · 1 unmodified line
143 actorId?: string;137 actorId?: string;
144 clientId?: string;138 clientId?: string;
145 includeDetails?: boolean;139 includeDetails?: boolean;
146 includeSchemaRegistry?: boolean;
147 includeSourceMeta?: boolean;140 includeSourceMeta?: boolean;
148 sessionId?: string;141 sessionId?: string;
149 toolPaths?: Array<string>;142 toolPaths?: Array<string>;
@@ -287,7 +280,7 @@ export declare const api: { · 137 unmodified lines
287 bootstrapAnonymousSession: FunctionReference<280 bootstrapAnonymousSession: FunctionReference<
288 "mutation",281 "mutation",
289 "public",282 "public",
290 { sessionId?: string },283 { actorId?: string; sessionId?: string },
291 any284 any
292 >;285 >;
293 deleteToolSource: FunctionReference<286 deleteToolSource: FunctionReference<
@@ -1098,7 +1091,7 @@ export declare const internal: { · 804 unmodified lines
1098 >;1091 >;
1099 };1092 };
1100 migrations: {1093 migrations: {
1101 backfillDtsStorageIds: FunctionReference<1094 cleanupAccessPolicyEmptyStringSentinels: FunctionReference<
1102 "mutation",1095 "mutation",
1103 "internal",1096 "internal",
1104 {1097 {
@@ -1110,7 +1103,7 @@ export declare const internal: { · 5 unmodified lines
1110 },1103 },
1111 any1104 any
1112 >;1105 >;
1113 cleanupAccessPolicyEmptyStringSentinels: FunctionReference<1106 cleanupTaskEmptyStringSentinels: FunctionReference<
1114 "mutation",1107 "mutation",
1115 "internal",1108 "internal",
1116 {1109 {
@@ -1122,7 +1115,7 @@ export declare const internal: { · 5 unmodified lines
1122 },1115 },
1123 any1116 any
1124 >;1117 >;
1125 cleanupTaskEmptyStringSentinels: FunctionReference<1118 cleanupWorkspaceToolCacheLegacyDtsStorageIds: FunctionReference<
1126 "mutation",1119 "mutation",
1127 "internal",1120 "internal",
1128 {1121 {
@@ -1226,6 +1219,115 @@ export declare const internal: { · 97 unmodified lines
1226 any1219 any
1227 >;1220 >;
1228 };1221 };
1222 toolRegistry: {
1223 beginBuild: FunctionReference<
1224 "mutation",
1225 "internal",
1226 { buildId: string; signature: string; workspaceId: Id<"workspaces"> },
1227 any
1228 >;
1229 finishBuild: FunctionReference<
1230 "mutation",
1231 "internal",
1232 { buildId: string; workspaceId: Id<"workspaces"> },
1233 any
1234 >;
1235 getState: FunctionReference<
1236 "query",
1237 "internal",
1238 { workspaceId: Id<"workspaces"> },
1239 any
1240 >;
1241 getToolByPath: FunctionReference<
1242 "query",
1243 "internal",
1244 { buildId: string; path: string; workspaceId: Id<"workspaces"> },
1245 any
1246 >;
1247 getToolsByNormalizedPath: FunctionReference<
1248 "query",
1249 "internal",
1250 {
1251 buildId: string;
1252 limit: number;
1253 normalizedPath: string;
1254 workspaceId: Id<"workspaces">;
1255 },
1256 any
1257 >;
1258 listNamespaces: FunctionReference<
1259 "query",
1260 "internal",
1261 { buildId: string; limit: number; workspaceId: Id<"workspaces"> },
1262 any
1263 >;
1264 listToolsByNamespace: FunctionReference<
1265 "query",
1266 "internal",
1267 {
1268 buildId: string;
1269 limit: number;
1270 namespace: string;
1271 workspaceId: Id<"workspaces">;
1272 },
1273 any
1274 >;
1275 putNamespacesBatch: FunctionReference<
1276 "mutation",
1277 "internal",
1278 {
1279 buildId: string;
1280 namespaces: Array<{
1281 namespace: string;
1282 samplePaths: Array<string>;
1283 toolCount: number;
1284 }>;
1285 workspaceId: Id<"workspaces">;
1286 },
1287 any
1288 >;
1289 putToolsBatch: FunctionReference<
1290 "mutation",
1291 "internal",
1292 {
1293 buildId: string;
1294 tools: Array<{
1295 aliases: Array<string>;
1296 approval: "auto" | "required";
1297 description: string;
1298 displayInput?: string;
1299 displayOutput?: string;
1300 namespace: string;
1301 normalizedPath: string;
1302 path: string;
1303 preferredPath: string;
1304 previewInputKeys?: Array<string>;
1305 requiredInputKeys?: Array<string>;
1306 searchText: string;
1307 serializedToolJson: string;
1308 source?: string;
1309 typedRef?: {
1310 kind: "openapi_operation";
1311 operationId: string;
1312 sourceKey: string;
1313 };
1314 }>;
1315 workspaceId: Id<"workspaces">;
1316 },
1317 any
1318 >;
1319 searchTools: FunctionReference<
1320 "query",
1321 "internal",
1322 {
1323 buildId: string;
1324 limit: number;
1325 query: string;
1326 workspaceId: Id<"workspaces">;
1327 },
1328 any
1329 >;
1330 };
1229 workspaceAuthInternal: {1331 workspaceAuthInternal: {
1230 getWorkspaceAccessForAnonymousSubject: FunctionReference<1332 getWorkspaceAccessForAnonymousSubject: FunctionReference<
1231 "query",1333 "query",
@@ -1257,11 +1359,11 @@ export declare const internal: { · 25 unmodified lines
1257 "mutation",1359 "mutation",
1258 "internal",1360 "internal",
1259 {1361 {
1260 dtsStorageIds: Array<{ sourceKey: string; storageId: Id<"_storage"> }>;
1261 signature: string;1362 signature: string;
1262 sizeBytes: number;1363 sizeBytes: number;
1263 storageId: Id<"_storage">;1364 storageId: Id<"_storage">;
1264 toolCount: number;1365 toolCount: number;
1366 typesStorageId?: Id<"_storage">;
1265 workspaceId: Id<"workspaces">;1367 workspaceId: Id<"workspaces">;
1266 },1368 },
1267 any1369 any
executor/packages/convex/access-controls.test.ts+1−0
@@ -21,6 +21,7 @@ function setup() { · 20 unmodified lines
21 "./app.ts": () => import("./app"),21 "./app.ts": () => import("./app"),
22 "./billingInternal.ts": () => import("./billingInternal"),22 "./billingInternal.ts": () => import("./billingInternal"),
23 "./billingSync.ts": () => import("./billingSync"),23 "./billingSync.ts": () => import("./billingSync"),
24 "./toolRegistry.ts": () => import("./toolRegistry"),
24 "./_generated/api.js": () => import("./_generated/api.js"),25 "./_generated/api.js": () => import("./_generated/api.js"),
25 });26 });
26}27}
executor/packages/convex/accounts.ts+2−4
@@ -64,10 +64,8 @@ async function deleteWorkspaceData( · 63 unmodified lines
64 .collect();64 .collect();
65 for (const cacheEntry of cachedToolsets) {65 for (const cacheEntry of cachedToolsets) {
66 await ctx.storage.delete(cacheEntry.storageId).catch(() => {});66 await ctx.storage.delete(cacheEntry.storageId).catch(() => {});
67 if (cacheEntry.dtsStorageIds) {67 if (cacheEntry.typesStorageId) {
68 for (const dtsEntry of cacheEntry.dtsStorageIds) {68 await ctx.storage.delete(cacheEntry.typesStorageId).catch(() => {});
69 await ctx.storage.delete(dtsEntry.storageId).catch(() => {});
70 }
71 }69 }
72 await ctx.db.delete(cacheEntry._id);70 await ctx.db.delete(cacheEntry._id);
73 }71 }
executor/packages/convex/database.test.ts+1−0
@@ -7,6 +7,7 @@ import schema from "./schema"; · 6 unmodified lines
7function setup() {7function setup() {
8 return convexTest(schema, {8 return convexTest(schema, {
9 "./database.ts": () => import("./database"),9 "./database.ts": () => import("./database"),
10 "./toolRegistry.ts": () => import("./toolRegistry"),
10 "./_generated/api.js": () => import("./_generated/api.js"),11 "./_generated/api.js": () => import("./_generated/api.js"),
11 });12 });
12}13}
executor/packages/convex/executor-mcp.e2e.test.ts+39−70
@@ -2,7 +2,6 @@ import { expect, test } from "bun:test"; · 1 unmodified line
2import { convexTest } from "convex-test";2import { convexTest } from "convex-test";
3import { Client } from "@modelcontextprotocol/sdk/client/index.js";3import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6import { internal } from "./_generated/api";5import { internal } from "./_generated/api";
7import type { Id } from "./_generated/dataModel.d.ts";6import type { Id } from "./_generated/dataModel.d.ts";
8import schema from "./schema";7import schema from "./schema";
@@ -16,12 +15,36 @@ function setup() { · 7 unmodified lines
16 "./auth.ts": () => import("./auth"),15 "./auth.ts": () => import("./auth"),
17 "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),16 "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),
18 "./workspaceToolCache.ts": () => import("./workspaceToolCache"),17 "./workspaceToolCache.ts": () => import("./workspaceToolCache"),
18 "./toolRegistry.ts": () => import("./toolRegistry"),
19 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),19 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),
20 "./_generated/api.js": () => import("./_generated/api.js"),20 "./_generated/api.js": () => import("./_generated/api.js"),
21 });21 });
22}22}
23 23
24function createMcpTransport(24async function getAnonymousAccessToken(
25 t: ReturnType<typeof setup>,
26 actorId: string,
27): Promise<string | null> {
28 const resp = await t.fetch(`/auth/anonymous/token?actorId=${encodeURIComponent(actorId)}`);
29 const body = await resp.json().catch(() => ({} as Record<string, unknown>));
30 if (resp.status === 503) {
31 const msg = typeof (body as any).error === "string" ? (body as any).error : "";
32 if (msg.toLowerCase().includes("not configured")) {
33 return null;
34 }
35 }
36 if (!resp.ok) {
37 const msg = typeof (body as any).error === "string" ? (body as any).error : `HTTP ${resp.status}`;
38 throw new Error(`Failed to issue anonymous token: ${msg}`);
39 }
40 const token = (body as any).accessToken;
41 if (typeof token !== "string" || token.length === 0) {
42 throw new Error("Anonymous token response missing accessToken");
43 }
44 return token;
45}
46
47async function createMcpTransport(
25 t: ReturnType<typeof setup>,48 t: ReturnType<typeof setup>,
26 workspaceId: string,49 workspaceId: string,
27 actorId: string,50 actorId: string,
@@ -32,19 +55,27 @@ function createMcpTransport( · 4 unmodified lines
32 const mcpPath = isAnonymousSession ? "/mcp/anonymous" : "/mcp";55 const mcpPath = isAnonymousSession ? "/mcp/anonymous" : "/mcp";
33 const url = new URL(`https://executor.test${mcpPath}`);56 const url = new URL(`https://executor.test${mcpPath}`);
34 url.searchParams.set("workspaceId", workspaceId);57 url.searchParams.set("workspaceId", workspaceId);
35 if (isAnonymousSession) {58 if (!isAnonymousSession) {
36 url.searchParams.set("actorId", actorId);
37 } else {
38 url.searchParams.set("sessionId", sessionId);59 url.searchParams.set("sessionId", sessionId);
39 }60 }
40 url.searchParams.set("clientId", clientId);61 url.searchParams.set("clientId", clientId);
41 62
63 const anonymousToken = isAnonymousSession ? await getAnonymousAccessToken(t, actorId) : null;
64 if (isAnonymousSession && !anonymousToken) {
65 // Legacy fallback for local/test when anonymous auth isn't configured.
66 url.searchParams.set("actorId", actorId);
67 }
68
42 return new StreamableHTTPClientTransport(url, {69 return new StreamableHTTPClientTransport(url, {
43 fetch: async (input, init) => {70 fetch: async (input, init) => {
44 const raw = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;71 const raw = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;
45 const parsed = new URL(raw);72 const parsed = new URL(raw);
46 const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;73 const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
47 return await t.fetch(path, init);74 const headers = new Headers(init?.headers ?? {});
75 if (anonymousToken) {
76 headers.set("authorization", `Bearer ${anonymousToken}`);
77 }
78 return await t.fetch(path, { ...init, headers });
48 },79 },
49 });80 });
50}81}
@@ -86,7 +117,7 @@ test("MCP run_code survives delayed approval and completes", async () => { · 35 unmodified lines
86 const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});117 const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
87 118
88 const client = new Client({ name: "executor-e2e", version: "0.0.1" }, { capabilities: {} });119 const client = new Client({ name: "executor-e2e", version: "0.0.1" }, { capabilities: {} });
89 const transport = createMcpTransport(t, session.workspaceId, session.actorId, session.sessionId, "e2e-approval-delay");120 const transport = await createMcpTransport(t, session.workspaceId, session.actorId, session.sessionId, "e2e-approval-delay");
90 121
91 try {122 try {
92 await client.connect(transport);123 await client.connect(transport);
@@ -132,74 +163,12 @@ test("MCP run_code survives delayed approval and completes", async () => { · 39 unmodified lines
132 }163 }
133}, 60_000);164}, 60_000);
134 165
135test("MCP run_code resolves approval via real server form elicitation", async () => {
136 const t = setup();
137 const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
138 let elicitationCount = 0;
139
140 const client = new Client(
141 { name: "executor-e2e-elicitation", version: "0.0.1" },
142 { capabilities: { elicitation: { form: {} } } },
143 );
144 client.setRequestHandler(ElicitRequestSchema, async (request) => {
145 const mode = request.params.mode ?? "form";
146 if (mode !== "form") {
147 return { action: "decline" };
148 }
149
150 elicitationCount += 1;
151 return {
152 action: "accept",
153 content: {
154 decision: "approved",
155 reason: "approved via MCP form elicitation",
156 },
157 };
158 });
159
160 const transport = createMcpTransport(t, session.workspaceId, session.actorId, session.sessionId, "e2e-elicitation");
161
162 try {
163 await client.connect(transport);
164
165 const runCode = client.callTool({
166 name: "run_code",
167 arguments: {
168 code: `return await tools.admin.send_announcement({ channel: "general", message: "approved by real-server elicitation" });`,
169 },
170 });
171
172 const taskId = await waitForTaskId(t, session.workspaceId);
173 const runTask = t.action(internal.executorNode.runTask, { taskId });
174
175 await runTask;
176
177 const result = (await runCode) as {
178 content: Array<{ type: string; text?: string }>;
179 isError?: boolean;
180 };
181 const text = result.content.find((item) => item.type === "text")?.text ?? "";
182
183 expect(elicitationCount).toBe(1);
184 expect(result.isError).toBeFalsy();
185 expect(text).toContain("status: completed");
186 expect(text).toContain("approved by real-server elicitation");
187
188 const approvals = await t.query(internal.database.listApprovals, { workspaceId: session.workspaceId });
189 const approval = approvals.find((item: { taskId: string }) => item.taskId === taskId);
190 expect(approval?.status).toBe("approved");
191 } finally {
192 await transport.close().catch(() => {});
193 await client.close().catch(() => {});
194 }
195}, 30_000);
196
197test("MCP run_code returns denied after approval denial", async () => {166test("MCP run_code returns denied after approval denial", async () => {
198 const t = setup();167 const t = setup();
199 const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});168 const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
200 169
201 const client = new Client({ name: "executor-e2e", version: "0.0.1" }, { capabilities: {} });170 const client = new Client({ name: "executor-e2e", version: "0.0.1" }, { capabilities: {} });
202 const transport = createMcpTransport(t, session.workspaceId, session.actorId, session.sessionId, "e2e-deny");171 const transport = await createMcpTransport(t, session.workspaceId, session.actorId, session.sessionId, "e2e-deny");
203 172
204 try {173 try {
205 await client.connect(transport);174 await client.connect(transport);
executor/packages/convex/executor.ts+6−5
@@ -11,6 +11,7 @@ import { isTerminalTaskStatus, taskTerminalEventType } from "./task/status"; · 10 unmodified lines
11import { DEFAULT_TASK_TIMEOUT_MS } from "./task/constants";11import { DEFAULT_TASK_TIMEOUT_MS } from "./task/constants";
12import { createTaskEvent } from "./task/events";12import { createTaskEvent } from "./task/events";
13import { markTaskFinished } from "./task/finish";13import { markTaskFinished } from "./task/finish";
14import { safeRunAfter } from "./lib/scheduler";
14 15
15type TaskCreateContext = Pick<MutationCtx, "runMutation"> & {16type TaskCreateContext = Pick<MutationCtx, "runMutation"> & {
16 scheduler?: Pick<MutationCtx, "scheduler">["scheduler"];17 scheduler?: Pick<MutationCtx, "scheduler">["scheduler"];
@@ -84,9 +85,7 @@ async function createTaskRecord( · 67 unmodified lines
84 throw new Error("Task scheduling is unavailable in this execution context");85 throw new Error("Task scheduling is unavailable in this execution context");
85 }86 }
86 87
87 await ctx.scheduler.runAfter(1, internal.executorNode.runTask, {88 await safeRunAfter(ctx.scheduler, 1, internal.executorNode.runTask, { taskId });
88 taskId,
89 });
90 }89 }
91 90
92 return { task };91 return { task };
@@ -174,7 +173,9 @@ export const createTask = action({ · 81 unmodified lines
174 }173 }
175 174
176 const waitForResult = args.waitForResult ?? false;175 const waitForResult = args.waitForResult ?? false;
177 const created = await createTaskRecord(ctx as TaskCreateContext, {176 // Use the internal mutation so task scheduling runs in a mutation context
177 // (convex-test does not support scheduler writes directly from actions).
178 const created = await ctx.runMutation(internal.executor.createTaskInternal, {
178 code: args.code,179 code: args.code,
179 timeoutMs: args.timeoutMs,180 timeoutMs: args.timeoutMs,
180 runtimeId: args.runtimeId,181 runtimeId: args.runtimeId,
@@ -186,7 +187,7 @@ export const createTask = action({ · 5 unmodified lines
186 });187 });
187 188
188 if (!waitForResult) {189 if (!waitForResult) {
189 return { task: created.task };190 return { task: created.task as TaskRecord };
190 }191 }
191 192
192 const runOutcome = await ctx.runAction(internal.executorNode.runTask, {193 const runOutcome = await ctx.runAction(internal.executorNode.runTask, {
executor/packages/convex/executorNode.github-profile.e2e.test.ts+7−18
@@ -1,6 +1,5 @@
1import { expect, test } from "bun:test";1import { expect, test } from "bun:test";
2import { convexTest } from "convex-test";2import { convexTest } from "convex-test";
3import { api } from "./_generated/api";
4import { internal } from "./_generated/api";3import { internal } from "./_generated/api";
5import schema from "./schema";4import schema from "./schema";
6 5
@@ -13,6 +12,7 @@ function setup() { · 6 unmodified lines
13 "./executorNode.ts": () => import("./executorNode"),12 "./executorNode.ts": () => import("./executorNode"),
14 "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),13 "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),
15 "./workspaceToolCache.ts": () => import("./workspaceToolCache"),14 "./workspaceToolCache.ts": () => import("./workspaceToolCache"),
15 "./toolRegistry.ts": () => import("./toolRegistry"),
16 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),16 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),
17 "./_generated/api.js": () => import("./_generated/api.js"),17 "./_generated/api.js": () => import("./_generated/api.js"),
18 });18 });
@@ -47,11 +47,6 @@ test("convex-test keeps GitHub inventory build warm-cache fast", async () => { · 28 unmodified lines
47 });47 });
48 const warmMs = performance.now() - warmStart;48 const warmMs = performance.now() - warmStart;
49 49
50 const publicWarm = await t.action(api.executorNode.listToolsWithWarnings, {
51 workspaceId: session.workspaceId,
52 sessionId: session.sessionId,
53 });
54
55 console.log(50 console.log(
56 `github openapi convex-test profiling: cold=${coldMs.toFixed(0)}ms warm=${warmMs.toFixed(0)}ms tools=${cold.tools.length}`,51 `github openapi convex-test profiling: cold=${coldMs.toFixed(0)}ms warm=${warmMs.toFixed(0)}ms tools=${cold.tools.length}`,
57 );52 );
@@ -60,18 +55,12 @@ test("convex-test keeps GitHub inventory build warm-cache fast", async () => { · 2 unmodified lines
60 expect(cold.tools.length).toBe(warm.tools.length);55 expect(cold.tools.length).toBe(warm.tools.length);
61 expect(cold.warnings.some((warning: string) => warning.includes("skipped bundle"))).toBe(false);56 expect(cold.warnings.some((warning: string) => warning.includes("skipped bundle"))).toBe(false);
62 expect(warm.warnings.some((warning: string) => warning.includes("skipped bundle"))).toBe(false);57 expect(warm.warnings.some((warning: string) => warning.includes("skipped bundle"))).toBe(false);
63 expect(Object.keys(cold.dtsUrls).length).toBe(0);58 expect(typeof cold.typesUrl).toBe("string");
64 expect(Object.keys(warm.dtsUrls).length).toBe(0);59 expect(typeof warm.typesUrl).toBe("string");
65 expect(publicWarm.tools.length).toBe(warm.tools.length);60 expect(cold.typesUrl).toBe(warm.typesUrl);
66 expect(publicWarm.debug.skipCacheRead).toBe(false);61 expect(warm.debug.skipCacheRead).toBe(false);
67 expect(publicWarm.debug.cacheHit).toBe(true);62 expect(warm.debug.cacheHit).toBe(true);
68 expect(publicWarm.debug.mode).toBe("cache-fresh");63 expect(warm.debug.mode).toBe("cache-fresh");
69
70 const dts = await t.action(api.executorNode.listToolDtsUrls, {
71 workspaceId: session.workspaceId,
72 sessionId: session.sessionId,
73 });
74 expect(Object.keys(dts.dtsUrls).length).toBeGreaterThan(0);
75 64
76 expect(coldMs).toBeLessThan(12_000);65 expect(coldMs).toBeLessThan(12_000);
77 expect(coldMs).toBeGreaterThan(warmMs * 3);66 expect(coldMs).toBeGreaterThan(warmMs * 3);
executor/packages/convex/executorNode.ts+6−35
@@ -10,11 +10,10 @@ import type { · 9 unmodified lines
10 SourceAuthProfile,10 SourceAuthProfile,
11} from "../core/src/types";11} from "../core/src/types";
12import { requireCanonicalActor } from "./runtime/actor_auth";12import { requireCanonicalActor } from "./runtime/actor_auth";
13import { safeRunAfter } from "./lib/scheduler";
13import {14import {
14 listToolsForContext,15 listToolsForContext,
15 listToolsWithWarningsForContext,16 listToolsWithWarningsForContext,
16 loadDtsUrls,
17 loadWorkspaceDtsStorageIds,
18 type WorkspaceToolsDebug,17 type WorkspaceToolsDebug,
19} from "./runtime/workspace_tools";18} from "./runtime/workspace_tools";
20import { runQueuedTask } from "./runtime/task_runner";19import { runQueuedTask } from "./runtime/task_runner";
@@ -28,7 +27,6 @@ export const listToolsWithWarnings = action({ · 7 unmodified lines
28 sessionId: v.optional(v.string()),27 sessionId: v.optional(v.string()),
29 includeDetails: v.optional(v.boolean()),28 includeDetails: v.optional(v.boolean()),
30 includeSourceMeta: v.optional(v.boolean()),29 includeSourceMeta: v.optional(v.boolean()),
31 includeSchemaRegistry: v.optional(v.boolean()),
32 toolPaths: v.optional(v.array(v.string())),30 toolPaths: v.optional(v.array(v.string())),
33 },31 },
34 handler: async (32 handler: async (
@@ -37,10 +35,9 @@ export const listToolsWithWarnings = action({ · 2 unmodified lines
37 ): Promise<{35 ): Promise<{
38 tools: ToolDescriptor[];36 tools: ToolDescriptor[];
39 warnings: string[];37 warnings: string[];
40 dtsUrls: Record<string, string>;38 typesUrl?: string;
41 sourceQuality: Record<string, OpenApiSourceQuality>;39 sourceQuality: Record<string, OpenApiSourceQuality>;
42 sourceAuthProfiles: Record<string, SourceAuthProfile>;40 sourceAuthProfiles: Record<string, SourceAuthProfile>;
43 sourceSchemas: Record<string, Record<string, string>>;
44 debug: WorkspaceToolsDebug;41 debug: WorkspaceToolsDebug;
45 }> => {42 }> => {
46 const canonicalActorId = await requireCanonicalActor(ctx, {43 const canonicalActorId = await requireCanonicalActor(ctx, {
@@ -54,10 +51,8 @@ export const listToolsWithWarnings = action({ · 7 unmodified lines
54 actorId: canonicalActorId,51 actorId: canonicalActorId,
55 clientId: args.clientId,52 clientId: args.clientId,
56 }, {53 }, {
57 includeDts: false,
58 includeDetails: args.includeDetails ?? true,54 includeDetails: args.includeDetails ?? true,
59 includeSourceMeta: args.includeSourceMeta ?? (args.toolPaths ? false : true),55 includeSourceMeta: args.includeSourceMeta ?? (args.toolPaths ? false : true),
60 includeSchemaRegistry: args.includeSchemaRegistry ?? false,
61 toolPaths: args.toolPaths,56 toolPaths: args.toolPaths,
62 sourceTimeoutMs: 2_500,57 sourceTimeoutMs: 2_500,
63 allowStaleOnMismatch: true,58 allowStaleOnMismatch: true,
@@ -65,7 +60,7 @@ export const listToolsWithWarnings = action({ · 1 unmodified line
65 60
66 if (inventory.warnings.some((warning) => warning.includes("showing previous results while refreshing"))) {61 if (inventory.warnings.some((warning) => warning.includes("showing previous results while refreshing"))) {
67 try {62 try {
68 await ctx.scheduler.runAfter(0, internal.executorNode.listToolsWithWarningsInternal, {63 await safeRunAfter(ctx.scheduler, 0, internal.executorNode.listToolsWithWarningsInternal, {
69 workspaceId: args.workspaceId,64 workspaceId: args.workspaceId,
70 actorId: canonicalActorId,65 actorId: canonicalActorId,
71 clientId: args.clientId,66 clientId: args.clientId,
@@ -79,29 +74,6 @@ export const listToolsWithWarnings = action({ · 7 unmodified lines
79 },74 },
80});75});
81 76
82export const listToolDtsUrls = action({
83 args: {
84 workspaceId: v.id("workspaces"),
85 actorId: v.optional(v.string()),
86 sessionId: v.optional(v.string()),
87 },
88 handler: async (
89 ctx,
90 args,
91 ): Promise<{
92 dtsUrls: Record<string, string>;
93 }> => {
94 await requireCanonicalActor(ctx, {
95 workspaceId: args.workspaceId,
96 sessionId: args.sessionId,
97 actorId: args.actorId,
98 });
99 const dtsStorageIds = await loadWorkspaceDtsStorageIds(ctx, args.workspaceId);
100 const dtsUrls = await loadDtsUrls(ctx, dtsStorageIds);
101 return { dtsUrls };
102 },
103});
104
105export const listToolsInternal = internalAction({77export const listToolsInternal = internalAction({
106 args: {78 args: {
107 workspaceId: v.id("workspaces"),79 workspaceId: v.id("workspaces"),
@@ -109,7 +81,7 @@ export const listToolsInternal = internalAction({ · 1 unmodified line
109 clientId: v.optional(v.string()),81 clientId: v.optional(v.string()),
110 },82 },
111 handler: async (ctx, args): Promise<ToolDescriptor[]> => {83 handler: async (ctx, args): Promise<ToolDescriptor[]> => {
112 return await listToolsForContext(ctx, args, { includeDts: false });84 return await listToolsForContext(ctx, args);
113 },85 },
114});86});
115 87
@@ -125,13 +97,12 @@ export const listToolsWithWarningsInternal = internalAction({ · 9 unmodified lines
125 ): Promise<{97 ): Promise<{
126 tools: ToolDescriptor[];98 tools: ToolDescriptor[];
127 warnings: string[];99 warnings: string[];
128 dtsUrls: Record<string, string>;100 typesUrl?: string;
129 sourceQuality: Record<string, OpenApiSourceQuality>;101 sourceQuality: Record<string, OpenApiSourceQuality>;
130 sourceAuthProfiles: Record<string, SourceAuthProfile>;102 sourceAuthProfiles: Record<string, SourceAuthProfile>;
131 sourceSchemas: Record<string, Record<string, string>>;
132 debug: WorkspaceToolsDebug;103 debug: WorkspaceToolsDebug;
133 }> => {104 }> => {
134 return await listToolsWithWarningsForContext(ctx, args, { includeDts: false });105 return await listToolsWithWarningsForContext(ctx, args);
135 },106 },
136});107});
137 108
executor/packages/convex/http/mcp_handler.ts+55−35
@@ -2,6 +2,7 @@ import { httpAction } from "../_generated/server"; · 1 unmodified line
2import { internal } from "../_generated/api";2import { internal } from "../_generated/api";
3import { handleMcpRequest, type McpWorkspaceContext } from "../../core/src/mcp-server";3import { handleMcpRequest, type McpWorkspaceContext } from "../../core/src/mcp-server";
4import { isAnonymousIdentity } from "../auth/anonymous";4import { isAnonymousIdentity } from "../auth/anonymous";
5import { getAnonymousAuthIssuer } from "../auth/anonymous";
5import {6import {
6 getMcpAuthConfig,7 getMcpAuthConfig,
7 isAnonymousSessionId,8 isAnonymousSessionId,
@@ -13,6 +14,12 @@ import { createMcpExecutorService } from "./mcp_service"; · 5 unmodified lines
13 14
14type McpEndpointMode = "default" | "anonymous";15type McpEndpointMode = "default" | "anonymous";
15 16
17function isAnonymousAuthConfigured(): boolean {
18 const issuer = getAnonymousAuthIssuer();
19 const privateKeyPem = process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM;
20 return Boolean(issuer && privateKeyPem && privateKeyPem.trim().length > 0);
21}
22
16function createMcpHandler(mode: McpEndpointMode) {23function createMcpHandler(mode: McpEndpointMode) {
17 return httpAction(async (ctx, request) => {24 return httpAction(async (ctx, request) => {
18 const url = new URL(request.url);25 const url = new URL(request.url);
@@ -32,48 +39,61 @@ function createMcpHandler(mode: McpEndpointMode) { · 13 unmodified lines
32 39
33 let context: McpWorkspaceContext | undefined;40 let context: McpWorkspaceContext | undefined;
34 41
35 if (mode === "anonymous") {42 if (mode === "anonymous") {
36 try {43 try {
37 const workspaceId = requestedContext?.workspaceId;44 const workspaceId = requestedContext?.workspaceId;
38 if (!workspaceId) {45 if (!workspaceId) {
39 return Response.json(46 return Response.json(
40 { error: "workspaceId query parameter is required for /mcp/anonymous" },47 { error: "workspaceId query parameter is required for /mcp/anonymous" },
41 { status: 400 },48 { status: 400 },
42 );49 );
43 }50 }
44 51
45 if (requestedContext?.sessionId || requestedContext?.actorId) {52 const anonymousAuthConfigured = isAnonymousAuthConfigured();
46 return Response.json(53 if (anonymousAuthConfigured && (requestedContext?.sessionId || requestedContext?.actorId)) {
47 {54 return Response.json(
48 error:55 {
49 "Legacy anonymous context query params are disabled. Use Authorization: Bearer <anonymous token>.",56 error:
50 },57 "Legacy anonymous context query params are disabled. Use Authorization: Bearer <anonymous token>.",
51 { status: 400 },58 },
52 );59 { status: 400 },
53 }60 );
61 }
54 62
55 const identity = await ctx.auth.getUserIdentity().catch(() => null);63 const identity = await ctx.auth.getUserIdentity().catch(() => null);
56 if (!identity || !isAnonymousIdentity(identity)) {64 if (identity && isAnonymousIdentity(identity)) {
57 return Response.json(65 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForAnonymousSubject, {
58 { error: "Anonymous bearer token is required for /mcp/anonymous" },66 workspaceId,
59 { status: 401 },67 actorId: identity.subject,
60 );68 });
61 }
62 69
63 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForAnonymousSubject, {70 context = {
64 workspaceId,71 workspaceId,
65 actorId: identity.subject,72 actorId: access.actorId,
66 });73 clientId: requestedContext?.clientId,
67 74 };
68 context = {75 } else if (!anonymousAuthConfigured && requestedContext?.actorId) {
69 workspaceId,76 // Local/test fallback: allow query-param actorId when anonymous auth isn't configured.
70 actorId: access.actorId,77 const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForAnonymousSubject, {
71 clientId: requestedContext?.clientId,78 workspaceId,
72 };79 actorId: requestedContext.actorId,
73 } catch (error) {80 });
74 return Response.json(81
75 { error: error instanceof Error ? error.message : "Workspace authorization failed" },82 context = {
76 { status: 403 },83 workspaceId,
84 actorId: access.actorId,
85 clientId: requestedContext?.clientId,
86 };
87 } else {
88 return Response.json(
89 { error: "Anonymous bearer token is required for /mcp/anonymous" },
90 { status: 401 },
91 );
92 }
93 } catch (error) {
94 return Response.json(
95 { error: error instanceof Error ? error.message : "Workspace authorization failed" },
96 { status: 403 },
77 );97 );
78 }98 }
79 } else {99 } else {
executor/packages/convex/lib/scheduler.ts+26−0
@@ -0,0 +1,26 @@
1type SchedulerLike = {
2 runAfter: (delayMs: number, functionReference: any, args: any) => Promise<any>;
3};
4
5export function isSchedulerDisabled(): boolean {
6 return process.env.DISABLE_CONVEX_SCHEDULER === "1";
7}
8
9export async function safeRunAfter(
10 scheduler: SchedulerLike | undefined,
11 delayMs: number,
12 functionReference: any,
13 args: any,
14): Promise<boolean> {
15 if (!scheduler || isSchedulerDisabled()) {
16 return false;
17 }
18
19 try {
20 await scheduler.runAfter(delayMs, functionReference, args);
21 return true;
22 } catch {
23 // Best effort only.
24 return false;
25 }
26}
executor/packages/convex/mcp/run/code-oom.e2e.test.ts+38−6
@@ -16,12 +16,36 @@ function setup() { · 15 unmodified lines
16 "../../auth.ts": () => import("../../auth"),16 "../../auth.ts": () => import("../../auth"),
17 "../../workspaceAuthInternal.ts": () => import("../../workspaceAuthInternal"),17 "../../workspaceAuthInternal.ts": () => import("../../workspaceAuthInternal"),
18 "../../workspaceToolCache.ts": () => import("../../workspaceToolCache"),18 "../../workspaceToolCache.ts": () => import("../../workspaceToolCache"),
19 "../../toolRegistry.ts": () => import("../../toolRegistry"),
19 "../../openApiSpecCache.ts": () => import("../../openApiSpecCache"),20 "../../openApiSpecCache.ts": () => import("../../openApiSpecCache"),
20 "../../_generated/api.js": () => import("../../_generated/api.js"),21 "../../_generated/api.js": () => import("../../_generated/api.js"),
21 });22 });
22}23}
23 24
24function createMcpTransport(25async function getAnonymousAccessToken(
26 t: ReturnType<typeof setup>,
27 actorId: string,
28): Promise<string | null> {
29 const resp = await t.fetch(`/auth/anonymous/token?actorId=${encodeURIComponent(actorId)}`);
30 const body = await resp.json().catch(() => ({} as Record<string, unknown>));
31 if (resp.status === 503) {
32 const msg = typeof (body as any).error === "string" ? (body as any).error : "";
33 if (msg.toLowerCase().includes("not configured")) {
34 return null;
35 }
36 }
37 if (!resp.ok) {
38 const msg = typeof (body as any).error === "string" ? (body as any).error : `HTTP ${resp.status}`;
39 throw new Error(`Failed to issue anonymous token: ${msg}`);
40 }
41 const token = (body as any).accessToken;
42 if (typeof token !== "string" || token.length === 0) {
43 throw new Error("Anonymous token response missing accessToken");
44 }
45 return token;
46}
47
48async function createMcpTransport(
25 t: ReturnType<typeof setup>,49 t: ReturnType<typeof setup>,
26 workspaceId: string,50 workspaceId: string,
27 actorId: string,51 actorId: string,
@@ -32,19 +56,27 @@ function createMcpTransport( · 4 unmodified lines
32 const mcpPath = isAnonymousSession ? "/mcp/anonymous" : "/mcp";56 const mcpPath = isAnonymousSession ? "/mcp/anonymous" : "/mcp";
33 const url = new URL(`https://executor.test${mcpPath}`);57 const url = new URL(`https://executor.test${mcpPath}`);
34 url.searchParams.set("workspaceId", workspaceId);58 url.searchParams.set("workspaceId", workspaceId);
35 if (isAnonymousSession) {59 if (!isAnonymousSession) {
36 url.searchParams.set("actorId", actorId);
37 } else {
38 url.searchParams.set("sessionId", sessionId);60 url.searchParams.set("sessionId", sessionId);
39 }61 }
40 url.searchParams.set("clientId", clientId);62 url.searchParams.set("clientId", clientId);
41 63
64 const anonymousToken = isAnonymousSession ? await getAnonymousAccessToken(t, actorId) : null;
65 if (isAnonymousSession && !anonymousToken) {
66 // Legacy fallback for local/test when anonymous auth isn't configured.
67 url.searchParams.set("actorId", actorId);
68 }
69
42 return new StreamableHTTPClientTransport(url, {70 return new StreamableHTTPClientTransport(url, {
43 fetch: async (input, init) => {71 fetch: async (input, init) => {
44 const raw = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;72 const raw = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;
45 const parsed = new URL(raw);73 const parsed = new URL(raw);
46 const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;74 const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
47 return await t.fetch(path, init);75 const headers = new Headers(init?.headers ?? {});
76 if (anonymousToken) {
77 headers.set("authorization", `Bearer ${anonymousToken}`);
78 }
79 return await t.fetch(path, { ...init, headers });
48 },80 },
49 });81 });
50}82}
@@ -54,7 +86,7 @@ test("MCP run_code no longer hits typecheck OOM path", async () => { · 3 unmodified lines
54 const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});86 const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
55 87
56 const client = new Client({ name: "executor-oom-repro", version: "0.0.1" }, { capabilities: {} });88 const client = new Client({ name: "executor-oom-repro", version: "0.0.1" }, { capabilities: {} });
57 const transport = createMcpTransport(t, session.workspaceId, session.actorId, session.sessionId);89 const transport = await createMcpTransport(t, session.workspaceId, session.actorId, session.sessionId);
58 90
59 try {91 try {
60 await client.connect(transport);92 await client.connect(transport);
executor/packages/convex/migrations.ts+21−9
@@ -92,15 +92,6 @@ export const deleteAnonymousSessionsMissingUserId = migrations.define({ · 91 unmodified lines
92 },92 },
93});93});
94 94
95export const backfillDtsStorageIds = migrations.define({
96 table: "workspaceToolCache",
97 migrateOne: async (_ctx, entry) => {
98 if (entry.dtsStorageIds === undefined) {
99 return { dtsStorageIds: [] };
100 }
101 },
102});
103
104export const cleanupTaskEmptyStringSentinels = migrations.define({95export const cleanupTaskEmptyStringSentinels = migrations.define({
105 table: "tasks",96 table: "tasks",
106 migrateOne: async (_ctx, task) => {97 migrateOne: async (_ctx, task) => {
@@ -121,4 +112,25 @@ export const cleanupAccessPolicyEmptyStringSentinels = migrations.define({ · 14 unmodified lines
121 },112 },
122});113});
123 114
115export const cleanupWorkspaceToolCacheLegacyDtsStorageIds = migrations.define({
116 table: "workspaceToolCache",
117 migrateOne: async (ctx, entry) => {
118 const legacy = (entry as any).dtsStorageIds as Array<{ storageId?: string }> | undefined;
119 if (!Array.isArray(legacy) || legacy.length === 0) {
120 if ((entry as any).dtsStorageIds !== undefined) {
121 return { dtsStorageIds: undefined };
122 }
123 return;
124 }
125
126 for (const dts of legacy) {
127 if (dts && typeof dts.storageId === "string") {
128 await ctx.storage.delete(dts.storageId as any).catch(() => {});
129 }
130 }
131
132 return { dtsStorageIds: undefined };
133 },
134});
135
124export const run = migrations.runner();136export const run = migrations.runner();
executor/packages/convex/openApiSpecCache.test.ts+1−0
@@ -6,6 +6,7 @@ import schema from "./schema"; · 5 unmodified lines
6function setup() {6function setup() {
7 return convexTest(schema, {7 return convexTest(schema, {
8 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),8 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),
9 "./toolRegistry.ts": () => import("./toolRegistry"),
9 "./_generated/api.js": () => import("./_generated/api.js"),10 "./_generated/api.js": () => import("./_generated/api.js"),
10 });11 });
11}12}
executor/packages/convex/runtime/tool_call_resolution.ts+84−53
@@ -1,17 +1,20 @@
1"use node";1"use node";
2 2
3import type { ActionCtx } from "../_generated/server";3import type { ActionCtx } from "../_generated/server";
4import { internal } from "../_generated/api";
4import { parseGraphqlOperationPaths } from "../../core/src/graphql/operation-paths";5import { parseGraphqlOperationPaths } from "../../core/src/graphql/operation-paths";
5import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../core/src/types";6import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../core/src/types";
7import { rehydrateTools, type SerializedTool } from "../../core/src/tool/source-serialization";
6import { getDecisionForContext, getToolDecision } from "./policy";8import { getDecisionForContext, getToolDecision } from "./policy";
7import { resolveAliasedToolPath, resolveClosestToolPath, suggestToolPaths, toPreferredToolPath } from "./tool_paths";9import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";
8import { baseTools, getWorkspaceTools } from "./workspace_tools";10import { baseTools } from "./workspace_tools";
11import { sourceSignature } from "./tool_source_loading";
9 12
10export function getGraphqlDecision(13export function getGraphqlDecision(
11 task: TaskRecord,14 task: TaskRecord,
12 tool: ToolDefinition,15 tool: ToolDefinition,
13 input: unknown,16 input: unknown,
14 workspaceTools: Map<string, ToolDefinition>,17 workspaceTools: Map<string, ToolDefinition> | undefined,
15 policies: AccessPolicyRecord[],18 policies: AccessPolicyRecord[],
16): { decision: PolicyDecision; effectivePaths: string[] } {19): { decision: PolicyDecision; effectivePaths: string[] } {
17 const sourceName = tool._graphqlSource!;20 const sourceName = tool._graphqlSource!;
@@ -30,7 +33,7 @@ export function getGraphqlDecision( · 12 unmodified lines
30 let worstDecision: PolicyDecision = "allow";33 let worstDecision: PolicyDecision = "allow";
31 34
32 for (const fieldPath of fieldPaths) {35 for (const fieldPath of fieldPaths) {
33 const pseudoTool = workspaceTools.get(fieldPath);36 const pseudoTool = workspaceTools?.get(fieldPath);
34 const fieldDecision = pseudoTool37 const fieldDecision = pseudoTool
35 ? getDecisionForContext(38 ? getDecisionForContext(
36 pseudoTool,39 pseudoTool,
@@ -63,31 +66,52 @@ export function getGraphqlDecision( · 26 unmodified lines
63 return { decision: worstDecision, effectivePaths: fieldPaths };66 return { decision: worstDecision, effectivePaths: fieldPaths };
64}67}
65 68
66function unknownToolErrorMessage(toolPath: string, availableTools: Map<string, ToolDefinition>): string {69async function resolveRegistryBuildId(
67 const suggestions = suggestToolPaths(toolPath, availableTools);70 ctx: ActionCtx,
68 const queryHint = toolPath71 workspaceId: TaskRecord["workspaceId"],
69 .split(".")72): Promise<string> {
70 .filter(Boolean)73 const [state, sources] = await Promise.all([
71 .join(" ");74 ctx.runQuery(internal.toolRegistry.getState, { workspaceId }) as Promise<null | { signature: string; readyBuildId?: string }>,
72 const suggestionText = suggestions.length > 075 ctx.runQuery(internal.database.listToolSources, { workspaceId }) as Promise<Array<{ id: string; updatedAt: number; enabled: boolean }>>,
73 ? `\nDid you mean: ${suggestions.map((path) => `tools.${toPreferredToolPath(path)}`).join(", ")}`76 ]);
74 : "";77
75 const discoverHint = `\nTry: const found = await tools.discover({ query: "${queryHint}", compact: false, depth: 2, limit: 12 });`;78 const enabledSources = sources.filter((source) => source.enabled);
76 return `Unknown tool: ${toolPath}${suggestionText}${discoverHint}`;79 const signature = sourceSignature(workspaceId, enabledSources);
80 const expectedSignature = `toolreg_v2|${signature}`;
81 const buildId = state?.readyBuildId;
82
83 if (!buildId || state.signature !== expectedSignature) {
84 throw new Error(
85 "Tool registry is not ready (or is stale). Open Tools to refresh, or call listToolsWithWarnings to rebuild.",
86 );
87 }
88
89 return buildId;
77}90}
78 91
79export async function ensureWorkspaceTools(92async function suggestFromRegistry(
80 ctx: ActionCtx,93 ctx: ActionCtx,
81 task: TaskRecord,94 workspaceId: TaskRecord["workspaceId"],
82 workspaceTools?: Map<string, ToolDefinition>,95 buildId: string,
83): Promise<Map<string, ToolDefinition>> {96 toolPath: string,
84 if (workspaceTools) {97): Promise<string[]> {
85 return workspaceTools;98 const term = toolPath.split(".").filter(Boolean).join(" ");
86 }99 const hits = await ctx.runQuery(internal.toolRegistry.searchTools, {
87 const result = await getWorkspaceTools(ctx, task.workspaceId, {100 workspaceId,
88 actorId: task.actorId,101 buildId,
89 });102 query: term,
90 return result.tools;103 limit: 3,
104 }) as Array<{ preferredPath: string }>;
105 return hits.map((hit) => hit.preferredPath);
106}
107
108function unknownToolErrorMessage(toolPath: string, suggestions: string[]): string {
109 const suggestionText = suggestions.length > 0
110 ? `\nDid you mean: ${suggestions.map((path) => `tools.${path}`).join(", ")}`
111 : "";
112 const queryHint = toolPath.split(".").filter(Boolean).join(" ");
113 const discoverHint = `\nTry: const found = await tools.discover({ query: "${queryHint}", compact: true, depth: 1, limit: 12 });`;
114 return `Unknown tool: ${toolPath}${suggestionText}${discoverHint}`;
91}115}
92 116
93export async function resolveToolForCall(117export async function resolveToolForCall(
@@ -97,41 +121,48 @@ export async function resolveToolForCall( · 3 unmodified lines
97): Promise<{121): Promise<{
98 tool: ToolDefinition;122 tool: ToolDefinition;
99 resolvedToolPath: string;123 resolvedToolPath: string;
100 workspaceTools?: Map<string, ToolDefinition>;
101}> {124}> {
102 let workspaceTools: Map<string, ToolDefinition> | undefined;125 const builtin = baseTools.get(toolPath);
126 if (builtin) {
127 return { tool: builtin, resolvedToolPath: toolPath };
128 }
129
130 const buildId = await resolveRegistryBuildId(ctx, task.workspaceId);
131
103 let resolvedToolPath = toolPath;132 let resolvedToolPath = toolPath;
104 let tool = baseTools.get(toolPath);133 let entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, {
105 if (!tool) {134 workspaceId: task.workspaceId,
106 workspaceTools = await ensureWorkspaceTools(ctx, task, workspaceTools);135 buildId,
107 tool = workspaceTools.get(toolPath);136 path: toolPath,
108 137 }) as null | { path: string; serializedToolJson: string };
109 if (!tool) {138
110 const aliasedPath = resolveAliasedToolPath(toolPath, workspaceTools);139 if (!entry) {
111 if (aliasedPath) {140 const normalized = normalizeToolPathForLookup(toolPath);
112 resolvedToolPath = aliasedPath;141 const hits = await ctx.runQuery(internal.toolRegistry.getToolsByNormalizedPath, {
113 tool = workspaceTools.get(aliasedPath);142 workspaceId: task.workspaceId,
114 }143 buildId,
144 normalizedPath: normalized,
145 limit: 5,
146 }) as Array<{ path: string; serializedToolJson: string }>;
147
148 if (hits.length > 0) {
149 // Prefer exact match on preferred path formatting, otherwise shortest canonical path.
150 const exact = hits.find((hit) => toPreferredToolPath(hit.path) === toPreferredToolPath(toolPath));
151 entry = exact ?? hits.sort((a, b) => a.path.length - b.path.length || a.path.localeCompare(b.path))[0]!;
152 resolvedToolPath = entry.path;
115 }153 }
116 }154 }
117 155
118 if (!tool) {156 if (!entry) {
119 const availableTools = workspaceTools ?? baseTools;157 const suggestions = await suggestFromRegistry(ctx, task.workspaceId, buildId, toolPath);
120 const healedPath = resolveClosestToolPath(toolPath, availableTools);158 throw new Error(unknownToolErrorMessage(toolPath, suggestions));
121 if (healedPath) {
122 resolvedToolPath = healedPath;
123 tool = availableTools.get(healedPath);
124 }
125 }159 }
126 160
161 const serialized = JSON.parse(entry.serializedToolJson) as SerializedTool;
162 const [tool] = rehydrateTools([serialized], baseTools);
127 if (!tool) {163 if (!tool) {
128 const availableTools = workspaceTools ?? baseTools;164 throw new Error(`Failed to rehydrate tool: ${resolvedToolPath}`);
129 throw new Error(unknownToolErrorMessage(toolPath, availableTools));
130 }165 }
131 166
132 return {167 return { tool, resolvedToolPath };
133 tool,
134 resolvedToolPath,
135 workspaceTools,
136 };
137}168}
executor/packages/convex/runtime/tool_descriptors.test.ts+26−10
@@ -2,17 +2,32 @@ import { expect, test } from "bun:test"; · 1 unmodified line
2import type { ToolDefinition } from "../../core/src/types";2import type { ToolDefinition } from "../../core/src/types";
3import { listVisibleToolDescriptors } from "./tool_descriptors";3import { listVisibleToolDescriptors } from "./tool_descriptors";
4 4
5test("listVisibleToolDescriptors formats type hints for client responses", () => {5test("listVisibleToolDescriptors derives display hints from schemas", () => {
6 const tool: ToolDefinition = {6 const tool: ToolDefinition = {
7 path: "github.actions.add_custom_labels_to_self_hosted_runner_for_org",7 path: "github.actions.add_custom_labels_to_self_hosted_runner_for_org",
8 description: "Add custom labels",8 description: "Add custom labels",
9 approval: "required",9 approval: "required",
10 source: "openapi:github",10 source: "openapi:github",
11 metadata: {11 typing: {
12 argsType: '{org:string;runner_id:number;labels:string[]}',12 inputSchema: {
13 returnsType: '{total_count:number;labels:components["schemas"]["runner-label"][]}',13 type: "object",
14 displayArgsType: '{ org: string; runner_id: number; labels: string[] }',14 properties: {
15 displayReturnsType: '{ total_count: number; labels: components["schemas"]["runner-label"][] }',15 org: { type: "string" },
16 runner_id: { type: "number" },
17 labels: { type: "array", items: { type: "string" } },
18 },
19 required: ["org", "runner_id", "labels"],
20 additionalProperties: false,
21 },
22 outputSchema: {
23 type: "object",
24 properties: {
25 total_count: { type: "number" },
26 labels: { type: "array", items: { type: "string" } },
27 },
28 required: ["total_count", "labels"],
29 additionalProperties: true,
30 },
16 },31 },
17 run: async () => ({ total_count: 0, labels: [] }),32 run: async () => ({ total_count: 0, labels: [] }),
18 };33 };
@@ -27,8 +42,9 @@ test("listVisibleToolDescriptors formats type hints for client responses", () => · 8 unmodified lines
27 42
28 expect(descriptors).toHaveLength(1);43 expect(descriptors).toHaveLength(1);
29 const descriptor = descriptors[0]!;44 const descriptor = descriptors[0]!;
30 expect(descriptor.argsType).toContain("org: string");45 expect(descriptor.display?.input).toContain("org");
31 expect(descriptor.strictArgsType).toContain("runner_id: number");46 expect(descriptor.display?.input).toContain("runner_id");
32 expect(descriptor.returnsType).toContain('components["schemas"]["runner-label"][]');47 expect(descriptor.display?.output).toContain("total_count");
33 expect(descriptor.strictReturnsType).toContain("total_count: number");48 expect(descriptor.typing?.requiredInputKeys).toEqual(expect.arrayContaining(["org", "runner_id", "labels"]));
49 expect(descriptor.typing?.previewInputKeys).toEqual(expect.arrayContaining(["org", "runner_id"]));
34});50});
executor/packages/convex/runtime/tool_descriptors.ts+30−22
@@ -6,7 +6,8 @@ import type { · 5 unmodified lines
6 ToolDefinition,6 ToolDefinition,
7 ToolDescriptor,7 ToolDescriptor,
8} from "../../core/src/types";8} from "../../core/src/types";
9import { formatTypeExpressionForClient } from "../../core/src/type-format";9import { jsonSchemaTypeHintFallback } from "../../core/src/openapi/schema-hints";
10import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../../core/src/tool-typing/schema-utils";
10import { getDecisionForContext } from "./policy";11import { getDecisionForContext } from "./policy";
11 12
12function toToolDescriptor(13function toToolDescriptor(
@@ -23,12 +24,23 @@ function toToolDescriptor( · 10 unmodified lines
23 source: tool.source,24 source: tool.source,
24 ...(includeDetails25 ...(includeDetails
25 ? {26 ? {
26 argsType: formatTypeExpressionForClient(tool.metadata?.displayArgsType ?? tool.metadata?.argsType),27 typing: tool.typing
27 returnsType: formatTypeExpressionForClient(tool.metadata?.displayReturnsType ?? tool.metadata?.returnsType),28 ? {
28 strictArgsType: formatTypeExpressionForClient(tool.metadata?.argsType),29 requiredInputKeys: tool.typing.requiredInputKeys
29 strictReturnsType: formatTypeExpressionForClient(tool.metadata?.returnsType),30 ?? extractTopLevelRequiredKeys(tool.typing.inputSchema),
30 argPreviewKeys: tool.metadata?.argPreviewKeys,31 previewInputKeys: tool.typing.previewInputKeys
31 operationId: tool.metadata?.operationId,32 ?? buildPreviewKeys(tool.typing.inputSchema),
33 typedRef: tool.typing.typedRef,
34 }
35 : undefined,
36 display: {
37 input: tool.typing?.inputSchema
38 ? (Object.keys(tool.typing.inputSchema).length === 0 ? "{}" : jsonSchemaTypeHintFallback(tool.typing.inputSchema))
39 : "{}",
40 output: tool.typing?.outputSchema
41 ? (Object.keys(tool.typing.outputSchema).length === 0 ? "unknown" : jsonSchemaTypeHintFallback(tool.typing.outputSchema))
42 : "unknown",
43 },
32 }44 }
33 : {}),45 : {}),
34 };46 };
@@ -58,21 +70,17 @@ export function computeOpenApiSourceQuality( · 23 unmodified lines
58 let partialUnknownReturnsCount = 0;70 let partialUnknownReturnsCount = 0;
59 71
60 for (const tool of tools) {72 for (const tool of tools) {
61 const argsType = tool.metadata?.argsType?.trim() ?? "";73 const inputSchema = tool.typing?.inputSchema ?? {};
62 const returnsType = tool.metadata?.returnsType?.trim() ?? "";74 const outputSchema = tool.typing?.outputSchema ?? {};
63 75 const hasInput = Object.keys(inputSchema).length > 0;
64 if (!argsType || argsType === "Record<string, unknown>") {76 const hasOutput = Object.keys(outputSchema).length > 0;
65 unknownArgsCount += 1;77 if (!hasInput) unknownArgsCount += 1;
66 }78 if (!hasOutput) unknownReturnsCount += 1;
67 if (!returnsType || returnsType === "unknown") {79 // Best-effort: count schema nodes that still include unknown-ish placeholders.
68 unknownReturnsCount += 1;80 const inputHint = hasInput ? jsonSchemaTypeHintFallback(inputSchema) : "unknown";
69 }81 const outputHint = hasOutput ? jsonSchemaTypeHintFallback(outputSchema) : "unknown";
70 if (argsType.includes("unknown")) {82 if (inputHint.includes("unknown")) partialUnknownArgsCount += 1;
71 partialUnknownArgsCount += 1;83 if (outputHint.includes("unknown")) partialUnknownReturnsCount += 1;
72 }
73 if (returnsType.includes("unknown")) {
74 partialUnknownReturnsCount += 1;
75 }
76 }84 }
77 85
78 const argsQuality = toolCount > 0 ? (toolCount - unknownArgsCount) / toolCount : 1;86 const argsQuality = toolCount > 0 ? (toolCount - unknownArgsCount) / toolCount : 1;
executor/packages/convex/runtime/tool_invocation.ts+128−6
@@ -8,18 +8,20 @@ import type { · 7 unmodified lines
8 PolicyDecision,8 PolicyDecision,
9 ResolvedToolCredential,9 ResolvedToolCredential,
10 TaskRecord,10 TaskRecord,
11 ToolDefinition,
11 ToolCallRecord,12 ToolCallRecord,
12 ToolCallRequest,13 ToolCallRequest,
13 ToolRunContext,14 ToolRunContext,
14} from "../../core/src/types";15} from "../../core/src/types";
15import { describeError } from "../../core/src/utils";16import { describeError } from "../../core/src/utils";
16import { asPayload } from "../lib/object";17import { asPayload } from "../lib/object";
17import { getToolDecision, isToolAllowedForTask } from "./policy";18import { getToolDecision, getDecisionForContext } from "./policy";
18import { baseTools } from "./workspace_tools";19import { baseTools } from "./workspace_tools";
19import { publishTaskEvent } from "./events";20import { publishTaskEvent } from "./events";
20import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";21import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";
21import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials";22import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials";
22import { ensureWorkspaceTools, getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";23import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";
24import { sourceSignature } from "./tool_source_loading";
23 25
24function createApprovalId(): string {26function createApprovalId(): string {
25 return `approval_${crypto.randomUUID()}`;27 return `approval_${crypto.randomUUID()}`;
@@ -57,13 +59,132 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 31 unmodified lines
57 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });59 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });
58 const typedPolicies = policies as AccessPolicyRecord[];60 const typedPolicies = policies as AccessPolicyRecord[];
59 61
60 let { tool, resolvedToolPath, workspaceTools } = await resolveToolForCall(ctx, task, toolPath);62 // Fast system tools are handled server-side from the registry.
63 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {
64 const toolRegistry = internal.toolRegistry;
65 const [state, sources] = await Promise.all([
66 ctx.runQuery(toolRegistry.getState, { workspaceId: task.workspaceId }) as Promise<null | { signature: string; readyBuildId?: string }>,
67 ctx.runQuery(internal.database.listToolSources, { workspaceId: task.workspaceId }) as Promise<Array<{ id: string; updatedAt: number; enabled: boolean }>>,
68 ]);
69 const enabledSources = sources.filter((source) => source.enabled);
70 const signature = sourceSignature(task.workspaceId, enabledSources);
71 const expectedSignature = `toolreg_v2|${signature}`;
72 const buildId = state?.readyBuildId;
73 if (!buildId || state.signature !== expectedSignature) {
74 throw new Error(
75 "Tool registry is not ready (or is stale). Open Tools to refresh, or call listToolsWithWarnings to rebuild.",
76 );
77 }
78
79 const payload = typeof input === "string"
80 ? { query: input }
81 : (input && typeof input === "object" ? (input as Record<string, unknown>) : {});
82 const isAllowed = (path: string, approval: ToolDefinition["approval"]) =>
83 getDecisionForContext(
84 { path, approval, description: "", run: async () => null } as ToolDefinition,
85 { workspaceId: task.workspaceId, actorId: task.actorId, clientId: task.clientId },
86 typedPolicies,
87 ) !== "deny";
88
89 const normalizeHint = (value: unknown, fallback: string) => {
90 const str = typeof value === "string" ? value.trim() : "";
91 return str.length > 0 ? str : fallback;
92 };
93
94 if (toolPath === "catalog.namespaces") {
95 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 200)));
96 const namespaces = await ctx.runQuery(toolRegistry.listNamespaces, {
97 workspaceId: task.workspaceId,
98 buildId,
99 limit,
100 }) as Array<{ namespace: string; toolCount: number; samplePaths: string[] }>;
101 return { namespaces, total: namespaces.length };
102 }
103
104 if (toolPath === "catalog.tools") {
105 const namespace = String(payload.namespace ?? "").trim().toLowerCase();
106 const query = String(payload.query ?? "").trim();
107 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));
108
109 const raw = query
110 ? await ctx.runQuery(toolRegistry.searchTools, {
111 workspaceId: task.workspaceId,
112 buildId,
113 query,
114 limit,
115 })
116 : namespace
117 ? await ctx.runQuery(toolRegistry.listToolsByNamespace, {
118 workspaceId: task.workspaceId,
119 buildId,
120 namespace,
121 limit,
122 })
123 : [];
124
125 const results = (raw as Array<any>)
126 .filter((entry) => !namespace || String(entry.preferredPath ?? entry.path ?? "").toLowerCase().startsWith(`${namespace}.`))
127 .filter((entry) => isAllowed(entry.path, entry.approval))
128 .slice(0, limit)
129 .map((entry) => {
130 const preferredPath = entry.preferredPath ?? entry.path;
131 return {
132 path: preferredPath,
133 source: entry.source,
134 approval: entry.approval,
135 description: entry.description,
136 input: normalizeHint(entry.displayInput, "{}"),
137 output: normalizeHint(entry.displayOutput, "unknown"),
138 // required keys are encoded in the `input` type hint
139 };
140 });
141
142 return { results, total: results.length };
143 }
144
145 // discover
146 const query = String(payload.query ?? "").trim();
147 const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));
148 const compact = payload.compact === false ? false : true;
149 const hits = await ctx.runQuery(toolRegistry.searchTools, {
150 workspaceId: task.workspaceId,
151 buildId,
152 query,
153 limit: Math.max(limit * 2, limit),
154 }) as Array<any>;
155
156 const filtered = hits
157 .filter((entry) => isAllowed(entry.path, entry.approval))
158 .slice(0, limit);
159
160 const results = filtered.map((entry) => {
161 const preferredPath = entry.preferredPath ?? entry.path;
162 const description = compact ? String(entry.description ?? "").split("\n")[0] : entry.description;
163 return {
164 path: preferredPath,
165 source: entry.source,
166 approval: entry.approval,
167 description,
168 input: normalizeHint(entry.displayInput, "{}"),
169 output: normalizeHint(entry.displayOutput, "unknown"),
170 // required keys are encoded in the `input` type hint
171 };
172 });
173
174 const bestPath = results[0]?.path ?? null;
175 return {
176 bestPath,
177 results,
178 total: results.length,
179 };
180 }
181
182 let { tool, resolvedToolPath } = await resolveToolForCall(ctx, task, toolPath);
61 183
62 let decision: PolicyDecision;184 let decision: PolicyDecision;
63 let effectiveToolPath = resolvedToolPath;185 let effectiveToolPath = resolvedToolPath;
64 if (tool._graphqlSource) {186 if (tool._graphqlSource) {
65 workspaceTools = await ensureWorkspaceTools(ctx, task, workspaceTools);187 const result = getGraphqlDecision(task, tool, input, undefined, typedPolicies);
66 const result = getGraphqlDecision(task, tool, input, workspaceTools, typedPolicies);
67 decision = result.decision;188 decision = result.decision;
68 if (result.effectivePaths.length > 0) {189 if (result.effectivePaths.length > 0) {
69 effectiveToolPath = result.effectivePaths.join(", ");190 effectiveToolPath = result.effectivePaths.join(", ");
@@ -179,7 +300,8 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 109 unmodified lines
179 actorId: task.actorId,300 actorId: task.actorId,
180 clientId: task.clientId,301 clientId: task.clientId,
181 credential,302 credential,
182 isToolAllowed: (path) => isToolAllowedForTask(task, path, workspaceTools ?? baseTools, typedPolicies),303 // Tool visibility is enforced server-side; runtime tool implementations don't use this.
304 isToolAllowed: (_path) => true,
183 };305 };
184 const value = await tool.run(input, context);306 const value = await tool.run(input, context);
185 await completeToolCall(ctx, {307 await completeToolCall(ctx, {
executor/packages/convex/runtime/tool_paths.ts+7−7
@@ -46,7 +46,7 @@ function normalizeToolPathSegment(segment: string, isNamespace = false): string · 45 unmodified lines
46 return collapsed.join("");46 return collapsed.join("");
47}47}
48 48
49function normalizeToolPath(path: string): string {49export function normalizeToolPathForLookup(path: string): string {
50 const segments = path50 const segments = path
51 .split(".")51 .split(".")
52 .filter(Boolean);52 .filter(Boolean);
@@ -110,12 +110,12 @@ export function resolveAliasedToolPath( · 57 unmodified lines
110): string | null {110): string | null {
111 if (toolMap.has(requestedPath)) return requestedPath;111 if (toolMap.has(requestedPath)) return requestedPath;
112 112
113 const normalizedRequested = normalizeToolPath(requestedPath);113 const normalizedRequested = normalizeToolPathForLookup(requestedPath);
114 if (!normalizedRequested) return null;114 if (!normalizedRequested) return null;
115 115
116 const matches: string[] = [];116 const matches: string[] = [];
117 for (const path of toolMap.keys()) {117 for (const path of toolMap.keys()) {
118 if (normalizeToolPath(path) === normalizedRequested) {118 if (normalizeToolPathForLookup(path) === normalizedRequested) {
119 matches.push(path);119 matches.push(path);
120 }120 }
121 }121 }
@@ -134,13 +134,13 @@ export function suggestToolPaths( · 12 unmodified lines
134 toolMap: Map<string, ToolDefinition>,134 toolMap: Map<string, ToolDefinition>,
135 limit = 3,135 limit = 3,
136): string[] {136): string[] {
137 const normalizedRequested = normalizeToolPath(requestedPath);137 const normalizedRequested = normalizeToolPathForLookup(requestedPath);
138 const requestedSegments = normalizedRequested.split(".").filter(Boolean);138 const requestedSegments = normalizedRequested.split(".").filter(Boolean);
139 const requestedNamespace = requestedSegments[0] ?? "";139 const requestedNamespace = requestedSegments[0] ?? "";
140 140
141 return [...toolMap.keys()]141 return [...toolMap.keys()]
142 .map((path) => {142 .map((path) => {
143 const normalizedCandidate = normalizeToolPath(path);143 const normalizedCandidate = normalizeToolPathForLookup(path);
144 const candidateSegments = normalizedCandidate.split(".").filter(Boolean);144 const candidateSegments = normalizedCandidate.split(".").filter(Boolean);
145 const candidateNamespace = candidateSegments[0] ?? "";145 const candidateNamespace = candidateSegments[0] ?? "";
146 146
@@ -173,7 +173,7 @@ export function resolveClosestToolPath( · 26 unmodified lines
173 requestedPath: string,173 requestedPath: string,
174 toolMap: Map<string, ToolDefinition>,174 toolMap: Map<string, ToolDefinition>,
175): string | null {175): string | null {
176 const normalizedRequested = normalizeToolPath(requestedPath);176 const normalizedRequested = normalizeToolPathForLookup(requestedPath);
177 if (!normalizedRequested) return null;177 if (!normalizedRequested) return null;
178 178
179 const requestedNamespace = normalizedRequested.split(".").filter(Boolean)[0] ?? "";179 const requestedNamespace = normalizedRequested.split(".").filter(Boolean)[0] ?? "";
@@ -182,7 +182,7 @@ export function resolveClosestToolPath( · 2 unmodified lines
182 182
183 const ranked = [...toolMap.keys()]183 const ranked = [...toolMap.keys()]
184 .map((path) => {184 .map((path) => {
185 const normalizedCandidate = normalizeToolPath(path);185 const normalizedCandidate = normalizeToolPathForLookup(path);
186 const candidateNamespace = normalizedCandidate.split(".").filter(Boolean)[0] ?? "";186 const candidateNamespace = normalizedCandidate.split(".").filter(Boolean)[0] ?? "";
187 const distance = levenshteinDistance(normalizedRequested, normalizedCandidate);187 const distance = levenshteinDistance(normalizedRequested, normalizedCandidate);
188 let score = -distance;188 let score = -distance;
executor/packages/convex/runtime/tool_source_loading.ts+4−4
@@ -24,8 +24,8 @@ import { asPayload } from "../lib/object"; · 23 unmodified lines
24 24
25const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;25const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
26 26
27/** Cache version - bump when PreparedOpenApiSpec shape changes. */27/** Cache version - bump when tool snapshot/registry/type-hint semantics change. */
28const TOOL_SOURCE_CACHE_VERSION = "v18";28const TOOL_SOURCE_CACHE_VERSION = "v24";
29 29
30export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {30export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
31 const parts = sources31 const parts = sources
@@ -279,7 +279,7 @@ export async function loadSourceArtifact( · 247 unmodified lines
279 ctx: ActionCtx,279 ctx: ActionCtx,
280 source: ExternalToolSourceConfig,280 source: ExternalToolSourceConfig,
281 options: { includeDts?: boolean; workspaceId: Id<"workspaces">; actorId?: string },281 options: { includeDts?: boolean; workspaceId: Id<"workspaces">; actorId?: string },
282): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> {282): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[]; openApiDts?: string }> {
283 const includeDts = options.includeDts ?? true;283 const includeDts = options.includeDts ?? true;
284 284
285 if (source.type === "openapi" && typeof source.spec === "string") {285 if (source.type === "openapi" && typeof source.spec === "string") {
@@ -289,7 +289,7 @@ export async function loadSourceArtifact( · 3 unmodified lines
289 const warnings = (prepared.warnings ?? []).map(289 const warnings = (prepared.warnings ?? []).map(
290 (warning) => `Source '${source.name}': ${warning}`,290 (warning) => `Source '${source.name}': ${warning}`,
291 );291 );
292 return { artifact, warnings };292 return { artifact, warnings, openApiDts: prepared.dts };
293 } catch (error) {293 } catch (error) {
294 const message = error instanceof Error ? error.message : String(error);294 const message = error instanceof Error ? error.message : String(error);
295 return {295 return {
executor/packages/convex/runtime/workspace_tools.ts+396−225
@@ -3,13 +3,16 @@ · 2 unmodified lines
3import type { ActionCtx } from "../_generated/server";3import type { ActionCtx } from "../_generated/server";
4import { internal } from "../_generated/api";4import { internal } from "../_generated/api";
5import type { Id } from "../_generated/dataModel.d.ts";5import type { Id } from "../_generated/dataModel.d.ts";
6import { createCatalogTools, createDiscoverTool } from "../../core/src/tool-discovery";6import { buildWorkspaceTypeBundle } from "../../core/src/tool-typing/typebundle";
7import { jsonSchemaTypeHintFallback } from "../../core/src/openapi/schema-hints";
8import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../../core/src/tool-typing/schema-utils";
7import {9import {
8 materializeCompiledToolSource,10 materializeCompiledToolSource,
9 materializeWorkspaceSnapshot,11 materializeWorkspaceSnapshot,
10 type CompiledToolSourceArtifact,12 type CompiledToolSourceArtifact,
11 type WorkspaceToolSnapshot,13 type WorkspaceToolSnapshot,
12} from "../../core/src/tool-sources";14} from "../../core/src/tool-sources";
15import type { SerializedTool } from "../../core/src/tool/source-serialization";
13import type { ExternalToolSourceConfig } from "../../core/src/tool/source-types";16import type { ExternalToolSourceConfig } from "../../core/src/tool/source-types";
14import type {17import type {
15 AccessPolicyRecord,18 AccessPolicyRecord,
@@ -18,24 +21,165 @@ import type { · 2 unmodified lines
18 ToolDefinition,21 ToolDefinition,
19 ToolDescriptor,22 ToolDescriptor,
20} from "../../core/src/types";23} from "../../core/src/types";
21import {
22 extractSchemaRefKeys,
23 extractSourceSchemaTypesFromDts,
24} from "../../core/src/tool-discovery/schema-registry";
25import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors";24import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors";
26import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";25import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";
26import { normalizeToolPathForLookup } from "./tool_paths";
27 27
28const baseTools = new Map<string, ToolDefinition>();28const baseTools = new Map<string, ToolDefinition>();
29 29
30interface DtsStorageEntry {30// Minimal built-in tools used by tests/demos.
31 sourceKey: string;31// These are intentionally simple and are always approval-gated.
32 storageId: Id<"_storage">;32baseTools.set("admin.send_announcement", {
33}33 path: "admin.send_announcement",
34 source: "system",
35 approval: "required",
36 description: "Send an announcement message (demo tool; approval-gated).",
37 typing: {
38 inputSchema: {
39 type: "object",
40 properties: {
41 channel: { type: "string" },
42 message: { type: "string" },
43 },
44 required: ["channel", "message"],
45 additionalProperties: false,
46 },
47 outputSchema: {
48 type: "object",
49 properties: {
50 ok: { type: "boolean" },
51 channel: { type: "string" },
52 message: { type: "string" },
53 },
54 required: ["ok", "channel", "message"],
55 additionalProperties: false,
56 },
57 },
58 run: async (input: unknown) => {
59 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
60 const channel = typeof payload.channel === "string" ? payload.channel : "";
61 const message = typeof payload.message === "string" ? payload.message : "";
62 return { ok: true, channel, message };
63 },
64});
65
66baseTools.set("admin.delete_data", {
67 path: "admin.delete_data",
68 source: "system",
69 approval: "required",
70 description: "Delete data (demo tool; approval-gated).",
71 typing: {
72 inputSchema: {
73 type: "object",
74 properties: {
75 key: { type: "string" },
76 id: { type: "string" },
77 },
78 additionalProperties: false,
79 },
80 outputSchema: {
81 type: "object",
82 properties: {
83 ok: { type: "boolean" },
84 },
85 required: ["ok"],
86 additionalProperties: false,
87 },
88 },
89 run: async () => {
90 return { ok: true };
91 },
92});
93
94// System tools (discover/catalog) are resolved server-side.
95// Their execution is handled in the Convex tool invocation pipeline.
96baseTools.set("discover", {
97 path: "discover",
98 source: "system",
99 approval: "auto",
100 description:
101 "Search available tools by keyword. Returns preferred path aliases, signature hints, and ready-to-copy call examples. Compact mode is enabled by default.",
102 typing: {
103 inputSchema: {
104 type: "object",
105 properties: {
106 query: { type: "string" },
107 depth: { type: "number" },
108 limit: { type: "number" },
109 compact: { type: "boolean" },
110 },
111 required: ["query"],
112 },
113 outputSchema: {
114 type: "object",
115 properties: {
116 bestPath: {},
117 results: { type: "array" },
118 total: { type: "number" },
119 },
120 required: ["bestPath", "results", "total"],
121 },
122 },
123 run: async () => {
124 throw new Error("discover is handled by the server tool invocation pipeline");
125 },
126});
127
128baseTools.set("catalog.namespaces", {
129 path: "catalog.namespaces",
130 source: "system",
131 approval: "auto",
132 description: "List available tool namespaces with counts and sample callable paths.",
133 typing: {
134 inputSchema: { type: "object", properties: {} },
135 outputSchema: {
136 type: "object",
137 properties: {
138 namespaces: { type: "array" },
139 total: { type: "number" },
140 },
141 required: ["namespaces", "total"],
142 },
143 },
144 run: async () => {
145 throw new Error("catalog.namespaces is handled by the server tool invocation pipeline");
146 },
147});
148
149baseTools.set("catalog.tools", {
150 path: "catalog.tools",
151 source: "system",
152 approval: "auto",
153 description: "List tools with typed signatures. Supports namespace and query filters in one call.",
154 typing: {
155 inputSchema: {
156 type: "object",
157 properties: {
158 namespace: { type: "string" },
159 query: { type: "string" },
160 depth: { type: "number" },
161 limit: { type: "number" },
162 compact: { type: "boolean" },
163 },
164 },
165 outputSchema: {
166 type: "object",
167 properties: {
168 results: { type: "array" },
169 total: { type: "number" },
170 },
171 required: ["results", "total"],
172 },
173 },
174 run: async () => {
175 throw new Error("catalog.tools is handled by the server tool invocation pipeline");
176 },
177});
34 178
35interface WorkspaceToolsResult {179interface WorkspaceToolsResult {
36 tools: Map<string, ToolDefinition>;180 tools: Map<string, ToolDefinition>;
37 warnings: string[];181 warnings: string[];
38 dtsStorageIds: DtsStorageEntry[];182 typesStorageId?: Id<"_storage">;
39 debug: WorkspaceToolsDebug;183 debug: WorkspaceToolsDebug;
40}184}
41 185
@@ -54,7 +198,6 @@ export interface WorkspaceToolsDebug { · 12 unmodified lines
54}198}
55 199
56interface GetWorkspaceToolsOptions {200interface GetWorkspaceToolsOptions {
57 includeDts?: boolean;
58 sourceTimeoutMs?: number;201 sourceTimeoutMs?: number;
59 allowStaleOnMismatch?: boolean;202 allowStaleOnMismatch?: boolean;
60 skipCacheRead?: boolean;203 skipCacheRead?: boolean;
@@ -64,10 +207,9 @@ interface GetWorkspaceToolsOptions { · 3 unmodified lines
64interface WorkspaceToolInventory {207interface WorkspaceToolInventory {
65 tools: ToolDescriptor[];208 tools: ToolDescriptor[];
66 warnings: string[];209 warnings: string[];
67 dtsStorageIds: DtsStorageEntry[];210 typesUrl?: string;
68 sourceQuality: Record<string, OpenApiSourceQuality>;211 sourceQuality: Record<string, OpenApiSourceQuality>;
69 sourceAuthProfiles: Record<string, SourceAuthProfile>;212 sourceAuthProfiles: Record<string, SourceAuthProfile>;
70 sourceSchemas: Record<string, Record<string, string>>;
71 debug: WorkspaceToolsDebug;213 debug: WorkspaceToolsDebug;
72}214}
73 215
@@ -122,125 +264,228 @@ function computeSourceAuthProfiles(tools: Map<string, ToolDefinition>): Record<s · 48 unmodified lines
122 return profiles;264 return profiles;
123}265}
124 266
125function formatSchemaRefToken(schemaKey: string): string {267function mergeTools(externalTools: Iterable<ToolDefinition>): Map<string, ToolDefinition> {
126 return `components["schemas"][${JSON.stringify(schemaKey)}]`;268 const merged = new Map<string, ToolDefinition>();
127}
128 269
129function collectSchemaKeysBySource(tools: ToolDescriptor[]): Map<string, Set<string>> {270 for (const tool of baseTools.values()) {
130 const refsBySource = new Map<string, Set<string>>();271 merged.set(tool.path, tool);
131 272 }
132 for (const tool of tools) {
133 if (!tool.source) continue;
134 const candidateTypes = [
135 tool.strictArgsType,
136 tool.strictReturnsType,
137 tool.argsType,
138 tool.returnsType,
139 ];
140
141 for (const typeExpression of candidateTypes) {
142 if (!typeExpression) continue;
143 const keys = extractSchemaRefKeys(typeExpression);
144 if (keys.length === 0) continue;
145
146 let sourceRefs = refsBySource.get(tool.source);
147 if (!sourceRefs) {
148 sourceRefs = new Set<string>();
149 refsBySource.set(tool.source, sourceRefs);
150 }
151 273
152 for (const key of keys) {274 for (const tool of externalTools) {
153 sourceRefs.add(key);275 merged.set(tool.path, tool);
154 }
155 }
156 }276 }
277 return merged;
278}
157 279
158 return refsBySource;280function tokenizePathSegment(value: string): string[] {
281 const normalized = value
282 .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
283 .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
284 .toLowerCase();
285
286 return normalized
287 .split(/[^a-z0-9]+/)
288 .map((token) => token.trim())
289 .filter((token) => token.length > 0);
159}290}
160 291
161async function loadSourceSchemasForTools(292const GENERIC_NAMESPACE_SUFFIXES = new Set([
162 ctx: ActionCtx,293 "api",
163 tools: ToolDescriptor[],294 "apis",
164 dtsStorageIds: DtsStorageEntry[],295 "openapi",
165): Promise<Record<string, Record<string, string>>> {296 "sdk",
166 const refsBySource = collectSchemaKeysBySource(tools);297 "service",
167 if (refsBySource.size === 0 || dtsStorageIds.length === 0) {298 "services",
168 return {};299]);
300
301function simplifyNamespaceSegment(segment: string): string {
302 const tokens = tokenizePathSegment(segment);
303 if (tokens.length === 0) return segment;
304
305 const collapsed: string[] = [];
306 for (const token of tokens) {
307 if (collapsed[collapsed.length - 1] === token) continue;
308 collapsed.push(token);
169 }309 }
170 310
171 const storageBySource = new Map<string, Id<"_storage">>();311 while (collapsed.length > 1) {
172 for (const entry of dtsStorageIds) {312 const last = collapsed[collapsed.length - 1];
173 storageBySource.set(entry.sourceKey, entry.storageId);313 if (!last || !GENERIC_NAMESPACE_SUFFIXES.has(last)) break;
314 collapsed.pop();
174 }315 }
175 316
176 const entries = await Promise.all([...refsBySource.entries()].map(async ([source, keys]) => {317 return collapsed.join("_");
177 const storageId = storageBySource.get(source);318}
178 if (!storageId) {
179 return null;
180 }
181 319
182 try {320function preferredToolPath(path: string): string {
183 const blob = await ctx.storage.get(storageId);321 const segments = path.split(".").filter(Boolean);
184 if (!blob) {322 if (segments.length === 0) return path;
185 return null;
186 }
187 323
188 const dtsText = await blob.text();324 const simplifiedNamespace = simplifyNamespaceSegment(segments[0]!);
189 const schemaTypes = extractSourceSchemaTypesFromDts(dtsText);325 if (!simplifiedNamespace || simplifiedNamespace === segments[0]) {
190 if (Object.keys(schemaTypes).length === 0) {326 return path;
191 return null;327 }
192 }
193 328
194 const sourceSchemas: Record<string, string> = {};329 return [simplifiedNamespace, ...segments.slice(1)].join(".");
195 for (const key of keys) {330}
196 const schemaType = schemaTypes[key];
197 if (!schemaType) continue;
198 sourceSchemas[formatSchemaRefToken(key)] = schemaType;
199 }
200 331
201 if (Object.keys(sourceSchemas).length === 0) {332function toCamelSegment(segment: string): string {
202 return null;333 return segment.replace(/_+([a-z0-9])/g, (_m, char: string) => char.toUpperCase());
203 }334}
204 335
205 return [source, sourceSchemas] as const;336function getPathAliases(path: string): string[] {
206 } catch {337 const segments = path.split(".").filter(Boolean);
207 return null;338 if (segments.length === 0) return [];
208 }
209 }));
210 339
211 const sourceSchemas: Record<string, Record<string, string>> = {};340 const canonicalPath = path;
212 for (const entry of entries) {341 const publicPath = preferredToolPath(path);
213 if (!entry) continue;
214 const [source, schemas] = entry;
215 sourceSchemas[source] = schemas;
216 }
217 342
218 return sourceSchemas;343 const aliases = new Set<string>();
344 const publicSegments = publicPath.split(".").filter(Boolean);
345 const camelPath = publicSegments.map(toCamelSegment).join(".");
346 const compactPath = publicSegments.map((segment) => segment.replace(/[_-]/g, "")).join(".");
347 const lowerPath = publicPath.toLowerCase();
348
349 if (publicPath !== canonicalPath) aliases.add(publicPath);
350 if (camelPath !== publicPath) aliases.add(camelPath);
351 if (compactPath !== publicPath) aliases.add(compactPath);
352 if (lowerPath !== publicPath) aliases.add(lowerPath);
353
354 return [...aliases].slice(0, 4);
219}355}
220 356
221function mergeToolsWithCatalog(externalTools: Iterable<ToolDefinition>): Map<string, ToolDefinition> {
222 const merged = new Map<string, ToolDefinition>();
223 357
224 for (const tool of baseTools.values()) {358function normalizeHint(type?: string): string {
225 if (tool.path === "discover") continue;359 return type && type.trim().length > 0 ? type : "unknown";
226 merged.set(tool.path, tool);360}
361
362async function buildWorkspaceToolRegistry(
363 ctx: ActionCtx,
364 args: {
365 workspaceId: Id<"workspaces">;
366 registrySignature: string;
367 serializedTools: SerializedTool[];
368 },
369): Promise<{ buildId: string }> {
370 const buildId = `toolreg_${crypto.randomUUID()}`;
371 await ctx.runMutation(internal.toolRegistry.beginBuild, {
372 workspaceId: args.workspaceId,
373 signature: args.registrySignature,
374 buildId,
375 });
376
377 const entries = args.serializedTools.map((st) => {
378 if (st.path === "discover" || st.path.startsWith("catalog.")) {
379 return null;
380 }
381 const preferredPath = preferredToolPath(st.path);
382 const aliases = getPathAliases(st.path);
383 const namespace = (preferredPath.split(".")[0] ?? "default").toLowerCase();
384 const normalizedPath = normalizeToolPathForLookup(st.path);
385 const searchText = `${st.path} ${preferredPath} ${aliases.join(" ")} ${st.description} ${st.source ?? ""}`.toLowerCase();
386
387 const inputSchema = (st.typing?.inputSchema && typeof st.typing.inputSchema === "object")
388 ? st.typing.inputSchema
389 : {};
390 const outputSchema = (st.typing?.outputSchema && typeof st.typing.outputSchema === "object")
391 ? st.typing.outputSchema
392 : {};
393
394 const requiredInputKeys = Array.isArray(st.typing?.requiredInputKeys)
395 ? st.typing!.requiredInputKeys!.filter((v): v is string => typeof v === "string")
396 : extractTopLevelRequiredKeys(inputSchema as any);
397 const previewInputKeys = Array.isArray(st.typing?.previewInputKeys)
398 ? st.typing!.previewInputKeys!.filter((v): v is string => typeof v === "string")
399 : buildPreviewKeys(inputSchema as any);
400
401 const displayInput = typeof st.typing?.inputHint === "string" && st.typing.inputHint.trim().length > 0
402 ? st.typing.inputHint.trim()
403 : (Object.keys(inputSchema as any).length === 0
404 ? "{}"
405 : normalizeHint(jsonSchemaTypeHintFallback(inputSchema)));
406
407 const displayOutput = typeof st.typing?.outputHint === "string" && st.typing.outputHint.trim().length > 0
408 ? st.typing.outputHint.trim()
409 : (Object.keys(outputSchema as any).length === 0
410 ? "unknown"
411 : normalizeHint(jsonSchemaTypeHintFallback(outputSchema)));
412
413 const typedRef = st.typing?.typedRef && st.typing.typedRef.kind === "openapi_operation"
414 ? {
415 kind: "openapi_operation" as const,
416 sourceKey: st.typing.typedRef.sourceKey,
417 operationId: st.typing.typedRef.operationId,
418 }
419 : undefined;
420
421 return {
422 path: st.path,
423 preferredPath,
424 namespace,
425 normalizedPath,
426 aliases,
427 description: st.description,
428 approval: st.approval,
429 source: st.source,
430 searchText,
431 displayInput,
432 displayOutput,
433 requiredInputKeys,
434 previewInputKeys,
435 typedRef,
436 serializedToolJson: JSON.stringify(st),
437 };
438 });
439
440 const filteredEntries = entries.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
441
442 const namespaceMap = new Map<string, { toolCount: number; samplePaths: string[] }>();
443 for (const entry of filteredEntries) {
444 const current = namespaceMap.get(entry.namespace) ?? { toolCount: 0, samplePaths: [] };
445 current.toolCount += 1;
446 if (current.samplePaths.length < 6) {
447 current.samplePaths.push(entry.preferredPath);
448 }
449 namespaceMap.set(entry.namespace, current);
227 }450 }
228 451
229 for (const tool of externalTools) {452 const namespaces = [...namespaceMap.entries()]
230 if (tool.path === "discover") continue;453 .map(([namespace, meta]) => ({
231 merged.set(tool.path, tool);454 namespace,
455 toolCount: meta.toolCount,
456 samplePaths: [...meta.samplePaths].sort((a, b) => a.localeCompare(b)).slice(0, 3),
457 }))
458 .sort((a, b) => a.namespace.localeCompare(b.namespace));
459
460 const TOOL_BATCH = 100;
461 for (let i = 0; i < filteredEntries.length; i += TOOL_BATCH) {
462 await ctx.runMutation(internal.toolRegistry.putToolsBatch, {
463 workspaceId: args.workspaceId,
464 buildId,
465 tools: filteredEntries.slice(i, i + TOOL_BATCH),
466 });
232 }467 }
233 468
234 const catalogTools = createCatalogTools([...merged.values()]);469 const NS_BATCH = 100;
235 for (const tool of catalogTools) {470 for (let i = 0; i < namespaces.length; i += NS_BATCH) {
236 merged.set(tool.path, tool);471 await ctx.runMutation(internal.toolRegistry.putNamespacesBatch, {
472 workspaceId: args.workspaceId,
473 buildId,
474 namespaces: namespaces.slice(i, i + NS_BATCH),
475 });
237 }476 }
238 477
239 const discover = createDiscoverTool([...merged.values()]);478 await ctx.runMutation(internal.toolRegistry.finishBuild, {
240 merged.set(discover.path, discover);479 workspaceId: args.workspaceId,
241 return merged;480 buildId,
481 });
482
483 return { buildId };
242}484}
243 485
486// No implicit "ensure"/backfill on reads: the registry is built alongside the
487// workspace tool snapshot during rebuilds.
488
244export async function getWorkspaceTools(489export async function getWorkspaceTools(
245 ctx: ActionCtx,490 ctx: ActionCtx,
246 workspaceId: Id<"workspaces">,491 workspaceId: Id<"workspaces">,
@@ -253,7 +498,7 @@ export async function getWorkspaceTools( · 6 unmodified lines
253 };498 };
254 499
255 const listSourcesStartedAt = Date.now();500 const listSourcesStartedAt = Date.now();
256 const includeDts = options.includeDts ?? false;501 const includeDts = true;
257 const sourceTimeoutMs = options.sourceTimeoutMs;502 const sourceTimeoutMs = options.sourceTimeoutMs;
258 const allowStaleOnMismatch = options.allowStaleOnMismatch ?? false;503 const allowStaleOnMismatch = options.allowStaleOnMismatch ?? false;
259 const actorId = options.actorId;504 const actorId = options.actorId;
@@ -271,6 +516,7 @@ export async function getWorkspaceTools( · 11 unmodified lines
271 traceStep("listToolSources", listSourcesStartedAt);516 traceStep("listToolSources", listSourcesStartedAt);
272 const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi");517 const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi");
273 const signature = sourceSignature(workspaceId, sources);518 const signature = sourceSignature(workspaceId, sources);
519 const registrySignature = `toolreg_v2|${signature}`;
274 const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = {520 const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = {
275 includeDts,521 includeDts,
276 sourceTimeoutMs: sourceTimeoutMs ?? null,522 sourceTimeoutMs: sourceTimeoutMs ?? null,
@@ -293,19 +539,16 @@ export async function getWorkspaceTools( · 16 unmodified lines
293 if (blob) {539 if (blob) {
294 const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot;540 const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot;
295 const restored = materializeWorkspaceSnapshot(snapshot);541 const restored = materializeWorkspaceSnapshot(snapshot);
296 const merged = mergeToolsWithCatalog(restored);542 const merged = mergeTools(restored);
297 traceStep("cacheHydrate", cacheHydrateStartedAt);543 traceStep("cacheHydrate", cacheHydrateStartedAt);
298 544
299 const dtsStorageIds = (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[];545 const typesStorageId = cacheEntry.typesStorageId as Id<"_storage"> | undefined;
300 if (cacheEntry.isFresh) {546 if (cacheEntry.isFresh) {
301 const hasCachedDts = dtsStorageIds.length > 0;547 if (typesStorageId) {
302 if (includeDts && hasOpenApiSource && !hasCachedDts) {
303 // Continue into rebuild path to generate missing DTS.
304 } else {
305 return {548 return {
306 tools: merged,549 tools: merged,
307 warnings: snapshot.warnings,550 warnings: snapshot.warnings,
308 dtsStorageIds,551 typesStorageId,
309 debug: {552 debug: {
310 ...debugBase,553 ...debugBase,
311 mode: "cache-fresh",554 mode: "cache-fresh",
@@ -318,11 +561,12 @@ export async function getWorkspaceTools( · 6 unmodified lines
318 },561 },
319 };562 };
320 }563 }
321 } else if (allowStaleOnMismatch && !includeDts) {564 // Continue into rebuild path to generate missing type bundle.
565 } else if (allowStaleOnMismatch) {
322 return {566 return {
323 tools: merged,567 tools: merged,
324 warnings: [...snapshot.warnings, "Tool sources changed; showing previous results while refreshing."],568 warnings: [...snapshot.warnings, "Tool sources changed; showing previous results while refreshing."],
325 dtsStorageIds,569 typesStorageId,
326 debug: {570 debug: {
327 ...debugBase,571 ...debugBase,
328 mode: "cache-stale",572 mode: "cache-stale",
@@ -374,6 +618,7 @@ export async function getWorkspaceTools( · 45 unmodified lines
374 warnings: string[];618 warnings: string[];
375 timedOut: boolean;619 timedOut: boolean;
376 sourceName: string;620 sourceName: string;
621 openApiDts?: string;
377 }>((resolve) => {622 }>((resolve) => {
378 timer = setTimeout(() => {623 timer = setTimeout(() => {
379 resolve({624 resolve({
@@ -381,6 +626,7 @@ export async function getWorkspaceTools( · 1 unmodified line
381 warnings: [`Source '${config.name}' is still loading; showing partial results.`],626 warnings: [`Source '${config.name}' is still loading; showing partial results.`],
382 timedOut: true,627 timedOut: true,
383 sourceName: config.name,628 sourceName: config.name,
629 openApiDts: undefined,
384 });630 });
385 }, sourceTimeoutMs);631 }, sourceTimeoutMs);
386 });632 });
@@ -404,15 +650,15 @@ export async function getWorkspaceTools( · 17 unmodified lines
404 const timedOutSources = loadedSources650 const timedOutSources = loadedSources
405 .filter((loaded) => loaded.timedOut)651 .filter((loaded) => loaded.timedOut)
406 .map((loaded) => loaded.sourceName);652 .map((loaded) => loaded.sourceName);
407 const merged = mergeToolsWithCatalog(externalTools);653 const merged = mergeTools(externalTools);
408 654
409 let dtsStorageIds: DtsStorageEntry[] = [];655 let typesStorageId: Id<"_storage"> | undefined;
410 try {656 try {
411 if (hasTimedOutSource) {657 if (hasTimedOutSource) {
412 return {658 return {
413 tools: merged,659 tools: merged,
414 warnings,660 warnings,
415 dtsStorageIds,661 typesStorageId,
416 debug: {662 debug: {
417 ...debugBase,663 ...debugBase,
418 mode: "rebuild",664 mode: "rebuild",
@@ -429,39 +675,32 @@ export async function getWorkspaceTools( · 10 unmodified lines
429 const snapshotWriteStartedAt = Date.now();675 const snapshotWriteStartedAt = Date.now();
430 const allTools = [...merged.values()];676 const allTools = [...merged.values()];
431 677
432 const seenDtsSources = new Set<string>();678 // Build a per-tool registry for fast discover + invocation.
433 const dtsEntries: { sourceKey: string; content: string }[] = [];679 const registryStartedAt = Date.now();
434 for (const artifact of externalArtifacts) {680 await buildWorkspaceToolRegistry(ctx, {
435 for (const tool of artifact.tools) {681 workspaceId,
436 if (tool.metadata?.sourceDts && tool.source && !seenDtsSources.has(tool.source)) {682 registrySignature,
437 seenDtsSources.add(tool.source);683 serializedTools: externalArtifacts.flatMap((artifact) => artifact.tools),
438 dtsEntries.push({ sourceKey: tool.source, content: tool.metadata.sourceDts });684 });
439 }685 traceStep("toolRegistryWrite", registryStartedAt);
686
687 // Build and store a workspace-wide Monaco type bundle.
688 const openApiDtsBySource: Record<string, string> = {};
689 for (const loaded of loadedSources) {
690 if (loaded.openApiDts && loaded.openApiDts.trim().length > 0) {
691 openApiDtsBySource[`openapi:${loaded.sourceName}`] = loaded.openApiDts;
440 }692 }
441 }693 }
442 694 const typeBundle = buildWorkspaceTypeBundle({
443 const storedDts = await Promise.all(695 tools: allTools,
444 dtsEntries.map(async (entry) => {696 openApiDtsBySource,
445 const dtsBlob = new Blob([entry.content], { type: "text/plain" });697 });
446 const sid = await ctx.storage.store(dtsBlob);698 const typesBlob = new Blob([typeBundle], { type: "text/plain" });
447 return { sourceKey: entry.sourceKey, storageId: sid };699 typesStorageId = await ctx.storage.store(typesBlob);
448 }),
449 );
450 dtsStorageIds = storedDts;
451
452 const sanitizedArtifacts: CompiledToolSourceArtifact[] = externalArtifacts.map((artifact) => ({
453 ...artifact,
454 tools: artifact.tools.map((tool) => {
455 if (!tool.metadata?.sourceDts) return tool;
456 const metadata = { ...tool.metadata };
457 delete (metadata as Record<string, unknown>).sourceDts;
458 return { ...tool, metadata };
459 }),
460 }));
461 700
462 const snapshot: WorkspaceToolSnapshot = {701 const snapshot: WorkspaceToolSnapshot = {
463 version: "v2",702 version: "v2",
464 externalArtifacts: sanitizedArtifacts,703 externalArtifacts,
465 warnings,704 warnings,
466 };705 };
467 706
@@ -473,7 +712,7 @@ export async function getWorkspaceTools( · 5 unmodified lines
473 workspaceId,712 workspaceId,
474 signature,713 signature,
475 storageId,714 storageId,
476 dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId })),715 typesStorageId,
477 toolCount: allTools.length,716 toolCount: allTools.length,
478 sizeBytes: json.length,717 sizeBytes: json.length,
479 });718 });
@@ -489,7 +728,7 @@ export async function getWorkspaceTools( · 9 unmodified lines
489 return {728 return {
490 tools: merged,729 tools: merged,
491 warnings,730 warnings,
492 dtsStorageIds,731 typesStorageId,
493 debug: {732 debug: {
494 ...debugBase,733 ...debugBase,
495 mode: "rebuild",734 mode: "rebuild",
@@ -507,26 +746,21 @@ async function loadWorkspaceToolInventoryForContext( · 11 unmodified lines
507 ctx: ActionCtx,746 ctx: ActionCtx,
508 context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },747 context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },
509 options: {748 options: {
510 includeDts?: boolean;
511 includeDetails?: boolean;749 includeDetails?: boolean;
512 includeSourceMeta?: boolean;750 includeSourceMeta?: boolean;
513 includeSchemaRegistry?: boolean;
514 toolPaths?: string[];751 toolPaths?: string[];
515 sourceTimeoutMs?: number;752 sourceTimeoutMs?: number;
516 allowStaleOnMismatch?: boolean;753 allowStaleOnMismatch?: boolean;
517 skipCacheRead?: boolean;754 skipCacheRead?: boolean;
518 } = {},755 } = {},
519): Promise<WorkspaceToolInventory> {756): Promise<WorkspaceToolInventory> {
520 const includeDts = options.includeDts ?? false;
521 const includeDetails = options.includeDetails ?? true;757 const includeDetails = options.includeDetails ?? true;
522 const includeSourceMeta = options.includeSourceMeta ?? true;758 const includeSourceMeta = options.includeSourceMeta ?? true;
523 const includeSchemaRegistry = options.includeSchemaRegistry ?? false;
524 const sourceTimeoutMs = options.sourceTimeoutMs;759 const sourceTimeoutMs = options.sourceTimeoutMs;
525 const allowStaleOnMismatch = options.allowStaleOnMismatch;760 const allowStaleOnMismatch = options.allowStaleOnMismatch;
526 const skipCacheRead = options.skipCacheRead;761 const skipCacheRead = options.skipCacheRead;
527 const [result, policies] = await Promise.all([762 const [result, policies] = await Promise.all([
528 getWorkspaceTools(ctx, context.workspaceId, {763 getWorkspaceTools(ctx, context.workspaceId, {
529 includeDts,
530 sourceTimeoutMs,764 sourceTimeoutMs,
531 allowStaleOnMismatch,765 allowStaleOnMismatch,
532 skipCacheRead,766 skipCacheRead,
@@ -543,10 +777,8 @@ async function loadWorkspaceToolInventoryForContext( · 10 unmodified lines
543 const descriptorsMs = Date.now() - descriptorsStartedAt;777 const descriptorsMs = Date.now() - descriptorsStartedAt;
544 let sourceQuality: Record<string, OpenApiSourceQuality> = {};778 let sourceQuality: Record<string, OpenApiSourceQuality> = {};
545 let sourceAuthProfiles: Record<string, SourceAuthProfile> = {};779 let sourceAuthProfiles: Record<string, SourceAuthProfile> = {};
546 let sourceSchemas: Record<string, Record<string, string>> = {};
547 let qualityMs = 0;780 let qualityMs = 0;
548 let authProfilesMs = 0;781 let authProfilesMs = 0;
549 let schemaRegistryMs = 0;
550 782
551 if (includeSourceMeta) {783 if (includeSourceMeta) {
552 const qualityStartedAt = Date.now();784 const qualityStartedAt = Date.now();
@@ -564,16 +796,15 @@ async function loadWorkspaceToolInventoryForContext( · 11 unmodified lines
564 ]796 ]
565 : ["sourceMeta=skipped"];797 : ["sourceMeta=skipped"];
566 798
567 if (includeSchemaRegistry) {799 let typesUrl: string | undefined;
568 const schemaRegistryStartedAt = Date.now();800 if (result.typesStorageId) {
569 sourceSchemas = await loadSourceSchemasForTools(ctx, tools, result.dtsStorageIds);801 try {
570 schemaRegistryMs = Date.now() - schemaRegistryStartedAt;802 typesUrl = await ctx.storage.getUrl(result.typesStorageId) ?? undefined;
803 } catch {
804 typesUrl = undefined;
805 }
571 }806 }
572 807
573 const schemaRegistryTrace = includeSchemaRegistry
574 ? [`buildSourceSchemas=${schemaRegistryMs}ms`]
575 : ["sourceSchemas=skipped"];
576
577 const { tools: boundedTools, warnings: boundedWarnings } = truncateToolsForActionResult(808 const { tools: boundedTools, warnings: boundedWarnings } = truncateToolsForActionResult(
578 tools,809 tools,
579 result.warnings,810 result.warnings,
@@ -582,17 +813,15 @@ async function loadWorkspaceToolInventoryForContext( · 2 unmodified lines
582 return {813 return {
583 tools: boundedTools,814 tools: boundedTools,
584 warnings: boundedWarnings,815 warnings: boundedWarnings,
585 dtsStorageIds: result.dtsStorageIds,816 typesUrl,
586 sourceQuality,817 sourceQuality,
587 sourceAuthProfiles,818 sourceAuthProfiles,
588 sourceSchemas,
589 debug: {819 debug: {
590 ...result.debug,820 ...result.debug,
591 trace: [821 trace: [
592 ...result.debug.trace,822 ...result.debug.trace,
593 `listVisibleToolDescriptors=${descriptorsMs}ms`,823 `listVisibleToolDescriptors=${descriptorsMs}ms`,
594 ...sourceMetaTrace,824 ...sourceMetaTrace,
595 ...schemaRegistryTrace,
596 ],825 ],
597 },826 },
598 };827 };
@@ -602,10 +831,8 @@ export async function listToolsForContext( · 3 unmodified lines
602 ctx: ActionCtx,831 ctx: ActionCtx,
603 context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },832 context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },
604 options: {833 options: {
605 includeDts?: boolean;
606 includeDetails?: boolean;834 includeDetails?: boolean;
607 includeSourceMeta?: boolean;835 includeSourceMeta?: boolean;
608 includeSchemaRegistry?: boolean;
609 toolPaths?: string[];836 toolPaths?: string[];
610 sourceTimeoutMs?: number;837 sourceTimeoutMs?: number;
611 allowStaleOnMismatch?: boolean;838 allowStaleOnMismatch?: boolean;
@@ -620,10 +847,8 @@ export async function listToolsWithWarningsForContext( · 8 unmodified lines
620 ctx: ActionCtx,847 ctx: ActionCtx,
621 context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },848 context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },
622 options: {849 options: {
623 includeDts?: boolean;
624 includeDetails?: boolean;850 includeDetails?: boolean;
625 includeSourceMeta?: boolean;851 includeSourceMeta?: boolean;
626 includeSchemaRegistry?: boolean;
627 toolPaths?: string[];852 toolPaths?: string[];
628 sourceTimeoutMs?: number;853 sourceTimeoutMs?: number;
629 allowStaleOnMismatch?: boolean;854 allowStaleOnMismatch?: boolean;
@@ -632,74 +857,20 @@ export async function listToolsWithWarningsForContext( · 2 unmodified lines
632): Promise<{857): Promise<{
633 tools: ToolDescriptor[];858 tools: ToolDescriptor[];
634 warnings: string[];859 warnings: string[];
635 dtsUrls: Record<string, string>;860 typesUrl?: string;
636 sourceQuality: Record<string, OpenApiSourceQuality>;861 sourceQuality: Record<string, OpenApiSourceQuality>;
637 sourceAuthProfiles: Record<string, SourceAuthProfile>;862 sourceAuthProfiles: Record<string, SourceAuthProfile>;
638 sourceSchemas: Record<string, Record<string, string>>;
639 debug: WorkspaceToolsDebug;863 debug: WorkspaceToolsDebug;
640}> {864}> {
641 const inventory = await loadWorkspaceToolInventoryForContext(ctx, context, options);865 const inventory = await loadWorkspaceToolInventoryForContext(ctx, context, options);
642 const dtsUrls = await loadDtsUrls(ctx, inventory.dtsStorageIds);
643
644 return {866 return {
645 tools: inventory.tools,867 tools: inventory.tools,
646 warnings: inventory.warnings,868 warnings: inventory.warnings,
647 dtsUrls,869 typesUrl: inventory.typesUrl,
648 sourceQuality: inventory.sourceQuality,870 sourceQuality: inventory.sourceQuality,
649 sourceAuthProfiles: inventory.sourceAuthProfiles,871 sourceAuthProfiles: inventory.sourceAuthProfiles,
650 sourceSchemas: inventory.sourceSchemas,
651 debug: inventory.debug,872 debug: inventory.debug,
652 };873 };
653}874}
654 875
655export async function loadDtsUrls(ctx: ActionCtx, entries: DtsStorageEntry[]): Promise<Record<string, string>> {
656 if (entries.length === 0) return {};
657
658 const urlEntries = await Promise.all(entries.map(async (entry) => {
659 try {
660 const url = await ctx.storage.getUrl(entry.storageId);
661 return url ? [entry.sourceKey, url] as const : null;
662 } catch {
663 return null;
664 }
665 }));
666
667 const dtsUrls: Record<string, string> = {};
668 for (const pair of urlEntries) {
669 if (!pair) continue;
670 const [sourceKey, url] = pair;
671 dtsUrls[sourceKey] = url;
672 }
673
674 return dtsUrls;
675}
676
677export async function loadWorkspaceDtsStorageIds(
678 ctx: ActionCtx,
679 workspaceId: Id<"workspaces">,
680): Promise<DtsStorageEntry[]> {
681 const sources = (await ctx.runQuery(internal.database.listToolSources, { workspaceId }))
682 .filter((source: { enabled: boolean }) => source.enabled);
683 const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi");
684 const signature = sourceSignature(workspaceId, sources);
685
686 try {
687 const cacheEntry = await ctx.runQuery(internal.workspaceToolCache.getEntry, {
688 workspaceId,
689 signature,
690 });
691 if (cacheEntry) {
692 const dtsStorageIds = (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[];
693 if (dtsStorageIds.length > 0 || !hasOpenApiSource) {
694 return dtsStorageIds;
695 }
696 }
697 } catch {
698 // Fall through to a full rebuild path.
699 }
700
701 const rebuilt = await getWorkspaceTools(ctx, workspaceId, { includeDts: true });
702 return rebuilt.dtsStorageIds;
703}
704
705export { baseTools };876export { baseTools };
executor/packages/convex/schema.ts+182−3
@@ -1,6 +1,20 @@
1import { defineSchema, defineTable } from "convex/server";1import { defineSchema, defineTable } from "convex/server";
2import { v } from "convex/values";2import { v } from "convex/values";
3 3
4// Convex database schema.
5//
6// Conventions used throughout:
7// - Most tables have `createdAt` / `updatedAt` as epoch milliseconds.
8// - Some tables use a *domain id* string (eg `task_<uuid>`, `approval_<uuid>`) in addition
9// to Convex's built-in `_id`. When present, the domain id is what gets referenced across
10// systems and in logs; `_id` stays internal to Convex.
11// - `actorId` is an external-ish identifier and is intentionally a string in some tables.
12// It can be an `accounts._id` string or an `anon_<uuid>` value.
13//
14// The small validators below act like enums for schema fields.
15// Note: Some of these are duplicated as request validators under `executor/packages/convex/database/validators.ts`
16// and in a few feature modules. Keep the literal sets aligned to avoid drift.
17
4const accountProvider = v.union(v.literal("workos"), v.literal("anonymous"));18const accountProvider = v.union(v.literal("workos"), v.literal("anonymous"));
5const accountStatus = v.union(v.literal("active"), v.literal("deleted"));19const accountStatus = v.union(v.literal("active"), v.literal("deleted"));
6const organizationStatus = v.union(v.literal("active"), v.literal("deleted"));20const organizationStatus = v.union(v.literal("active"), v.literal("deleted"));
@@ -41,6 +55,7 @@ const toolCallStatus = v.union( · 34 unmodified lines
41 v.literal("failed"),55 v.literal("failed"),
42 v.literal("denied"),56 v.literal("denied"),
43);57);
58const toolApprovalMode = v.union(v.literal("auto"), v.literal("required"));
44const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));59const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
45const credentialScope = v.union(v.literal("workspace"), v.literal("actor"));60const credentialScope = v.union(v.literal("workspace"), v.literal("actor"));
46const credentialProvider = v.union(61const credentialProvider = v.union(
@@ -50,6 +65,10 @@ const credentialProvider = v.union( · 3 unmodified lines
50const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));65const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
51 66
52export default defineSchema({67export default defineSchema({
68 // User identities (WorkOS-backed or anonymous).
69 //
70 // Primary access patterns:
71 // - Lookup by provider + providerAccountId (WorkOS user id / anon id).
53 accounts: defineTable({72 accounts: defineTable({
54 provider: accountProvider,73 provider: accountProvider,
55 providerAccountId: v.string(), // WorkOS user ID or anon_* UUID74 providerAccountId: v.string(), // WorkOS user ID or anon_* UUID
@@ -65,6 +84,12 @@ export default defineSchema({ · 9 unmodified lines
65 })84 })
66 .index("by_provider", ["provider", "providerAccountId"]),85 .index("by_provider", ["provider", "providerAccountId"]),
67 86
87 // Workspaces are the main unit of isolation for tasks, tools, and credentials.
88 // A workspace always belongs to exactly one `organizations` row.
89 //
90 // Primary access patterns:
91 // - Resolve by slug (global) or by (organizationId, slug).
92 // - List workspaces in an org by creation time.
68 workspaces: defineTable({93 workspaces: defineTable({
69 workosOrgId: v.optional(v.string()), // external WorkOS org ID94 workosOrgId: v.optional(v.string()), // external WorkOS org ID
70 organizationId: v.id("organizations"),95 organizationId: v.id("organizations"),
@@ -80,6 +105,12 @@ export default defineSchema({ · 9 unmodified lines
80 .index("by_organization_slug", ["organizationId", "slug"])105 .index("by_organization_slug", ["organizationId", "slug"])
81 .index("by_slug", ["slug"]),106 .index("by_slug", ["slug"]),
82 107
108 // Billing / membership umbrella entity.
109 // Note: WorkOS organization id is stored here and mirrored onto `workspaces` for convenience.
110 //
111 // Primary access patterns:
112 // - Resolve by slug.
113 // - Resolve by WorkOS org id.
83 organizations: defineTable({114 organizations: defineTable({
84 workosOrgId: v.optional(v.string()), // external WorkOS org ID115 workosOrgId: v.optional(v.string()), // external WorkOS org ID
85 slug: v.string(),116 slug: v.string(),
@@ -93,6 +124,13 @@ export default defineSchema({ · 7 unmodified lines
93 .index("by_slug", ["slug"])124 .index("by_slug", ["slug"])
94 .index("by_status_created", ["status", "createdAt"]),125 .index("by_status_created", ["status", "createdAt"]),
95 126
127 // Membership of an account within an organization.
128 // `billable` drives seat-count calculations.
129 //
130 // Primary access patterns:
131 // - List members in org.
132 // - Get membership for (org, account).
133 // - Count billable active members (org, billable, status).
96 organizationMembers: defineTable({134 organizationMembers: defineTable({
97 organizationId: v.id("organizations"),135 organizationId: v.id("organizations"),
98 accountId: v.id("accounts"),136 accountId: v.id("accounts"),
@@ -110,6 +148,12 @@ export default defineSchema({ · 11 unmodified lines
110 .index("by_account", ["accountId"])148 .index("by_account", ["accountId"])
111 .index("by_org_billable_status", ["organizationId", "billable", "status"]),149 .index("by_org_billable_status", ["organizationId", "billable", "status"]),
112 150
151 // Membership of an account within a workspace (scopes app permissions within the org).
152 //
153 // Primary access patterns:
154 // - List members in workspace.
155 // - Get membership for (workspace, account).
156 // - Lookup by WorkOS membership id during auth event handlers.
113 workspaceMembers: defineTable({157 workspaceMembers: defineTable({
114 workspaceId: v.id("workspaces"),158 workspaceId: v.id("workspaces"),
115 accountId: v.id("accounts"),159 accountId: v.id("accounts"),
@@ -124,6 +168,12 @@ export default defineSchema({ · 8 unmodified lines
124 .index("by_account", ["accountId"])168 .index("by_account", ["accountId"])
125 .index("by_workos_membership_id", ["workosOrgMembershipId"]),169 .index("by_workos_membership_id", ["workosOrgMembershipId"]),
126 170
171 // Organization (and optionally workspace-specific) email invites.
172 // Provider-specific invite id is stored once WorkOS invite delivery succeeds.
173 //
174 // Primary access patterns:
175 // - List invites for org.
176 // - Find invites by (org, email, status) during acceptance flows.
127 invites: defineTable({177 invites: defineTable({
128 organizationId: v.id("organizations"),178 organizationId: v.id("organizations"),
129 workspaceId: v.optional(v.id("workspaces")),179 workspaceId: v.optional(v.id("workspaces")),
@@ -140,6 +190,11 @@ export default defineSchema({ · 10 unmodified lines
140 .index("by_org", ["organizationId"])190 .index("by_org", ["organizationId"])
141 .index("by_org_email_status", ["organizationId", "email", "status"]),191 .index("by_org_email_status", ["organizationId", "email", "status"]),
142 192
193 // Stripe customer linkage for an organization.
194 //
195 // Primary access patterns:
196 // - Resolve by organization.
197 // - (Potential/expected) resolve by Stripe customer id in webhook reconciliation.
143 billingCustomers: defineTable({198 billingCustomers: defineTable({
144 organizationId: v.id("organizations"),199 organizationId: v.id("organizations"),
145 stripeCustomerId: v.string(), // external Stripe customer ID200 stripeCustomerId: v.string(), // external Stripe customer ID
@@ -149,6 +204,11 @@ export default defineSchema({ · 3 unmodified lines
149 .index("by_org", ["organizationId"])204 .index("by_org", ["organizationId"])
150 .index("by_stripe_customer_id", ["stripeCustomerId"]),205 .index("by_stripe_customer_id", ["stripeCustomerId"]),
151 206
207 // Stripe subscription state for an organization.
208 //
209 // Primary access patterns:
210 // - List subscriptions for an org.
211 // - (Potential/expected) resolve by subscription id and filter by status.
152 billingSubscriptions: defineTable({212 billingSubscriptions: defineTable({
153 organizationId: v.id("organizations"),213 organizationId: v.id("organizations"),
154 stripeSubscriptionId: v.string(), // external Stripe subscription ID214 stripeSubscriptionId: v.string(), // external Stripe subscription ID
@@ -165,6 +225,8 @@ export default defineSchema({ · 10 unmodified lines
165 .index("by_org_status", ["organizationId", "status"])225 .index("by_org_status", ["organizationId", "status"])
166 .index("by_stripe_subscription_id", ["stripeSubscriptionId"]),226 .index("by_stripe_subscription_id", ["stripeSubscriptionId"]),
167 227
228 // Seat syncing bookkeeping (eg Stripe per-seat quantity).
229 // Stored separately from subscription records so sync logic can be retried/idempotent.
168 billingSeatState: defineTable({230 billingSeatState: defineTable({
169 organizationId: v.id("organizations"),231 organizationId: v.id("organizations"),
170 desiredSeats: v.number(),232 desiredSeats: v.number(),
@@ -175,6 +237,13 @@ export default defineSchema({ · 4 unmodified lines
175 updatedAt: v.number(),237 updatedAt: v.number(),
176 }).index("by_org", ["organizationId"]),238 }).index("by_org", ["organizationId"]),
177 239
240 // Task executions (code run in a runtime for a workspace).
241 // Note: `taskId` is a stable domain id used across systems; `_id` is Convex internal.
242 //
243 // Primary access patterns:
244 // - Resolve by domain task id.
245 // - List recent tasks in a workspace.
246 // - Poll queues by status.
178 tasks: defineTable({247 tasks: defineTable({
179 taskId: v.string(), // domain ID: task_<uuid>248 taskId: v.string(), // domain ID: task_<uuid>
180 code: v.string(),249 code: v.string(),
@@ -198,6 +267,12 @@ export default defineSchema({ · 17 unmodified lines
198 .index("by_workspace_created", ["workspaceId", "createdAt"])267 .index("by_workspace_created", ["workspaceId", "createdAt"])
199 .index("by_status_created", ["status", "createdAt"]),268 .index("by_status_created", ["status", "createdAt"]),
200 269
270 // Approval records for sensitive tool calls.
271 // `taskId` references `tasks.taskId` (domain id), not `tasks._id`.
272 //
273 // Primary access patterns:
274 // - Resolve by approval id.
275 // - List approvals by workspace and status.
201 approvals: defineTable({276 approvals: defineTable({
202 approvalId: v.string(), // domain ID: approval_<uuid>277 approvalId: v.string(), // domain ID: approval_<uuid>
203 taskId: v.string(), // references tasks.taskId (not tasks._id)278 taskId: v.string(), // references tasks.taskId (not tasks._id)
@@ -214,6 +289,12 @@ export default defineSchema({ · 10 unmodified lines
214 .index("by_workspace_created", ["workspaceId", "createdAt"])289 .index("by_workspace_created", ["workspaceId", "createdAt"])
215 .index("by_workspace_status_created", ["workspaceId", "status", "createdAt"]),290 .index("by_workspace_status_created", ["workspaceId", "status", "createdAt"]),
216 291
292 // Individual tool call rows emitted during a task.
293 //
294 // Primary access patterns:
295 // - Get a specific call by (taskId, callId).
296 // - List calls for a task ordered by creation time.
297 // - (Potential/expected) resolve via approval id to tie approvals to tool call rows.
217 toolCalls: defineTable({298 toolCalls: defineTable({
218 taskId: v.string(),299 taskId: v.string(),
219 callId: v.string(),300 callId: v.string(),
@@ -231,6 +312,8 @@ export default defineSchema({ · 11 unmodified lines
231 .index("by_workspace_created", ["workspaceId", "createdAt"])312 .index("by_workspace_created", ["workspaceId", "createdAt"])
232 .index("by_approval_id", ["approvalId"]),313 .index("by_approval_id", ["approvalId"]),
233 314
315 // Append-only event log for a task.
316 // `sequence` is monotonically increasing per task (used for ordered replay).
234 taskEvents: defineTable({317 taskEvents: defineTable({
235 sequence: v.number(),318 sequence: v.number(),
236 taskId: v.string(), // references tasks.taskId (not tasks._id)319 taskId: v.string(), // references tasks.taskId (not tasks._id)
@@ -241,6 +324,9 @@ export default defineSchema({ · 4 unmodified lines
241 })324 })
242 .index("by_task_sequence", ["taskId", "sequence"]),325 .index("by_task_sequence", ["taskId", "sequence"]),
243 326
327 // Workspace access policy rules used by the approval / tool firewall.
328 // `toolPathPattern` is matched against tool paths and combined with actor/client selectors.
329 // Higher `priority` wins when multiple policies match.
244 accessPolicies: defineTable({330 accessPolicies: defineTable({
245 policyId: v.string(), // domain ID: policy_<uuid>331 policyId: v.string(), // domain ID: policy_<uuid>
246 workspaceId: v.id("workspaces"),332 workspaceId: v.id("workspaces"),
@@ -255,6 +341,15 @@ export default defineSchema({ · 8 unmodified lines
255 .index("by_policy_id", ["policyId"])341 .index("by_policy_id", ["policyId"])
256 .index("by_workspace_created", ["workspaceId", "createdAt"]),342 .index("by_workspace_created", ["workspaceId", "createdAt"]),
257 343
344 // Stored credentials for tool sources.
345 //
346 // A single credential "connection" (credentialId) can have multiple rows to support
347 // different bindings (workspace-wide and per-actor). `bindingId` exists as a stable handle
348 // for UI/API operations that need an id before the connection id is known.
349 //
350 // Primary access patterns:
351 // - Resolve by (workspaceId, sourceKey, scope, actorId) - actorId is "" for workspace scope.
352 // - List all credentials in workspace by createdAt.
258 sourceCredentials: defineTable({353 sourceCredentials: defineTable({
259 bindingId: v.string(), // domain ID: bind_<uuid>354 bindingId: v.string(), // domain ID: bind_<uuid>
260 credentialId: v.string(), // domain ID: conn_<uuid>355 credentialId: v.string(), // domain ID: conn_<uuid>
@@ -274,6 +369,14 @@ export default defineSchema({ · 13 unmodified lines
274 .index("by_workspace_credential", ["workspaceId", "credentialId"])369 .index("by_workspace_credential", ["workspaceId", "credentialId"])
275 .index("by_binding_id", ["bindingId"]),370 .index("by_binding_id", ["bindingId"]),
276 371
372 // Configured tool sources for a workspace (MCP servers, OpenAPI sources, GraphQL sources).
373 // `specHash` enables cache invalidation when the definition changes.
374 // `authFingerprint` is used to determine whether cached tool materialization is still valid.
375 //
376 // Primary access patterns:
377 // - Resolve by domain source id.
378 // - List sources by workspace, sorted by updatedAt.
379 // - Enforce name uniqueness per workspace.
277 toolSources: defineTable({380 toolSources: defineTable({
278 sourceId: v.string(), // domain ID: src_<uuid>381 sourceId: v.string(), // domain ID: src_<uuid>
279 workspaceId: v.id("workspaces"),382 workspaceId: v.id("workspaces"),
@@ -290,6 +393,8 @@ export default defineSchema({ · 10 unmodified lines
290 .index("by_workspace_updated", ["workspaceId", "updatedAt"])393 .index("by_workspace_updated", ["workspaceId", "updatedAt"])
291 .index("by_workspace_name", ["workspaceId", "name"]),394 .index("by_workspace_name", ["workspaceId", "name"]),
292 395
396 // Cached OpenAPI spec blobs stored in Convex storage.
397 // (specUrl, version) uniquely identifies a stored spec payload.
293 openApiSpecCache: defineTable({398 openApiSpecCache: defineTable({
294 specUrl: v.string(),399 specUrl: v.string(),
295 storageId: v.id("_storage"),400 storageId: v.id("_storage"),
@@ -299,21 +404,95 @@ export default defineSchema({ · 3 unmodified lines
299 })404 })
300 .index("by_spec_url_version", ["specUrl", "version"]),405 .index("by_spec_url_version", ["specUrl", "version"]),
301 406
407 // Cached, materialized tool catalog for a workspace.
408 // This stores large artifacts (compiled tool definitions and per-source .d.ts files)
409 // in `_storage` and keeps pointers + metadata here.
302 workspaceToolCache: defineTable({410 workspaceToolCache: defineTable({
303 workspaceId: v.id("workspaces"),411 workspaceId: v.id("workspaces"),
304 signature: v.string(),412 signature: v.string(),
305 storageId: v.id("_storage"),413 storageId: v.id("_storage"),
306 /** Per-source .d.ts blobs stored separately (too large for action responses). */414 /** Legacy per-source OpenAPI .d.ts blobs. No longer used; retained for safe schema upgrades. */
307 dtsStorageIds: v.array(v.object({415 dtsStorageIds: v.optional(v.array(v.object({
308 sourceKey: v.string(),416 sourceKey: v.string(),
309 storageId: v.id("_storage"),417 storageId: v.id("_storage"),
310 })),418 sizeBytes: v.number(),
419 }))),
420 /** Workspace-wide Monaco type bundle (.d.ts) stored separately. */
421 typesStorageId: v.optional(v.id("_storage")),
311 toolCount: v.number(),422 toolCount: v.number(),
312 sizeBytes: v.number(),423 sizeBytes: v.number(),
313 createdAt: v.number(),424 createdAt: v.number(),
314 })425 })
315 .index("by_workspace", ["workspaceId"]),426 .index("by_workspace", ["workspaceId"]),
316 427
428 // Workspace tool registry state.
429 // Stores the currently "ready" build id for search + invocation.
430 workspaceToolRegistryState: defineTable({
431 workspaceId: v.id("workspaces"),
432 signature: v.string(),
433 readyBuildId: v.optional(v.string()),
434 buildingBuildId: v.optional(v.string()),
435 createdAt: v.number(),
436 updatedAt: v.number(),
437 })
438 .index("by_workspace", ["workspaceId"]),
439
440 // Per-tool registry entries for fast discover + invocation.
441 // NOTE: We avoid storing raw JSON Schemas here because Convex forbids `$`-prefixed keys.
442 workspaceToolRegistry: defineTable({
443 workspaceId: v.id("workspaces"),
444 buildId: v.string(),
445 path: v.string(),
446 preferredPath: v.string(),
447 namespace: v.string(),
448 normalizedPath: v.string(),
449 aliases: v.array(v.string()),
450 description: v.string(),
451 approval: toolApprovalMode,
452 source: v.optional(v.string()),
453 searchText: v.string(),
454 displayInput: v.optional(v.string()),
455 displayOutput: v.optional(v.string()),
456 requiredInputKeys: v.optional(v.array(v.string())),
457 previewInputKeys: v.optional(v.array(v.string())),
458 typedRef: v.optional(v.object({
459 kind: v.literal("openapi_operation"),
460 sourceKey: v.string(),
461 operationId: v.string(),
462 })),
463 // JSON string of a core SerializedTool (safe to contain `$ref` etc in string content).
464 serializedToolJson: v.string(),
465 createdAt: v.number(),
466 })
467 .index("by_workspace_build_path", ["workspaceId", "buildId", "path"])
468 .index("by_workspace_build_normalized", ["workspaceId", "buildId", "normalizedPath"])
469 .index("by_workspace_build_namespace", ["workspaceId", "buildId", "namespace"])
470 .index("by_workspace_build", ["workspaceId", "buildId"])
471 .searchIndex("search_text", {
472 searchField: "searchText",
473 filterFields: ["workspaceId", "buildId"],
474 }),
475
476 // Precomputed namespace summaries for fast catalog.namespaces.
477 workspaceToolNamespaces: defineTable({
478 workspaceId: v.id("workspaces"),
479 buildId: v.string(),
480 namespace: v.string(),
481 toolCount: v.number(),
482 samplePaths: v.array(v.string()),
483 createdAt: v.number(),
484 })
485 .index("by_workspace_build", ["workspaceId", "buildId"])
486 .index("by_workspace_build_namespace", ["workspaceId", "buildId", "namespace"]),
487
488 // Anonymous session linkage.
489 // Used to map an unauthenticated/anonymous actor to a backing `accounts` row and a
490 // `workspaceMembers` user entry.
491 //
492 // Primary access patterns:
493 // - Resolve by session id.
494 // - Resolve by (workspaceId, actorId) to find an existing session.
495 // - List sessions for an account.
317 anonymousSessions: defineTable({496 anonymousSessions: defineTable({
318 sessionId: v.string(), // domain ID: anon_session_<uuid> or mcp_<uuid>497 sessionId: v.string(), // domain ID: anon_session_<uuid> or mcp_<uuid>
319 workspaceId: v.id("workspaces"),498 workspaceId: v.id("workspaces"),
executor/packages/convex/test-fixtures/executorNode.oomFixture.ts+13−3
@@ -8,9 +8,19 @@ const TOOL = { · 7 unmodified lines
8 description: "Get the authenticated GitHub user",8 description: "Get the authenticated GitHub user",
9 approval: "auto" as const,9 approval: "auto" as const,
10 source: "openapi:github",10 source: "openapi:github",
11 argsType: "{}",11 typing: {
12 returnsType: "{ login: string; name: string | null; email: string | null }",12 typedRef: {
13 operationId: "users/get-authenticated",13 kind: "openapi_operation" as const,
14 sourceKey: "openapi:github",
15 operationId: "users/get-authenticated",
16 },
17 requiredInputKeys: [],
18 previewInputKeys: [],
19 },
20 display: {
21 input: "{}",
22 output: "{ login: string; name: string | null; email: string | null }",
23 },
14};24};
15 25
16export const listToolsInternal = internalAction({26export const listToolsInternal = internalAction({
executor/packages/convex/toolRegistry.ts+331−0
@@ -0,0 +1,331 @@
1import { v } from "convex/values";
2import { internalMutation, internalQuery } from "./_generated/server";
3
4export const getState = internalQuery({
5 args: {
6 workspaceId: v.id("workspaces"),
7 },
8 handler: async (ctx, args) => {
9 const entry = await ctx.db
10 .query("workspaceToolRegistryState")
11 .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
12 .unique();
13
14 if (!entry) return null;
15
16 return {
17 signature: entry.signature,
18 readyBuildId: entry.readyBuildId,
19 buildingBuildId: entry.buildingBuildId,
20 updatedAt: entry.updatedAt,
21 };
22 },
23});
24
25export const beginBuild = internalMutation({
26 args: {
27 workspaceId: v.id("workspaces"),
28 signature: v.string(),
29 buildId: v.string(),
30 },
31 handler: async (ctx, args) => {
32 const now = Date.now();
33 const existing = await ctx.db
34 .query("workspaceToolRegistryState")
35 .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
36 .unique();
37
38 if (existing) {
39 await ctx.db.patch(existing._id, {
40 signature: args.signature,
41 buildingBuildId: args.buildId,
42 updatedAt: now,
43 });
44 return;
45 }
46
47 await ctx.db.insert("workspaceToolRegistryState", {
48 workspaceId: args.workspaceId,
49 signature: args.signature,
50 readyBuildId: undefined,
51 buildingBuildId: args.buildId,
52 createdAt: now,
53 updatedAt: now,
54 });
55 },
56});
57
58export const putToolsBatch = internalMutation({
59 args: {
60 workspaceId: v.id("workspaces"),
61 buildId: v.string(),
62 tools: v.array(
63 v.object({
64 path: v.string(),
65 preferredPath: v.string(),
66 namespace: v.string(),
67 normalizedPath: v.string(),
68 aliases: v.array(v.string()),
69 description: v.string(),
70 approval: v.union(v.literal("auto"), v.literal("required")),
71 source: v.optional(v.string()),
72 searchText: v.string(),
73 displayInput: v.optional(v.string()),
74 displayOutput: v.optional(v.string()),
75 requiredInputKeys: v.optional(v.array(v.string())),
76 previewInputKeys: v.optional(v.array(v.string())),
77 typedRef: v.optional(
78 v.object({
79 kind: v.literal("openapi_operation"),
80 sourceKey: v.string(),
81 operationId: v.string(),
82 }),
83 ),
84 serializedToolJson: v.string(),
85 }),
86 ),
87 },
88 handler: async (ctx, args) => {
89 const now = Date.now();
90 for (const tool of args.tools) {
91 await ctx.db.insert("workspaceToolRegistry", {
92 workspaceId: args.workspaceId,
93 buildId: args.buildId,
94 path: tool.path,
95 preferredPath: tool.preferredPath,
96 namespace: tool.namespace,
97 normalizedPath: tool.normalizedPath,
98 aliases: tool.aliases,
99 description: tool.description,
100 approval: tool.approval,
101 source: tool.source,
102 searchText: tool.searchText,
103 displayInput: tool.displayInput,
104 displayOutput: tool.displayOutput,
105 requiredInputKeys: tool.requiredInputKeys,
106 previewInputKeys: tool.previewInputKeys,
107 typedRef: tool.typedRef,
108 serializedToolJson: tool.serializedToolJson,
109 createdAt: now,
110 });
111 }
112 },
113});
114
115export const putNamespacesBatch = internalMutation({
116 args: {
117 workspaceId: v.id("workspaces"),
118 buildId: v.string(),
119 namespaces: v.array(
120 v.object({
121 namespace: v.string(),
122 toolCount: v.number(),
123 samplePaths: v.array(v.string()),
124 }),
125 ),
126 },
127 handler: async (ctx, args) => {
128 const now = Date.now();
129 for (const ns of args.namespaces) {
130 await ctx.db.insert("workspaceToolNamespaces", {
131 workspaceId: args.workspaceId,
132 buildId: args.buildId,
133 namespace: ns.namespace,
134 toolCount: ns.toolCount,
135 samplePaths: ns.samplePaths,
136 createdAt: now,
137 });
138 }
139 },
140});
141
142export const finishBuild = internalMutation({
143 args: {
144 workspaceId: v.id("workspaces"),
145 buildId: v.string(),
146 },
147 handler: async (ctx, args) => {
148 const now = Date.now();
149 const state = await ctx.db
150 .query("workspaceToolRegistryState")
151 .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
152 .unique();
153
154 if (!state) {
155 await ctx.db.insert("workspaceToolRegistryState", {
156 workspaceId: args.workspaceId,
157 signature: "",
158 readyBuildId: args.buildId,
159 buildingBuildId: undefined,
160 createdAt: now,
161 updatedAt: now,
162 });
163 return;
164 }
165
166 if (state.buildingBuildId !== args.buildId) {
167 // Another build started; ignore finishing this one.
168 return;
169 }
170
171 await ctx.db.patch(state._id, {
172 readyBuildId: args.buildId,
173 buildingBuildId: undefined,
174 updatedAt: now,
175 });
176 },
177});
178
179export const getToolByPath = internalQuery({
180 args: {
181 workspaceId: v.id("workspaces"),
182 buildId: v.string(),
183 path: v.string(),
184 },
185 handler: async (ctx, args) => {
186 const entry = await ctx.db
187 .query("workspaceToolRegistry")
188 .withIndex("by_workspace_build_path", (q) =>
189 q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("path", args.path),
190 )
191 .first();
192
193 if (!entry) return null;
194
195 return {
196 path: entry.path,
197 preferredPath: entry.preferredPath,
198 approval: entry.approval,
199 namespace: entry.namespace,
200 aliases: entry.aliases,
201 description: entry.description,
202 source: entry.source,
203 displayInput: entry.displayInput,
204 displayOutput: entry.displayOutput,
205 requiredInputKeys: entry.requiredInputKeys,
206 previewInputKeys: entry.previewInputKeys,
207 serializedToolJson: entry.serializedToolJson,
208 };
209 },
210});
211
212export const listToolsByNamespace = internalQuery({
213 args: {
214 workspaceId: v.id("workspaces"),
215 buildId: v.string(),
216 namespace: v.string(),
217 limit: v.number(),
218 },
219 handler: async (ctx, args) => {
220 const namespace = args.namespace.trim().toLowerCase();
221 const limit = Math.max(1, Math.min(200, Math.floor(args.limit)));
222 if (!namespace) return [];
223
224 const entries = await ctx.db
225 .query("workspaceToolRegistry")
226 .withIndex("by_workspace_build_namespace", (q) =>
227 q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("namespace", namespace),
228 )
229 .take(limit);
230
231 return entries.map((entry) => ({
232 path: entry.path,
233 preferredPath: entry.preferredPath,
234 aliases: entry.aliases,
235 description: entry.description,
236 approval: entry.approval,
237 source: entry.source,
238 displayInput: entry.displayInput,
239 displayOutput: entry.displayOutput,
240 requiredInputKeys: entry.requiredInputKeys,
241 previewInputKeys: entry.previewInputKeys,
242 }));
243 },
244});
245
246export const getToolsByNormalizedPath = internalQuery({
247 args: {
248 workspaceId: v.id("workspaces"),
249 buildId: v.string(),
250 normalizedPath: v.string(),
251 limit: v.number(),
252 },
253 handler: async (ctx, args) => {
254 const normalized = args.normalizedPath.trim().toLowerCase();
255 if (!normalized) return [];
256 const limit = Math.max(1, Math.min(10, Math.floor(args.limit)));
257
258 const entries = await ctx.db
259 .query("workspaceToolRegistry")
260 .withIndex("by_workspace_build_normalized", (q) =>
261 q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("normalizedPath", normalized),
262 )
263 .take(limit);
264
265 return entries.map((entry) => ({
266 path: entry.path,
267 preferredPath: entry.preferredPath,
268 approval: entry.approval,
269 serializedToolJson: entry.serializedToolJson,
270 }));
271 },
272});
273
274export const searchTools = internalQuery({
275 args: {
276 workspaceId: v.id("workspaces"),
277 buildId: v.string(),
278 query: v.string(),
279 limit: v.number(),
280 },
281 handler: async (ctx, args) => {
282 const term = args.query.trim();
283 if (!term) return [];
284
285 const limit = Math.max(1, Math.min(50, Math.floor(args.limit)));
286 const hits = await ctx.db
287 .query("workspaceToolRegistry")
288 .withSearchIndex("search_text", (q) =>
289 q.search("searchText", term)
290 .eq("workspaceId", args.workspaceId)
291 .eq("buildId", args.buildId),
292 )
293 .take(limit);
294
295 return hits.map((entry) => ({
296 path: entry.path,
297 preferredPath: entry.preferredPath,
298 aliases: entry.aliases,
299 description: entry.description,
300 approval: entry.approval,
301 source: entry.source,
302 displayInput: entry.displayInput,
303 displayOutput: entry.displayOutput,
304 requiredInputKeys: entry.requiredInputKeys,
305 previewInputKeys: entry.previewInputKeys,
306 }));
307 },
308});
309
310export const listNamespaces = internalQuery({
311 args: {
312 workspaceId: v.id("workspaces"),
313 buildId: v.string(),
314 limit: v.number(),
315 },
316 handler: async (ctx, args) => {
317 const limit = Math.max(1, Math.min(200, Math.floor(args.limit)));
318 const entries = await ctx.db
319 .query("workspaceToolNamespaces")
320 .withIndex("by_workspace_build", (q) =>
321 q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId),
322 )
323 .take(limit);
324
325 return entries.map((entry) => ({
326 namespace: entry.namespace,
327 toolCount: entry.toolCount,
328 samplePaths: entry.samplePaths,
329 }));
330 },
331});
executor/packages/convex/workspace.ts+25−16
@@ -3,6 +3,7 @@ import { internal } from "./_generated/api"; · 2 unmodified lines
3import { mutation, query } from "./_generated/server";3import { mutation, query } from "./_generated/server";
4import { workspaceMutation, workspaceQuery } from "../core/src/function-builders";4import { workspaceMutation, workspaceQuery } from "../core/src/function-builders";
5import { isAnonymousIdentity } from "./auth/anonymous";5import { isAnonymousIdentity } from "./auth/anonymous";
6import { safeRunAfter } from "./lib/scheduler";
6 7
7const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));8const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
8const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));9const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
@@ -17,25 +18,33 @@ function redactCredential<T extends { secretJson: Record<string, unknown> }>(cre · 8 unmodified lines
17}18}
18 19
19export const bootstrapAnonymousSession = mutation({20export const bootstrapAnonymousSession = mutation({
20 args: { sessionId: v.optional(v.string()) },21 args: {
21 handler: async (ctx) => {22 sessionId: v.optional(v.string()),
22 const identity = await ctx.auth.getUserIdentity();23 actorId: v.optional(v.string()),
23 if (!identity) {24 },
24 throw new Error("Anonymous auth token is required");25 handler: async (ctx, args) => {
25 }26 const identity = await ctx.auth.getUserIdentity().catch(() => null);
27 const requestedSessionId = args.sessionId?.trim() || undefined;
28 const requestedActorId = args.actorId?.trim() || undefined;
26 29
27 if (!isAnonymousIdentity(identity)) {30 if (identity && !isAnonymousIdentity(identity)) {
28 throw new Error("Anonymous auth token is required");31 throw new Error("Cannot bootstrap an anonymous session while authenticated");
29 }32 }
30 33
31 const actorId = identity.subject;34 const actorId = identity ? identity.subject : requestedActorId;
32 if (!actorId.startsWith("anon_")) {35 if (actorId && !actorId.startsWith("anon_")) {
33 throw new Error("Anonymous token subject must use anon_* actor ids");36 throw new Error("actorId must be an anonymous actor (anon_*)");
34 }37 }
35 const stableSessionId = `anon_session_${actorId}`;38
39 // Allow unauthenticated callers to bootstrap a fresh anonymous session.
40 // The internal mutation will generate ids when not provided.
41 const sessionId = identity
42 ? (requestedSessionId ?? `anon_session_${actorId}`)
43 : requestedSessionId;
44
36 return await ctx.runMutation(internal.database.bootstrapAnonymousSession, {45 return await ctx.runMutation(internal.database.bootstrapAnonymousSession, {
37 sessionId: stableSessionId,46 ...(sessionId ? { sessionId } : {}),
38 actorId,47 ...(actorId ? { actorId } : {}),
39 clientId: "web",48 clientId: "web",
40 });49 });
41 },50 },
@@ -192,7 +201,7 @@ export const upsertToolSource = workspaceMutation({ · 150 unmodified lines
192 });201 });
193 202
194 try {203 try {
195 await ctx.scheduler.runAfter(0, internal.executorNode.listToolsWithWarningsInternal, {204 await safeRunAfter(ctx.scheduler, 0, internal.executorNode.listToolsWithWarningsInternal, {
196 workspaceId: ctx.workspaceId,205 workspaceId: ctx.workspaceId,
197 });206 });
198 } catch {207 } catch {
@@ -222,7 +231,7 @@ export const deleteToolSource = workspaceMutation({ · 23 unmodified lines
222 });231 });
223 232
224 try {233 try {
225 await ctx.scheduler.runAfter(0, internal.executorNode.listToolsWithWarningsInternal, {234 await safeRunAfter(ctx.scheduler, 0, internal.executorNode.listToolsWithWarningsInternal, {
226 workspaceId: ctx.workspaceId,235 workspaceId: ctx.workspaceId,
227 });236 });
228 } catch {237 } catch {
executor/packages/convex/workspaceToolCache.ts+13−9
@@ -21,7 +21,7 @@ export const getEntry = internalQuery({ · 20 unmodified lines
21 return {21 return {
22 isFresh: entry.signature === args.signature,22 isFresh: entry.signature === args.signature,
23 storageId: entry.storageId,23 storageId: entry.storageId,
24 dtsStorageIds: entry.dtsStorageIds,24 typesStorageId: entry.typesStorageId,
25 toolCount: entry.toolCount,25 toolCount: entry.toolCount,
26 sizeBytes: entry.sizeBytes,26 sizeBytes: entry.sizeBytes,
27 createdAt: entry.createdAt,27 createdAt: entry.createdAt,
@@ -38,10 +38,7 @@ export const putEntry = internalMutation({ · 10 unmodified lines
38 workspaceId: v.id("workspaces"),38 workspaceId: v.id("workspaces"),
39 signature: v.string(),39 signature: v.string(),
40 storageId: v.id("_storage"),40 storageId: v.id("_storage"),
41 dtsStorageIds: v.array(v.object({41 typesStorageId: v.optional(v.id("_storage")),
42 sourceKey: v.string(),
43 storageId: v.id("_storage"),
44 })),
45 toolCount: v.number(),42 toolCount: v.number(),
46 sizeBytes: v.number(),43 sizeBytes: v.number(),
47 },44 },
@@ -54,9 +51,16 @@ export const putEntry = internalMutation({ · 6 unmodified lines
54 if (existing) {51 if (existing) {
55 // Delete old main snapshot blob52 // Delete old main snapshot blob
56 await ctx.storage.delete(existing.storageId).catch(() => {});53 await ctx.storage.delete(existing.storageId).catch(() => {});
57 // Delete old .d.ts blobs54 const legacy = (existing as any).dtsStorageIds as Array<{ storageId?: string }> | undefined;
58 for (const entry of existing.dtsStorageIds) {55 if (Array.isArray(legacy)) {
59 await ctx.storage.delete(entry.storageId).catch(() => {});56 for (const entry of legacy) {
57 if (entry && typeof entry.storageId === "string") {
58 await ctx.storage.delete(entry.storageId as any).catch(() => {});
59 }
60 }
61 }
62 if (existing.typesStorageId) {
63 await ctx.storage.delete(existing.typesStorageId).catch(() => {});
60 }64 }
61 await ctx.db.delete(existing._id);65 await ctx.db.delete(existing._id);
62 }66 }
@@ -65,7 +69,7 @@ export const putEntry = internalMutation({ · 2 unmodified lines
65 workspaceId: args.workspaceId,69 workspaceId: args.workspaceId,
66 signature: args.signature,70 signature: args.signature,
67 storageId: args.storageId,71 storageId: args.storageId,
68 dtsStorageIds: args.dtsStorageIds,72 typesStorageId: args.typesStorageId,
69 toolCount: args.toolCount,73 toolCount: args.toolCount,
70 sizeBytes: args.sizeBytes,74 sizeBytes: args.sizeBytes,
71 createdAt: Date.now(),75 createdAt: Date.now(),
executor/packages/core/src/declaration-generation.ts+2−2
@@ -30,8 +30,8 @@ export function generateToolInventory(tools: ToolDescriptor[]): string { · 29 unmodified lines
30 ` ${namespaces.join(", ")}`,30 ` ${namespaces.join(", ")}`,
31 "",31 "",
32 "Prefer one broad lookup over many small ones: use `tools.catalog.namespaces({})` and `tools.catalog.tools({ namespace?, query?, compact: false, depth: 2, limit: 20 })` first.",32 "Prefer one broad lookup over many small ones: use `tools.catalog.namespaces({})` and `tools.catalog.tools({ namespace?, query?, compact: false, depth: 2, limit: 20 })` first.",
33 "Then use `tools.discover({ query, depth?, limit?, compact? })` when you need ranking. It returns `{ bestPath, results, schemas?, total }` (not an array).",33 "Then use `tools.discover({ query, depth?, limit?, compact? })` when you need ranking. It returns `{ bestPath, results, total }` (not an array).",
34 "Prefer `bestPath` when present, otherwise copy a `results[i].exampleCall` for invocation shape.",34 "Prefer `bestPath` when present; each result includes `{ path, input, output }` to help you form a correct call.",
35 "For migration/ETL tasks: discover once, then execute in small batches and return compact summaries (counts, IDs, top-N samples).",35 "For migration/ETL tasks: discover once, then execute in small batches and return compact summaries (counts, IDs, top-N samples).",
36 "Never shadow the global `tools` object (do NOT write `const tools = ...`).",36 "Never shadow the global `tools` object (do NOT write `const tools = ...`).",
37 "Then call tools directly using the returned path.",37 "Then call tools directly using the returned path.",
executor/packages/core/src/dts-loader.test.ts+0−64
@@ -1,64 +0,0 @@
1import { expect, test } from "bun:test";
2import { loadSourceDtsByUrl } from "./dts-loader";
3
4test("loadSourceDtsByUrl fetches declarations for each source key", async () => {
5 let requestCount = 0;
6 const server = Bun.serve({
7 port: 0,
8 fetch: () => {
9 requestCount += 1;
10 return new Response("export interface operations { ping: {} }", {
11 headers: { "content-type": "text/plain" },
12 });
13 },
14 });
15
16 try {
17 const url = `http://127.0.0.1:${server.port}/types.d.ts`;
18
19 const first = await loadSourceDtsByUrl({
20 "openapi:one": url,
21 "openapi:two": url,
22 });
23 const second = await loadSourceDtsByUrl({
24 "openapi:one": url,
25 });
26
27 expect(first["openapi:one"]).toContain("interface operations");
28 expect(first["openapi:two"]).toContain("interface operations");
29 expect(second["openapi:one"]).toContain("interface operations");
30 expect(requestCount).toBe(3);
31 } finally {
32 server.stop(true);
33 }
34});
35
36test("loadSourceDtsByUrl skips sources that fail to download", async () => {
37 const server = Bun.serve({
38 port: 0,
39 fetch: (request) => {
40 const url = new URL(request.url);
41 if (url.pathname === "/fail.d.ts") {
42 return new Response("nope", { status: 500 });
43 }
44 return new Response("export interface operations { pong: {} }", {
45 headers: { "content-type": "text/plain" },
46 });
47 },
48 });
49
50 try {
51 const okUrl = `http://127.0.0.1:${server.port}/ok.d.ts`;
52 const failUrl = `http://127.0.0.1:${server.port}/fail.d.ts`;
53
54 const loaded = await loadSourceDtsByUrl({
55 "openapi:ok": okUrl,
56 "openapi:fail": failUrl,
57 });
58
59 expect(loaded["openapi:ok"]).toContain("interface operations");
60 expect(loaded["openapi:fail"]).toBeUndefined();
61 } finally {
62 server.stop(true);
63 }
64});
executor/packages/core/src/dts-loader.ts+0−37
@@ -1,37 +0,0 @@
1async function fetchDtsText(url: string): Promise<string | null> {
2 try {
3 const response = await fetch(url);
4 if (!response.ok) {
5 console.warn(`[executor] failed to fetch source .d.ts from ${url}: HTTP ${response.status}`);
6 return null;
7 }
8 return await response.text();
9 } catch (error) {
10 console.warn(
11 `[executor] failed to fetch source .d.ts from ${url}: ${error instanceof Error ? error.message : String(error)}`,
12 );
13 return null;
14 }
15}
16
17export async function loadSourceDtsByUrl(
18 dtsUrls: Record<string, string>,
19): Promise<Record<string, string>> {
20 const entries = Object.entries(dtsUrls);
21 if (entries.length === 0) {
22 return {};
23 }
24
25 const results = await Promise.all(entries.map(async ([sourceKey, url]) => {
26 if (!url) return [sourceKey, null] as const;
27 return [sourceKey, await fetchDtsText(url)] as const;
28 }));
29
30 const sourceDtsBySource: Record<string, string> = {};
31 for (const [sourceKey, dts] of results) {
32 if (dts) {
33 sourceDtsBySource[sourceKey] = dts;
34 }
35 }
36 return sourceDtsBySource;
37}
executor/packages/core/src/mcp-server.ts+6−7
@@ -191,14 +191,14 @@ async function createMcpServer( · 190 unmodified lines
191): Promise<McpServer> {191): Promise<McpServer> {
192 const mcp = new McpServer(192 const mcp = new McpServer(
193 { name: "executor", version: "0.1.0" },193 { name: "executor", version: "0.1.0" },
194 { capabilities: { tools: {} } },194 { capabilities: { tools: {}, elicitation: { form: {} } } as any },
195 );195 );
196 const onApprovalPrompt = createMcpApprovalPrompt(mcp);196 const onApprovalPrompt = createMcpApprovalPrompt(mcp);
197 const registerTool = mcp.registerTool as (197 const registerTool = (mcp.registerTool as (
198 name: string,198 name: string,
199 config: { description: string; inputSchema: AnySchema },199 config: { description: string; inputSchema: AnySchema },
200 cb: ReturnType<typeof createRunCodeTool>,200 cb: ReturnType<typeof createRunCodeTool>,
201 ) => void;201 ) => void).bind(mcp);
202 202
203 registerTool(203 registerTool(
204 "run_code",204 "run_code",
@@ -241,8 +241,7 @@ export async function handleMcpRequest( · 36 unmodified lines
241 request: Request,241 request: Request,
242 context?: McpWorkspaceContext,242 context?: McpWorkspaceContext,
243): Promise<Response> {243): Promise<Response> {
244 const headers = new Headers(request.headers);244 // Preserve MCP session headers so the SDK can negotiate capabilities
245 headers.delete("mcp-session-id");245 // (elicitation/sampling) across requests.
246 const requestWithoutSession = new Request(request, { headers });246 return await handleStatelessMcpRequest(service, request, context);
247 return await handleStatelessMcpRequest(service, requestWithoutSession, context);
248}247}
executor/packages/core/src/mcp/server-approval.ts+26−1
@@ -111,6 +111,7 @@ export function waitForTerminalTask( · 110 unmodified lines
111 let loggedElicitationFallback = false;111 let loggedElicitationFallback = false;
112 const seenApprovalIds = new Set<string>();112 const seenApprovalIds = new Set<string>();
113 let unsubscribe: (() => void) | undefined;113 let unsubscribe: (() => void) | undefined;
114 let interval: ReturnType<typeof setInterval> | undefined;
114 115
115 const logElicitationFallback = (reason: string) => {116 const logElicitationFallback = (reason: string) => {
116 if (loggedElicitationFallback) return;117 if (loggedElicitationFallback) return;
@@ -122,12 +123,36 @@ export function waitForTerminalTask( · 5 unmodified lines
122 if (settled) return;123 if (settled) return;
123 settled = true;124 settled = true;
124 clearTimeout(timeout);125 clearTimeout(timeout);
126 if (interval) {
127 clearInterval(interval);
128 }
125 unsubscribe?.();129 unsubscribe?.();
126 resolve(await service.getTask(taskId, workspaceId));130 resolve(await service.getTask(taskId, workspaceId));
127 };131 };
128 132
129 const timeout = setTimeout(done, waitTimeoutMs);133 const timeout = setTimeout(done, waitTimeoutMs);
130 134
135 // Convex HTTP actions don't provide a real push subscription. To avoid hanging
136 // forever, we poll task state and pending approvals on an interval.
137 let polling = false;
138 const poll = async () => {
139 if (settled || polling) return;
140 polling = true;
141 try {
142 await maybeHandleApprovals();
143 const task = await service.getTask(taskId, workspaceId);
144 if (task && getTaskTerminalState(task.status)) {
145 await done();
146 }
147 } finally {
148 polling = false;
149 }
150 };
151
152 interval = setInterval(() => {
153 void poll().catch(() => {});
154 }, 750);
155
131 const maybeHandleApprovals = async () => {156 const maybeHandleApprovals = async () => {
132 if (!elicitationEnabled || !service.listPendingApprovals || !service.resolveApproval || !onApprovalPrompt || !approvalContext) {157 if (!elicitationEnabled || !service.listPendingApprovals || !service.resolveApproval || !onApprovalPrompt || !approvalContext) {
133 return;158 return;
@@ -184,7 +209,7 @@ export function waitForTerminalTask( · 50 unmodified lines
184 }209 }
185 });210 });
186 211
187 void maybeHandleApprovals().catch(() => {});212 void poll().catch(() => {});
188 213
189 void service.getTask(taskId, workspaceId).then((task) => {214 void service.getTask(taskId, workspaceId).then((task) => {
190 if (task && getTaskTerminalState(task.status)) {215 if (task && getTaskTerminalState(task.status)) {
executor/packages/core/src/openapi-cache.test.ts+0−1335
@@ -1,1335 +0,0 @@
1import { expect, test, describe } from "bun:test";
2import {
3 prepareOpenApiSpec,
4 buildOpenApiToolsFromPrepared,
5 type PreparedOpenApiSpec,
6} from "./tool-sources";
7
8/**
9 * Generate a synthetic OpenAPI spec with many operations, simulating a large
10 * real-world spec like Stripe, GitHub, or Cloudflare.
11 *
12 * Each operation gets a unique tag, operationId, parameters, request body,
13 * and response schema to produce meaningful type metadata.
14 */
15function makeLargeSpec(operationCount: number): Record<string, unknown> {
16 const paths: Record<string, unknown> = {};
17
18 for (let i = 0; i < operationCount; i++) {
19 const tag = `resource_${i}`;
20 const pathTemplate = `/api/v1/${tag}/{id}`;
21
22 paths[pathTemplate] = {
23 get: {
24 operationId: `get_${tag}`,
25 tags: [tag],
26 summary: `Get ${tag} by ID`,
27 parameters: [
28 {
29 name: "id",
30 in: "path",
31 required: true,
32 schema: { type: "string" },
33 },
34 {
35 name: "include",
36 in: "query",
37 required: false,
38 schema: { type: "string", enum: ["metadata", "related", "all"] },
39 },
40 ],
41 responses: {
42 "200": {
43 description: "Success",
44 content: {
45 "application/json": {
46 schema: {
47 type: "object",
48 properties: {
49 id: { type: "string" },
50 name: { type: "string" },
51 created_at: { type: "string" },
52 status: {
53 type: "string",
54 enum: ["active", "inactive", "archived"],
55 },
56 metadata: {
57 type: "object",
58 properties: {
59 key: { type: "string" },
60 value: { type: "string" },
61 },
62 },
63 },
64 required: ["id", "name"],
65 },
66 },
67 },
68 },
69 },
70 },
71 post: {
72 operationId: `create_${tag}`,
73 tags: [tag],
74 summary: `Create ${tag}`,
75 requestBody: {
76 required: true,
77 content: {
78 "application/json": {
79 schema: {
80 type: "object",
81 properties: {
82 name: { type: "string" },
83 description: { type: "string" },
84 config: {
85 type: "object",
86 properties: {
87 enabled: { type: "boolean" },
88 timeout: { type: "number" },
89 tags: {
90 type: "array",
91 items: { type: "string" },
92 },
93 },
94 },
95 },
96 required: ["name"],
97 },
98 },
99 },
100 },
101 responses: {
102 "201": {
103 description: "Created",
104 content: {
105 "application/json": {
106 schema: {
107 type: "object",
108 properties: {
109 id: { type: "string" },
110 name: { type: "string" },
111 },
112 },
113 },
114 },
115 },
116 },
117 },
118 delete: {
119 operationId: `delete_${tag}`,
120 tags: [tag],
121 summary: `Delete ${tag}`,
122 parameters: [
123 {
124 name: "id",
125 in: "path",
126 required: true,
127 schema: { type: "string" },
128 },
129 ],
130 responses: {
131 "204": { description: "Deleted" },
132 },
133 },
134 };
135 }
136
137 return {
138 openapi: "3.0.3",
139 info: { title: "Large Test API", version: "1.0.0" },
140 servers: [{ url: "https://api.example.com/v1" }],
141 paths,
142 };
143}
144
145describe("prepareOpenApiSpec with large specs", () => {
146 test("handles spec with 100 resources (300 operations) without truncation", async () => {
147 const spec = makeLargeSpec(100);
148 const prepared = await prepareOpenApiSpec(spec, "large-test");
149
150 // Should have all operations
151 const pathCount = Object.keys(prepared.paths).length;
152 expect(pathCount).toBe(100);
153
154 // Should not have any size-related warnings
155 const sizeWarnings = prepared.warnings.filter(
156 (w) =>
157 w.includes("cache size") ||
158 w.includes("too large") ||
159 w.includes("metadata omitted"),
160 );
161 expect(sizeWarnings).toHaveLength(0);
162
163 // Verify the full prepared spec serializes to well over the old 900KB limit
164 // to confirm we're not artificially truncating
165 const json = JSON.stringify(prepared);
166 // 100 resources × 3 ops each × ~2KB per op = ~600KB minimum
167 // With type metadata it should be even larger
168 expect(json.length).toBeGreaterThan(100_000);
169 });
170
171 test("very large specs still generate d.ts and typed tools", async () => {
172 const spec = makeLargeSpec(250); // 750 operations
173 const start = performance.now();
174 const prepared = await prepareOpenApiSpec(spec, "large-full");
175 const elapsed = performance.now() - start;
176
177 expect(Object.keys(prepared.paths)).toHaveLength(250);
178 expect(prepared.dts).toBeDefined();
179 expect(prepared.warnings.some((warning: string) => warning.includes("skipped bundle"))).toBe(false);
180
181 const tools = buildOpenApiToolsFromPrepared(
182 {
183 type: "openapi",
184 name: "large-full",
185 spec,
186 baseUrl: "https://api.example.com/v1",
187 },
188 prepared,
189 );
190
191 expect(tools.length).toBeGreaterThan(700);
192 const deleteTool = tools.find((tool) => tool.metadata?.operationId?.startsWith("delete_"));
193 expect(deleteTool).toBeDefined();
194 expect(deleteTool?.metadata?.displayReturnsType).toBeDefined();
195 expect(deleteTool?.metadata?.displayReturnsType).not.toContain("ToolOutput<operations[");
196 expect(elapsed).toBeLessThan(20_000);
197 });
198
199 test("inventory profile skips d.ts but preserves typed tool hints", async () => {
200 const spec = makeLargeSpec(3);
201 const prepared = await prepareOpenApiSpec(spec, "inventory-typed", { includeDts: false });
202
203 expect(prepared.dts).toBeUndefined();
204 expect(prepared.dtsStatus).toBe("skipped");
205
206 const tools = buildOpenApiToolsFromPrepared(
207 {
208 type: "openapi",
209 name: "inventory-typed",
210 spec,
211 baseUrl: "https://api.example.com/v1",
212 },
213 prepared,
214 );
215
216 expect(tools).toHaveLength(9);
217 for (const tool of tools) {
218 expect(tool.metadata?.returnsType).not.toBe("unknown");
219 expect(tool.metadata?.argsType).not.toBe("Record<string, unknown>");
220 }
221 });
222
223
224});
225
226describe("buildOpenApiToolsFromPrepared", () => {
227 test("builds tools from a prepared spec with full type metadata", async () => {
228 const spec = makeLargeSpec(5);
229 const prepared = await prepareOpenApiSpec(spec, "build-test");
230
231 const tools = buildOpenApiToolsFromPrepared(
232 {
233 type: "openapi",
234 name: "test-api",
235 spec,
236 baseUrl: "https://api.example.com/v1",
237 },
238 prepared,
239 );
240
241 // 5 resources × 3 operations each = 15 tools
242 expect(tools).toHaveLength(15);
243
244 // Each tool should have metadata
245 for (const tool of tools) {
246 expect(tool.metadata).toBeDefined();
247 expect(tool.metadata!.argsType).toBeDefined();
248 expect(tool.metadata!.returnsType).toBeDefined();
249 expect(tool.metadata!.displayArgsType).toBeDefined();
250 expect(tool.metadata!.displayReturnsType).toBeDefined();
251 }
252
253 // GET operations should default to "auto" approval
254 const getTools = tools.filter((t) =>
255 (t.metadata?.operationId ?? "").startsWith("get_"),
256 );
257 for (const tool of getTools) {
258 expect(tool.approval).toBe("auto");
259 }
260
261 // POST/DELETE operations should default to "required" approval
262 const writeTools = tools.filter((t) => {
263 const operationId = t.metadata?.operationId ?? "";
264 return operationId.startsWith("create_") || operationId.startsWith("delete_");
265 });
266 for (const tool of writeTools) {
267 expect(tool.approval).toBe("required");
268 }
269 });
270
271 test("uses compact preview args for large nested request bodies", async () => {
272 const spec = {
273 openapi: "3.0.3",
274 info: { title: "Notion-like", version: "1.0.0" },
275 servers: [{ url: "https://api.notion.example" }],
276 paths: {
277 "/databases": {
278 post: {
279 operationId: "createDatabase",
280 tags: ["databases"],
281 summary: "Create a database",
282 requestBody: {
283 required: true,
284 content: {
285 "application/json": {
286 schema: {
287 type: "object",
288 properties: {
289 parent: {
290 type: "object",
291 properties: {
292 page_id: { type: "string" },
293 },
294 },
295 title: {
296 type: "array",
297 items: {
298 type: "object",
299 properties: {
300 text: {
301 type: "object",
302 properties: {
303 content: { type: "string" },
304 },
305 },
306 },
307 },
308 },
309 properties: {
310 type: "object",
311 additionalProperties: true,
312 },
313 icon: {
314 type: "object",
315 properties: { emoji: { type: "string" } },
316 },
317 cover: {
318 type: "object",
319 properties: {
320 external: {
321 type: "object",
322 properties: { url: { type: "string" } },
323 },
324 },
325 },
326 },
327 required: ["parent", "title", "properties"],
328 },
329 },
330 },
331 },
332 responses: {
333 "200": {
334 description: "ok",
335 content: {
336 "application/json": {
337 schema: {
338 type: "object",
339 properties: {
340 id: { type: "string" },
341 },
342 },
343 },
344 },
345 },
346 },
347 },
348 },
349 },
350 };
351
352 const prepared = await prepareOpenApiSpec(spec, "notion-like");
353 const tools = buildOpenApiToolsFromPrepared(
354 { type: "openapi", name: "notion", spec, baseUrl: "https://api.notion.example" },
355 prepared,
356 );
357
358 const createDbTool = tools.find((tool) => tool.metadata?.operationId === "createDatabase");
359 expect(createDbTool).toBeDefined();
360 expect(createDbTool?.metadata?.argPreviewKeys).toEqual([
361 "parent",
362 "title",
363 "properties",
364 "icon",
365 "cover",
366 ]);
367 expect(createDbTool?.metadata?.displayArgsType).toBe(
368 "{ parent: ...; title: ...; properties: ...; icon: ...; cover: ... }",
369 );
370 });
371
372 test("includes anyOf body keys in preview args and input hint", async () => {
373 const spec: Record<string, unknown> = {
374 openapi: "3.0.3",
375 info: { title: "DNS", version: "1.0.0" },
376 servers: [{ url: "https://api.example.com" }],
377 paths: {
378 "/domains/{domain}/records": {
379 post: {
380 operationId: "createRecord",
381 tags: ["dns"],
382 summary: "Create record",
383 parameters: [
384 {
385 name: "domain",
386 in: "path",
387 required: true,
388 schema: { type: "string" },
389 },
390 ],
391 requestBody: {
392 required: true,
393 content: {
394 "application/json": {
395 schema: {
396 required: ["type"],
397 properties: {
398 type: {
399 type: "string",
400 enum: ["A", "CNAME"],
401 },
402 },
403 anyOf: [
404 {
405 type: "object",
406 required: ["type", "name", "value"],
407 properties: {
408 name: { type: "string" },
409 type: { type: "string", enum: ["A"] },
410 value: { type: "string" },
411 },
412 },
413 {
414 type: "object",
415 required: ["type", "name", "value"],
416 properties: {
417 name: { type: "string" },
418 type: { type: "string", enum: ["CNAME"] },
419 value: { type: "string" },
420 },
421 },
422 ],
423 },
424 },
425 },
426 },
427 responses: {
428 "200": {
429 description: "ok",
430 content: {
431 "application/json": {
432 schema: {
433 type: "object",
434 properties: { id: { type: "string" } },
435 },
436 },
437 },
438 },
439 },
440 },
441 },
442 },
443 };
444
445 const prepared = await prepareOpenApiSpec(spec, "dns");
446 const tools = buildOpenApiToolsFromPrepared(
447 {
448 type: "openapi",
449 name: "vercel",
450 spec,
451 baseUrl: "https://api.example.com",
452 },
453 prepared,
454 );
455
456 const tool = tools.find((candidate) => candidate.metadata?.operationId === "createRecord");
457 expect(tool).toBeDefined();
458 expect(tool?.metadata?.argPreviewKeys).toEqual(["domain", "type", "name", "value"]);
459 expect(tool?.metadata?.displayArgsType).toContain("name: string");
460 expect(tool?.metadata?.displayArgsType).toContain("value: string");
461 expect(tool?.metadata?.argsType).toContain("name");
462 expect(tool?.metadata?.argsType).toContain("value");
463 });
464
465 test("keeps concrete primitive type for compact single-arg hints", async () => {
466 const spec: Record<string, unknown> = {
467 openapi: "3.0.3",
468 info: { title: "Threads", version: "1.0.0" },
469 servers: [{ url: "https://api.example.com" }],
470 paths: {
471 "/threads/{thread_id}/subscription": {
472 delete: {
473 operationId: "deleteThreadSubscription",
474 tags: ["activity"],
475 summary: "Delete a thread subscription",
476 parameters: [
477 {
478 name: "thread_id",
479 in: "path",
480 required: true,
481 schema: { type: "string" },
482 },
483 ],
484 responses: {
485 "204": {
486 description: "deleted",
487 },
488 },
489 },
490 },
491 },
492 };
493
494 const prepared = await prepareOpenApiSpec(spec, "threads");
495 const tools = buildOpenApiToolsFromPrepared(
496 {
497 type: "openapi",
498 name: "github",
499 spec,
500 baseUrl: "https://api.example.com",
501 },
502 prepared,
503 );
504
505 const tool = tools.find((candidate) => candidate.metadata?.operationId === "deleteThreadSubscription");
506 expect(tool).toBeDefined();
507 expect(tool?.metadata?.displayArgsType).toBe("{ thread_id: string }");
508 expect(tool?.metadata?.returnsType).toBe("void");
509 });
510
511 test("infers workspace auth from OpenAPI securitySchemes when source auth is unset", async () => {
512 const spec: Record<string, unknown> = {
513 openapi: "3.0.3",
514 info: { title: "Auth inferred", version: "1.0.0" },
515 servers: [{ url: "https://api.example.com" }],
516 security: [{ bearerAuth: [] }],
517 components: {
518 securitySchemes: {
519 bearerAuth: {
520 type: "http",
521 scheme: "bearer",
522 },
523 },
524 },
525 paths: {
526 "/me": {
527 get: {
528 operationId: "getMe",
529 tags: ["users"],
530 responses: {
531 "200": {
532 description: "ok",
533 content: {
534 "application/json": {
535 schema: {
536 type: "object",
537 properties: { id: { type: "string" } },
538 },
539 },
540 },
541 },
542 },
543 },
544 },
545 },
546 };
547
548 const prepared = await prepareOpenApiSpec(spec, "auth-inferred");
549 expect(prepared.inferredAuth).toEqual({ type: "bearer", mode: "workspace" });
550
551 const tools = buildOpenApiToolsFromPrepared(
552 {
553 type: "openapi",
554 name: "auth-source",
555 sourceKey: "source:test-source-id",
556 spec,
557 baseUrl: "https://api.example.com",
558 },
559 prepared,
560 );
561
562 const meTool = tools.find((tool) => tool.metadata?.operationId === "getMe");
563 expect(meTool?.credential).toEqual({
564 sourceKey: "source:test-source-id",
565 mode: "workspace",
566 authType: "bearer",
567 });
568 });
569
570 test("resolves shared parameter refs and maps 204 responses to void", async () => {
571 const spec: Record<string, unknown> = {
572 openapi: "3.0.3",
573 info: { title: "GitHub-like", version: "1.0.0" },
574 servers: [{ url: "https://api.example.com" }],
575 components: {
576 parameters: {
577 owner: {
578 name: "owner",
579 in: "path",
580 required: true,
581 schema: { type: "string" },
582 },
583 repo: {
584 name: "repo",
585 in: "path",
586 required: true,
587 schema: { type: "string" },
588 },
589 },
590 },
591 paths: {
592 "/repos/{owner}/{repo}/subscription": {
593 parameters: [
594 { $ref: "#/components/parameters/owner" },
595 { $ref: "#/components/parameters/repo" },
596 ],
597 delete: {
598 operationId: "activity/delete-repo-subscription",
599 tags: ["activity"],
600 responses: {
601 "204": { description: "No Content" },
602 },
603 },
604 },
605 },
606 };
607
608 const prepared = await prepareOpenApiSpec(spec, "github-like");
609 const tools = buildOpenApiToolsFromPrepared(
610 {
611 type: "openapi",
612 name: "github",
613 spec,
614 baseUrl: "https://api.example.com",
615 },
616 prepared,
617 );
618
619 const tool = tools.find((t) => t.metadata?.operationId === "activity/delete-repo-subscription");
620 expect(tool).toBeDefined();
621 expect(tool!.metadata?.argsType).toContain("owner");
622 expect(tool!.metadata?.argsType).toContain("repo");
623 expect(tool!.metadata?.returnsType).toBe("void");
624 });
625
626 test("supports Swagger 2.x parameter and response schemas for type hints", async () => {
627 const spec: Record<string, unknown> = {
628 swagger: "2.0",
629 info: { title: "Slack-like", version: "1.0.0" },
630 host: "api.example.com",
631 schemes: ["https"],
632 basePath: "/api",
633 paths: {
634 "/admin.apps.approved.list": {
635 get: {
636 operationId: "admin_apps_approved_list",
637 tags: ["admin.apps.approved"],
638 parameters: [
639 { name: "token", in: "query", required: true, type: "string" },
640 { name: "limit", in: "query", required: false, type: "integer" },
641 ],
642 responses: {
643 "200": {
644 description: "ok",
645 schema: {
646 type: "object",
647 properties: {
648 ok: { type: "boolean" },
649 },
650 required: ["ok"],
651 },
652 },
653 },
654 },
655 },
656 },
657 };
658
659 const prepared = await prepareOpenApiSpec(spec, "slack-like");
660 const tools = buildOpenApiToolsFromPrepared(
661 {
662 type: "openapi",
663 name: "slack",
664 spec,
665 baseUrl: "https://api.example.com/api",
666 },
667 prepared,
668 );
669
670 const tool = tools.find((t) => t.metadata?.operationId === "admin_apps_approved_list");
671 expect(tool).toBeDefined();
672 expect(tool!.metadata?.argsType).toContain("token: string");
673 expect(tool!.metadata?.argsType).toContain("limit?: number");
674 expect(tool!.metadata?.returnsType).toContain("ok");
675 expect(tool!.metadata?.returnsType).not.toBe("unknown");
676 });
677
678 test("quotes non-identifier parameter names in args type hints", async () => {
679 const spec: Record<string, unknown> = {
680 openapi: "3.0.3",
681 info: { title: "Header names", version: "1.0.0" },
682 servers: [{ url: "https://api.example.com" }],
683 paths: {
684 "/meta": {
685 get: {
686 operationId: "meta/get",
687 tags: ["meta"],
688 parameters: [
689 {
690 name: "X-GitHub-Api-Version",
691 in: "header",
692 required: false,
693 schema: { type: "string" },
694 },
695 ],
696 responses: {
697 "200": {
698 description: "ok",
699 content: {
700 "application/json": {
701 schema: {
702 type: "object",
703 properties: {
704 ver: { type: "string" },
705 },
706 },
707 },
708 },
709 },
710 },
711 },
712 },
713 },
714 };
715
716 const prepared = await prepareOpenApiSpec(spec, "headers");
717 const tools = buildOpenApiToolsFromPrepared(
718 {
719 type: "openapi",
720 name: "github",
721 spec,
722 baseUrl: "https://api.example.com",
723 },
724 prepared,
725 );
726
727 const tool = tools.find((t) => t.metadata?.operationId === "meta/get");
728 expect(tool).toBeDefined();
729 expect(tool!.metadata?.argsType).toContain('"X-GitHub-Api-Version"?: string');
730 });
731
732 test("resolves OpenAPI component schema refs for return type hints", async () => {
733 const spec: Record<string, unknown> = {
734 openapi: "3.0.3",
735 info: { title: "Ref return type", version: "1.0.0" },
736 servers: [{ url: "https://api.example.com" }],
737 components: {
738 schemas: {
739 Runner: {
740 type: "object",
741 properties: {
742 id: { type: "number" },
743 name: { type: "string" },
744 },
745 required: ["id", "name"],
746 },
747 },
748 },
749 paths: {
750 "/orgs/{org}/actions/hosted-runners": {
751 post: {
752 operationId: "actions/create-hosted-runner-for-org",
753 tags: ["actions"],
754 parameters: [
755 { name: "org", in: "path", required: true, schema: { type: "string" } },
756 ],
757 requestBody: {
758 required: true,
759 content: {
760 "application/json": {
761 schema: {
762 type: "object",
763 properties: {
764 name: { type: "string" },
765 },
766 required: ["name"],
767 },
768 },
769 },
770 },
771 responses: {
772 "201": {
773 description: "created",
774 content: {
775 "application/json": {
776 schema: { $ref: "#/components/schemas/Runner" },
777 },
778 },
779 },
780 },
781 },
782 },
783 },
784 };
785
786 const prepared = await prepareOpenApiSpec(spec, "ref-return");
787 const tools = buildOpenApiToolsFromPrepared(
788 {
789 type: "openapi",
790 name: "github",
791 spec,
792 baseUrl: "https://api.example.com",
793 },
794 prepared,
795 );
796
797 const tool = tools.find((t) => t.metadata?.operationId === "actions/create-hosted-runner-for-org");
798 expect(tool).toBeDefined();
799 expect(tool!.metadata?.returnsType).toContain("id");
800 expect(tool!.metadata?.returnsType).toContain("name");
801 expect(tool!.metadata?.returnsType).not.toBe("unknown");
802 });
803
804 test("resolves OpenAPI component response refs for return type hints", async () => {
805 const spec: Record<string, unknown> = {
806 openapi: "3.0.3",
807 info: { title: "Response ref type", version: "1.0.0" },
808 servers: [{ url: "https://api.example.com" }],
809 components: {
810 schemas: {
811 BudgetList: {
812 type: "object",
813 properties: {
814 total: { type: "number" },
815 },
816 required: ["total"],
817 },
818 },
819 responses: {
820 BudgetResponse: {
821 description: "ok",
822 content: {
823 "application/json": {
824 schema: { $ref: "#/components/schemas/BudgetList" },
825 },
826 },
827 },
828 },
829 },
830 paths: {
831 "/organizations/{org}/settings/billing/budgets": {
832 get: {
833 operationId: "billing/get-all-budgets-org",
834 tags: ["billing"],
835 parameters: [
836 { name: "org", in: "path", required: true, schema: { type: "string" } },
837 ],
838 responses: {
839 "200": { $ref: "#/components/responses/BudgetResponse" },
840 },
841 },
842 },
843 },
844 };
845
846 const prepared = await prepareOpenApiSpec(spec, "response-ref");
847 const tools = buildOpenApiToolsFromPrepared(
848 {
849 type: "openapi",
850 name: "github",
851 spec,
852 baseUrl: "https://api.example.com",
853 },
854 prepared,
855 );
856
857 const tool = tools.find((t) => t.metadata?.operationId === "billing/get-all-budgets-org");
858 expect(tool).toBeDefined();
859 expect(tool!.metadata?.returnsType).toContain("total");
860 expect(tool!.metadata?.returnsType).not.toBe("unknown");
861 });
862
863 test("resolves nested schema refs inside array items", async () => {
864 const spec: Record<string, unknown> = {
865 openapi: "3.0.3",
866 info: { title: "Nested refs", version: "1.0.0" },
867 servers: [{ url: "https://api.example.com" }],
868 components: {
869 schemas: {
870 Budget: {
871 type: "object",
872 properties: {
873 id: { type: "string" },
874 },
875 required: ["id"],
876 },
877 BudgetList: {
878 type: "object",
879 properties: {
880 budgets: {
881 type: "array",
882 items: { $ref: "#/components/schemas/Budget" },
883 },
884 },
885 },
886 },
887 },
888 paths: {
889 "/organizations/{org}/settings/billing/budgets": {
890 get: {
891 operationId: "billing/get-all-budgets-org",
892 tags: ["billing"],
893 parameters: [
894 { name: "org", in: "path", required: true, schema: { type: "string" } },
895 ],
896 responses: {
897 "200": {
898 description: "ok",
899 content: {
900 "application/json": {
901 schema: { $ref: "#/components/schemas/BudgetList" },
902 },
903 },
904 },
905 },
906 },
907 },
908 },
909 };
910
911 const prepared = await prepareOpenApiSpec(spec, "nested-refs");
912 const tools = buildOpenApiToolsFromPrepared(
913 {
914 type: "openapi",
915 name: "github",
916 spec,
917 baseUrl: "https://api.example.com",
918 },
919 prepared,
920 );
921
922 const tool = tools.find((t) => t.metadata?.operationId === "billing/get-all-budgets-org");
923 expect(tool).toBeDefined();
924 expect(tool!.metadata?.returnsType).toContain("budgets");
925 expect(tool!.metadata?.returnsType).toContain('components["schemas"]["Budget"][]');
926 expect(tool!.metadata?.returnsType).not.toContain("unknown[]");
927 });
928
929 test("resolves deep ref chains used by nested response objects", async () => {
930 const spec: Record<string, unknown> = {
931 openapi: "3.0.3",
932 info: { title: "Deep refs", version: "1.0.0" },
933 servers: [{ url: "https://api.example.com" }],
934 components: {
935 schemas: {
936 Root: {
937 type: "object",
938 properties: {
939 result: {
940 type: "array",
941 items: { $ref: "#/components/schemas/A" },
942 },
943 },
944 },
945 A: {
946 type: "object",
947 properties: {
948 value: { $ref: "#/components/schemas/B" },
949 },
950 },
951 B: {
952 type: "object",
953 properties: {
954 value: { $ref: "#/components/schemas/C" },
955 },
956 },
957 C: {
958 type: "object",
959 properties: {
960 value: { type: "string" },
961 },
962 },
963 },
964 },
965 paths: {
966 "/deep": {
967 get: {
968 operationId: "deep/get",
969 tags: ["deep"],
970 responses: {
971 "200": {
972 description: "ok",
973 content: {
974 "application/json": {
975 schema: { $ref: "#/components/schemas/Root" },
976 },
977 },
978 },
979 },
980 },
981 },
982 },
983 };
984
985 const prepared = await prepareOpenApiSpec(spec, "deep-refs");
986 const tools = buildOpenApiToolsFromPrepared(
987 {
988 type: "openapi",
989 name: "deep",
990 spec,
991 baseUrl: "https://api.example.com",
992 },
993 prepared,
994 );
995
996 const tool = tools.find((t) => t.metadata?.operationId === "deep/get");
997 expect(tool).toBeDefined();
998 expect(tool!.metadata?.returnsType).toContain('components["schemas"]["A"][]');
999 expect(tool!.metadata?.returnsType).not.toContain("unknown");
1000 });
1001
1002 test("handles recursive schema refs without blowing up", async () => {
1003 const spec: Record<string, unknown> = {
1004 openapi: "3.0.3",
1005 info: { title: "Recursive refs", version: "1.0.0" },
1006 servers: [{ url: "https://api.example.com" }],
1007 components: {
1008 schemas: {
1009 Node: {
1010 type: "object",
1011 properties: {
1012 id: { type: "string" },
1013 child: { $ref: "#/components/schemas/Node" },
1014 },
1015 required: ["id"],
1016 },
1017 },
1018 },
1019 paths: {
1020 "/nodes": {
1021 get: {
1022 operationId: "nodes/get",
1023 tags: ["nodes"],
1024 responses: {
1025 "200": {
1026 description: "ok",
1027 content: {
1028 "application/json": {
1029 schema: { $ref: "#/components/schemas/Node" },
1030 },
1031 },
1032 },
1033 },
1034 },
1035 },
1036 },
1037 };
1038
1039 const prepared = await prepareOpenApiSpec(spec, "recursive");
1040 const tools = buildOpenApiToolsFromPrepared(
1041 {
1042 type: "openapi",
1043 name: "recursive",
1044 spec,
1045 baseUrl: "https://api.example.com",
1046 },
1047 prepared,
1048 );
1049
1050 const tool = tools.find((t) => t.metadata?.operationId === "nodes/get");
1051 expect(tool).toBeDefined();
1052 expect(tool!.metadata?.returnsType).toContain("id: string");
1053 expect(tool!.metadata?.returnsType).toContain("child");
1054 });
1055
1056 test("supports allOf-composed response schemas", async () => {
1057 const spec: Record<string, unknown> = {
1058 openapi: "3.0.3",
1059 info: { title: "AllOf response", version: "1.0.0" },
1060 servers: [{ url: "https://api.example.com" }],
1061 components: {
1062 schemas: {
1063 Envelope: {
1064 type: "object",
1065 properties: {
1066 success: { type: "boolean" },
1067 },
1068 required: ["success"],
1069 },
1070 Account: {
1071 type: "object",
1072 properties: {
1073 id: { type: "string" },
1074 },
1075 required: ["id"],
1076 },
1077 AccountResponse: {
1078 allOf: [
1079 { $ref: "#/components/schemas/Envelope" },
1080 {
1081 type: "object",
1082 properties: {
1083 result: { $ref: "#/components/schemas/Account" },
1084 },
1085 },
1086 ],
1087 },
1088 },
1089 },
1090 paths: {
1091 "/accounts/{id}": {
1092 get: {
1093 operationId: "accounts/get",
1094 tags: ["accounts"],
1095 parameters: [
1096 { name: "id", in: "path", required: true, schema: { type: "string" } },
1097 ],
1098 responses: {
1099 "200": {
1100 description: "ok",
1101 content: {
1102 "application/json": {
1103 schema: { $ref: "#/components/schemas/AccountResponse" },
1104 },
1105 },
1106 },
1107 },
1108 },
1109 },
1110 },
1111 };
1112
1113 const prepared = await prepareOpenApiSpec(spec, "allof");
1114 const tools = buildOpenApiToolsFromPrepared(
1115 {
1116 type: "openapi",
1117 name: "cf",
1118 spec,
1119 baseUrl: "https://api.example.com",
1120 },
1121 prepared,
1122 );
1123
1124 const tool = tools.find((t) => t.metadata?.operationId === "accounts/get");
1125 expect(tool).toBeDefined();
1126 expect(tool!.metadata?.returnsType).toContain("success");
1127 expect(tool!.metadata?.returnsType).toContain("result");
1128 expect(tool!.metadata?.returnsType).not.toBe("unknown");
1129 });
1130
1131 test("supports schemas that encode unions via top-level items arrays", async () => {
1132 const spec: Record<string, unknown> = {
1133 swagger: "2.0",
1134 info: { title: "Tuple-style union", version: "1.0.0" },
1135 host: "api.example.com",
1136 schemes: ["https"],
1137 paths: {
1138 "/users.identity": {
1139 get: {
1140 operationId: "users_identity",
1141 tags: ["users"],
1142 responses: {
1143 "200": {
1144 description: "ok",
1145 schema: {
1146 items: [
1147 {
1148 type: "object",
1149 properties: {
1150 ok: { type: "boolean" },
1151 user: {
1152 type: "object",
1153 properties: {
1154 id: { type: "string" },
1155 },
1156 },
1157 },
1158 },
1159 {
1160 type: "object",
1161 properties: {
1162 ok: { type: "boolean" },
1163 user: {
1164 type: "object",
1165 properties: {
1166 id: { type: "string" },
1167 email: { type: "string" },
1168 },
1169 },
1170 },
1171 },
1172 ],
1173 },
1174 },
1175 },
1176 },
1177 },
1178 },
1179 };
1180
1181 const prepared = await prepareOpenApiSpec(spec, "tuple-union");
1182 const tools = buildOpenApiToolsFromPrepared(
1183 {
1184 type: "openapi",
1185 name: "slack",
1186 spec,
1187 baseUrl: "https://api.example.com",
1188 },
1189 prepared,
1190 );
1191
1192 const tool = tools.find((t) => t.metadata?.operationId === "users_identity");
1193 expect(tool).toBeDefined();
1194 expect(tool!.metadata?.returnsType).toContain(" | ");
1195 expect(tool!.metadata?.returnsType).not.toBe("unknown");
1196 });
1197
1198
1199});
1200
1201describe("prepared spec serialization round-trip", () => {
1202 test("prepared spec survives JSON serialization (simulating cache store/load)", async () => {
1203 const spec = makeLargeSpec(20);
1204 const prepared = await prepareOpenApiSpec(spec, "roundtrip-test");
1205
1206 // Simulate what loadCachedOpenApiSpec does: serialize → store → load → deserialize
1207 const json = JSON.stringify(prepared);
1208 const restored = JSON.parse(json) as PreparedOpenApiSpec;
1209
1210 expect(restored.servers).toEqual(prepared.servers);
1211 expect(Object.keys(restored.paths).length).toBe(
1212 Object.keys(prepared.paths).length,
1213 );
1214 expect(restored.warnings).toEqual(prepared.warnings);
1215
1216 // Verify tools built from the restored spec match the original
1217 const config = {
1218 type: "openapi" as const,
1219 name: "roundtrip",
1220 spec,
1221 baseUrl: "https://api.example.com/v1",
1222 };
1223 const originalTools = buildOpenApiToolsFromPrepared(config, prepared);
1224 const restoredTools = buildOpenApiToolsFromPrepared(config, restored);
1225
1226 expect(restoredTools.length).toBe(originalTools.length);
1227 for (let i = 0; i < originalTools.length; i++) {
1228 expect(restoredTools[i]!.path).toBe(originalTools[i]!.path);
1229 expect(restoredTools[i]!.description).toBe(originalTools[i]!.description);
1230 expect(restoredTools[i]!.approval).toBe(originalTools[i]!.approval);
1231 expect(restoredTools[i]!.metadata?.argsType).toBe(
1232 originalTools[i]!.metadata?.argsType,
1233 );
1234 expect(restoredTools[i]!.metadata?.returnsType).toBe(
1235 originalTools[i]!.metadata?.returnsType,
1236 );
1237 }
1238 });
1239
1240 test("large spec serialized size exceeds old 900KB limit", async () => {
1241 // Generate a spec large enough to exceed the old ActionCache limit.
1242 // This verifies that the new approach handles what the old one couldn't.
1243 const spec = makeLargeSpec(200);
1244 const prepared = await prepareOpenApiSpec(spec, "size-test");
1245 const json = JSON.stringify(prepared);
1246
1247 // The old limit was 900_000 bytes. With 200 resources (600 operations),
1248 // the prepared spec with full type metadata should exceed that easily.
1249 console.log(
1250 `Large spec size: ${(json.length / 1024).toFixed(0)}KB (old limit: 900KB)`,
1251 );
1252
1253 // Even if it doesn't exceed 900KB with this synthetic spec (real APIs have
1254 // much more complex schemas), verify no warnings about size limits exist
1255 const sizeWarnings = prepared.warnings.filter(
1256 (w) => w.includes("cache size") || w.includes("too large"),
1257 );
1258 expect(sizeWarnings).toHaveLength(0);
1259 });
1260});
1261
1262describe("spec with $ref keys survives cache round-trip", () => {
1263 test("specs with $ref in schemas serialize cleanly as JSON", async () => {
1264 // The old ActionCache approach had issues with $ref keys in Convex values.
1265 // The new approach stores as a JSON blob, which handles $ref fine.
1266 const specWithRefs: Record<string, unknown> = {
1267 openapi: "3.0.3",
1268 info: { title: "Ref test", version: "1.0.0" },
1269 servers: [{ url: "https://api.example.com" }],
1270 components: {
1271 schemas: {
1272 Address: {
1273 type: "object",
1274 properties: {
1275 street: { type: "string" },
1276 city: { type: "string" },
1277 },
1278 },
1279 Person: {
1280 type: "object",
1281 properties: {
1282 name: { type: "string" },
1283 address: { $ref: "#/components/schemas/Address" },
1284 },
1285 },
1286 },
1287 },
1288 paths: {
1289 "/people": {
1290 get: {
1291 operationId: "listPeople",
1292 tags: ["people"],
1293 responses: {
1294 "200": {
1295 description: "ok",
1296 content: {
1297 "application/json": {
1298 schema: {
1299 type: "array",
1300 items: { $ref: "#/components/schemas/Person" },
1301 },
1302 },
1303 },
1304 },
1305 },
1306 },
1307 },
1308 },
1309 };
1310
1311 const prepared = await prepareOpenApiSpec(specWithRefs, "ref-test");
1312 const json = JSON.stringify(prepared);
1313
1314 // Should serialize without error
1315 expect(json.length).toBeGreaterThan(0);
1316
1317 // Should deserialize back
1318 const restored = JSON.parse(json) as PreparedOpenApiSpec;
1319 expect(Object.keys(restored.paths).length).toBe(1);
1320
1321 // Build tools from the restored spec
1322 const tools = buildOpenApiToolsFromPrepared(
1323 {
1324 type: "openapi",
1325 name: "ref-api",
1326 spec: specWithRefs,
1327 baseUrl: "https://api.example.com",
1328 },
1329 restored,
1330 );
1331
1332 expect(tools.length).toBe(1);
1333 expect(tools[0]!.path).toBe("ref_api.people.list_people");
1334 });
1335});
executor/packages/core/src/openapi-compaction.ts+25−34
@@ -103,6 +103,8 @@ export function compactOpenApiPaths( · 102 unmodified lines
103 let requestBodySchema: Record<string, unknown> = {};103 let requestBodySchema: Record<string, unknown> = {};
104 let responseSchema: Record<string, unknown> = {};104 let responseSchema: Record<string, unknown> = {};
105 let responseStatus = "";105 let responseStatus = "";
106 // Always attempt to compute minimal input/output schemas. This keeps the
107 // prepared spec compact while enabling schema-first tool signatures.
106 if (includeSchemas || hasGeneratedTypes) {108 if (includeSchemas || hasGeneratedTypes) {
107 const requestBody = resolveRequestBodyRef(asRecord(operation.requestBody), compRequestBodies);109 const requestBody = resolveRequestBodyRef(asRecord(operation.requestBody), compRequestBodies);
108 const requestBodyContent = asRecord(requestBody.content);110 const requestBodyContent = asRecord(requestBody.content);
@@ -122,43 +124,32 @@ export function compactOpenApiPaths( · 13 unmodified lines
122 }124 }
123 }125 }
124 126
125 if (hasGeneratedTypes) {127 const mergedParameters = normalizeParameters(operation.parameters).concat(sharedParameters);
126 const mergedParameters = normalizeParameters(operation.parameters).concat(sharedParameters);128 const hasInputSchema = mergedParameters.length > 0 || Object.keys(requestBodySchema).length > 0;
127 const hasInputSchema =129 if (hasInputSchema) {
128 mergedParameters.length > 0 || Object.keys(requestBodySchema).length > 0;130 compactOperation._inputSchema = buildOpenApiInputSchema(mergedParameters, requestBodySchema);
129 const combinedSchema = buildOpenApiInputSchema(mergedParameters, requestBodySchema);131 }
130 compactOperation._argsTypeHint = hasInputSchema132 if (Object.keys(responseSchema).length > 0 || responseStatus) {
131 ? jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas)133 compactOperation._outputSchema = responseSchema;
132 : "{}";134 if (responseStatus) {
133 compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas);135 compactOperation._successStatus = responseStatus;
134 const previewKeys = buildOpenApiArgPreviewKeys(mergedParameters, requestBodySchema, compSchemas);
135 if (previewKeys.length > 0) {
136 compactOperation._argPreviewKeys = [...new Set(previewKeys)];
137 }
138 } else if (includeSchemas) {
139 if (Object.keys(requestBodySchema).length > 0) {
140 compactOperation.requestBody = {
141 content: {
142 "application/json": {
143 schema: requestBodySchema,
144 },
145 },
146 };
147 }136 }
137 }
148 138
149 if (responseStatus) {139 const previewKeys = buildOpenApiArgPreviewKeys(mergedParameters, requestBodySchema, compSchemas);
150 compactOperation.responses = {140 if (previewKeys.length > 0) {
151 [responseStatus]: Object.keys(responseSchema).length > 0141 compactOperation._previewInputKeys = [...new Set(previewKeys)];
152 ? {142 }
153 content: {143
154 "application/json": {144 if (hasGeneratedTypes) {
155 schema: responseSchema,145 // Keep a low-cost type hint string for optional UI usage.
156 },146 // NOTE: Not required for the schema-first agent signature.
157 },147 if (compactOperation._inputSchema) {
158 }148 compactOperation._argsTypeHint = jsonSchemaTypeHintFallback(compactOperation._inputSchema, 0, compSchemas);
159 : {},149 } else {
160 };150 compactOperation._argsTypeHint = "{}";
161 }151 }
152 compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas);
162 }153 }
163 154
164 compactPathObject[method] = compactOperation;155 compactPathObject[method] = compactOperation;
executor/packages/core/src/openapi-typing.test.ts+130−0
@@ -0,0 +1,130 @@
1import { describe, expect, test } from "bun:test";
2import { buildOpenApiToolsFromPrepared, prepareOpenApiSpec } from "./tool-sources";
3
4function makeLargeSpec(operationCount: number): Record<string, unknown> {
5 const paths: Record<string, unknown> = {};
6
7 for (let i = 0; i < operationCount; i += 1) {
8 const tag = `resource_${i}`;
9 const pathTemplate = `/api/v1/${tag}/{id}`;
10
11 paths[pathTemplate] = {
12 get: {
13 operationId: `get_${tag}`,
14 tags: [tag],
15 summary: `Get ${tag} by ID`,
16 parameters: [
17 { name: "id", in: "path", required: true, schema: { type: "string" } },
18 { name: "include", in: "query", schema: { type: "string", enum: ["metadata", "related", "all"] } },
19 ],
20 responses: {
21 "200": {
22 description: "ok",
23 content: {
24 "application/json": {
25 schema: {
26 type: "object",
27 properties: {
28 id: { type: "string" },
29 name: { type: "string" },
30 },
31 required: ["id", "name"],
32 },
33 },
34 },
35 },
36 },
37 },
38 post: {
39 operationId: `create_${tag}`,
40 tags: [tag],
41 summary: `Create ${tag}`,
42 requestBody: {
43 required: true,
44 content: {
45 "application/json": {
46 schema: {
47 type: "object",
48 properties: {
49 name: { type: "string" },
50 },
51 required: ["name"],
52 },
53 },
54 },
55 },
56 responses: {
57 "201": {
58 description: "created",
59 content: {
60 "application/json": {
61 schema: {
62 type: "object",
63 properties: {
64 id: { type: "string" },
65 name: { type: "string" },
66 },
67 required: ["id", "name"],
68 },
69 },
70 },
71 },
72 },
73 },
74 };
75 }
76
77 return {
78 openapi: "3.0.3",
79 info: { title: "Large API", version: "1.0.0" },
80 servers: [{ url: "https://api.example.com" }],
81 paths,
82 };
83}
84
85describe("OpenAPI schema-first typing", () => {
86 test("buildOpenApiToolsFromPrepared emits input/output schemas and preview keys", async () => {
87 const spec = makeLargeSpec(50);
88 const prepared = await prepareOpenApiSpec(spec, "large", { includeDts: false, profile: "inventory" });
89
90 const tools = buildOpenApiToolsFromPrepared(
91 { type: "openapi", name: "large", spec, baseUrl: "https://api.example.com" },
92 prepared,
93 );
94
95 expect(tools.length).toBeGreaterThan(0);
96
97 const getTool = tools.find((t) => t.path.includes("get_resource_"));
98 expect(getTool).toBeDefined();
99 expect(getTool!.typing?.inputSchema).toBeDefined();
100 expect(getTool!.typing?.outputSchema).toBeDefined();
101 expect(getTool!.typing?.requiredInputKeys ?? []).toContain("id");
102 expect(getTool!.typing?.previewInputKeys ?? []).toContain("include");
103 expect(getTool!.typing?.typedRef).toBeDefined();
104 });
105
106 test("full profile with dts sets typedRef for OpenAPI operations", async () => {
107 const spec = makeLargeSpec(3);
108 const prepared = await prepareOpenApiSpec(spec, "large", { includeDts: true, profile: "full" });
109 expect(prepared.dts).toBeDefined();
110
111 const tools = buildOpenApiToolsFromPrepared(
112 { type: "openapi", name: "large", spec, baseUrl: "https://api.example.com" },
113 prepared,
114 );
115
116 const anyTyped = tools.find((t) => t.typing?.typedRef?.kind === "openapi_operation");
117 expect(anyTyped).toBeDefined();
118 expect(anyTyped!.typing!.typedRef!.sourceKey).toBe("openapi:large");
119 });
120
121 test("prepared spec stays reasonably small for many operations", async () => {
122 const spec = makeLargeSpec(250);
123 const prepared = await prepareOpenApiSpec(spec, "large", { includeDts: false, profile: "inventory" });
124 const json = JSON.stringify(prepared);
125 const sizeKB = json.length / 1024;
126 console.log(`prepared OpenAPI (250 ops): ${sizeKB.toFixed(0)}KB`);
127 // Loose threshold; this guards against accidentally embedding full .d.ts or huge raw specs.
128 expect(json.length).toBeLessThan(5_000_000);
129 }, 300_000);
130});
executor/packages/core/src/openapi/real-specs.test.ts+25−261
@@ -128,42 +128,19 @@ describe("real-world OpenAPI specs", () => { · 127 unmodified lines
128 128
129 expect(tools.length).toBeGreaterThan(0);129 expect(tools.length).toBeGreaterThan(0);
130 130
131 // Spot-check: every tool has a path and type metadata131 // Spot-check: every tool has a path and schema-first typing
132 for (const tool of tools) {132 for (const tool of tools) {
133 expect(tool.path).toContain(`${fixture.name}.`);133 expect(tool.path).toContain(`${fixture.name}.`);
134 expect(typeof tool.description).toBe("string");134 expect(typeof tool.description).toBe("string");
135 expect(tool.metadata).toBeDefined();135 expect(tool.typing).toBeDefined();
136 expect(tool.metadata!.argsType).toBeDefined();
137 expect(tool.metadata!.returnsType).toBeDefined();
138 }136 }
139 137
140 // If we have .d.ts, tools should carry operationId + sourceDts for typechecking138 // If we have .d.ts, tools should carry typed refs for high-fidelity typing
141 if (fixture.expectDts) {139 if (fixture.expectDts) {
142 // At least some tools should have operationId set140 const withTypedRef = tools.filter(
143 const withOperationId = tools.filter(141 (t) => t.typing?.typedRef?.kind === "openapi_operation",
144 (t) => t.metadata!.operationId != null,
145 );142 );
146 expect(withOperationId.length).toBeGreaterThan(0);143 expect(withTypedRef.length).toBeGreaterThan(0);
147
148 // At least one tool per source should carry the raw .d.ts
149 const withSourceDts = tools.filter(
150 (t) => t.metadata!.sourceDts != null && t.metadata!.sourceDts!.length > 0,
151 );
152 expect(withSourceDts.length).toBeGreaterThan(0);
153
154 // The sourceDts should contain operations interface
155 const dts = withSourceDts[0].metadata!.sourceDts!;
156 expect(dts).toContain("operations");
157
158 // Real specs should produce useful type hints for at least some operations.
159 const typedInputs = tools.filter(
160 (t) => t.metadata?.argsType && t.metadata.argsType !== "Record<string, unknown>",
161 );
162 const typedOutputs = tools.filter(
163 (t) => t.metadata?.returnsType && t.metadata.returnsType !== "unknown",
164 );
165 expect(typedInputs.length).toBeGreaterThan(0);
166 expect(typedOutputs.length).toBeGreaterThan(0);
167 }144 }
168 145
169 if (prepared.warnings.length > 0) {146 if (prepared.warnings.length > 0) {
@@ -193,15 +170,13 @@ describe("real-world OpenAPI specs", () => { · 23 unmodified lines
193 );170 );
194 171
195 const tool = tools.find(172 const tool = tools.find(
196 (t) => t.metadata?.operationId === "activity/delete-repo-subscription",173 (t) => t.typing?.typedRef?.kind === "openapi_operation" && t.typing.typedRef.operationId === "activity/delete-repo-subscription",
197 );174 );
198 175
199 expect(tool).toBeDefined();176 expect(tool).toBeDefined();
200 expect(tool!.path).toBe("github.activity.delete_repo_subscription");177 expect(tool!.path).toBe("github.activity.delete_repo_subscription");
201 expect(tool!.metadata!.argsType).toContain("owner");178 expect(tool!.typing?.requiredInputKeys ?? []).toContain("owner");
202 expect(tool!.metadata!.argsType).toContain("repo");179 expect(tool!.typing?.requiredInputKeys ?? []).toContain("repo");
203 expect(tool!.metadata!.argsType).not.toBe("Record<string, unknown>");
204 expect(tool!.metadata!.returnsType).toBe("void");
205 },180 },
206 300_000,181 300_000,
207 );182 );
@@ -224,16 +199,15 @@ describe("real-world OpenAPI specs", () => { · 16 unmodified lines
224 );199 );
225 200
226 const tool = tools.find(201 const tool = tools.find(
227 (t) => t.metadata?.operationId === "actions/add-custom-labels-to-self-hosted-runner-for-repo",202 (t) => t.typing?.typedRef?.kind === "openapi_operation" && t.typing.typedRef.operationId === "actions/add-custom-labels-to-self-hosted-runner-for-repo",
228 );203 );
229 204
230 expect(tool).toBeDefined();205 expect(tool).toBeDefined();
231 expect(tool!.path).toBe("github.actions.add_custom_labels_to_self_hosted_runner_for_repo");206 expect(tool!.path).toBe("github.actions.add_custom_labels_to_self_hosted_runner_for_repo");
232 expect(tool!.metadata!.displayArgsType).toContain("owner: string");207 expect(tool!.typing?.requiredInputKeys ?? []).toContain("owner");
233 expect(tool!.metadata!.displayArgsType).toContain("repo: string");208 expect(tool!.typing?.requiredInputKeys ?? []).toContain("repo");
234 expect(tool!.metadata!.displayArgsType).toContain("runner_id: number");209 expect(tool!.typing?.requiredInputKeys ?? []).toContain("runner_id");
235 expect(tool!.metadata!.displayArgsType).toContain("labels: string[]");210 expect(tool!.typing?.requiredInputKeys ?? []).toContain("labels");
236 expect(tool!.metadata!.displayArgsType).not.toContain("owner: ...");
237 },211 },
238 300_000,212 300_000,
239 );213 );
@@ -256,137 +230,24 @@ describe("real-world OpenAPI specs", () => { · 16 unmodified lines
256 );230 );
257 231
258 const tool = tools.find(232 const tool = tools.find(
259 (t) => t.metadata?.operationId === "actions/create-hosted-runner-for-org",233 (t) => t.typing?.typedRef?.kind === "openapi_operation" && t.typing.typedRef.operationId === "actions/create-hosted-runner-for-org",
260 );234 );
261 235
262 expect(tool).toBeDefined();236 expect(tool).toBeDefined();
263 expect(tool!.path).toBe("github.actions.create_hosted_runner_for_org");237 expect(tool!.path).toBe("github.actions.create_hosted_runner_for_org");
264 expect(tool!.metadata!.argsType).toContain("org: string");238 expect(tool!.typing?.requiredInputKeys ?? []).toContain("org");
265 expect(tool!.metadata!.returnsType).toContain("id");239 expect(tool!.typing?.outputSchema).toBeDefined();
266 expect(tool!.metadata!.returnsType).not.toBe("unknown");
267 },
268 300_000,
269 );
270
271 test(
272 "github: meta/get has empty object input hint",
273 async () => {
274 const githubUrl =
275 "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml";
276
277 const prepared = await prepareOpenApiSpec(githubUrl, "github");
278 const tools = buildOpenApiToolsFromPrepared(
279 {
280 type: "openapi",
281 name: "github",
282 spec: githubUrl,
283 baseUrl: prepared.servers[0] || "https://api.github.com",
284 },
285 prepared,
286 );
287
288 const tool = tools.find((t) => t.metadata?.operationId === "meta/get");
289
290 expect(tool).toBeDefined();
291 expect(tool!.path).toBe("github.meta.get");
292 expect(tool!.metadata!.argsType).toBe("{}");
293 },
294 300_000,
295 );
296
297 test(
298 "github: all budgets for org has non-unknown return hint",
299 async () => {
300 const githubUrl =
301 "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml";
302
303 const prepared = await prepareOpenApiSpec(githubUrl, "github");
304 const tools = buildOpenApiToolsFromPrepared(
305 {
306 type: "openapi",
307 name: "github",
308 spec: githubUrl,
309 baseUrl: prepared.servers[0] || "https://api.github.com",
310 },
311 prepared,
312 );
313
314 const tool = tools.find(
315 (t) => t.metadata?.operationId === "billing/get-all-budgets-org",
316 );
317
318 expect(tool).toBeDefined();
319 expect(tool!.path).toBe("github.billing.get_all_budgets_org");
320 expect(tool!.metadata!.argsType).toContain("org: string");
321 expect(tool!.metadata!.returnsType).toContain("budgets");
322 expect(tool!.metadata!.returnsType).not.toBe("unknown");
323 },
324 300_000,
325 );
326
327 test(
328 "slack: approved apps list keeps typed query params and non-unknown output",
329 async () => {
330 const slackUrl = "https://api.slack.com/specs/openapi/v2/slack_web.json";
331
332 const prepared = await prepareOpenApiSpec(slackUrl, "slack");
333 const tools = buildOpenApiToolsFromPrepared(
334 {
335 type: "openapi",
336 name: "slack",
337 spec: slackUrl,
338 baseUrl: "https://slack.com/api",
339 },
340 prepared,
341 );
342
343 const tool = tools.find(
344 (t) => t.metadata?.operationId === "admin_apps_approved_list",
345 );
346
347 expect(tool).toBeDefined();
348 expect(tool!.path).toBe("slack.admin_apps_approved.list");
349 expect(tool!.metadata!.argsType).toContain("token: string");
350 expect(tool!.metadata!.argsType).toContain("limit?: number");
351 expect(tool!.metadata!.returnsType).not.toBe("unknown");
352 },
353 300_000,
354 );
355
356 test(
357 "cloudflare: list health checks includes typed health check fields",
358 async () => {
359 const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";
360
361 const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare");
362 const tools = buildOpenApiToolsFromPrepared(
363 {
364 type: "openapi",
365 name: "cloudflare",
366 spec: cloudflareUrl,
367 baseUrl: prepared.servers[0] || "https://api.cloudflare.com/client/v4",
368 },
369 prepared,
370 );
371
372 const tool = tools.find(
373 (t) => t.metadata?.operationId === "health-checks-list-health-checks",
374 );
375
376 expect(tool).toBeDefined();
377 expect(tool!.path).toBe("cloudflare.health_checks.list_health_checks");
378 expect(tool!.metadata!.returnsType).toContain('components["schemas"]["healthchecks_healthchecks"][]');
379 expect(tool!.metadata!.returnsType).not.toContain("unknown | unknown");
380 },240 },
381 300_000,241 300_000,
382 );242 );
383 243
384 test(244 test(
385 "cloudflare inventory mode: access apps keeps typed args and non-unknown returns",245 "OpenAPI inventory mode still yields usable schemas",
386 async () => {246 async () => {
387 const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";247 const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";
248 const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare", { includeDts: false, profile: "inventory" });
249 expect(prepared.dts).toBeUndefined();
388 250
389 const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare", { includeDts: false });
390 const tools = buildOpenApiToolsFromPrepared(251 const tools = buildOpenApiToolsFromPrepared(
391 {252 {
392 type: "openapi",253 type: "openapi",
@@ -397,108 +258,11 @@ describe("real-world OpenAPI specs", () => { · 4 unmodified lines
397 prepared,258 prepared,
398 );259 );
399 260
400 const tool = tools.find(261 expect(tools.length).toBeGreaterThan(0);
401 (t) => t.metadata?.operationId === "access-applications-list-access-applications",262 const anyToolWithSchema = tools.find((t) => t.typing?.inputSchema && Object.keys(t.typing.inputSchema).length > 0);
402 );263 expect(anyToolWithSchema).toBeDefined();
403 264 const anyToolWithTypedRef = tools.find((t) => t.typing?.typedRef);
404 expect(tool).toBeDefined();265 expect(anyToolWithTypedRef).toBeDefined();
405 expect(tool!.path).toBe("cloudflare.access_applications.list_access_applications");
406 expect(tool!.metadata!.argsType).toContain("account_id: string");
407 expect(tool!.metadata!.argsType).not.toContain("account_id: unknown");
408 expect(tool!.metadata!.returnsType).not.toBe("unknown");
409 },
410 300_000,
411 );
412
413 test(
414 "cloudflare: access authentication logs keeps non-ellipsis compact hints",
415 async () => {
416 const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";
417
418 const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare", { includeDts: false });
419 const tools = buildOpenApiToolsFromPrepared(
420 {
421 type: "openapi",
422 name: "cloudflare",
423 spec: cloudflareUrl,
424 baseUrl: prepared.servers[0] || "https://api.cloudflare.com/client/v4",
425 },
426 prepared,
427 );
428
429 const tool = tools.find(
430 (t) => t.metadata?.operationId === "access-authentication-logs-get-access-authentication-logs",
431 );
432
433 expect(tool).toBeDefined();
434 expect(tool!.path).toBe("cloudflare.access_authentication_logs.get_access_authentication_logs");
435 expect(tool!.metadata!.displayArgsType).toContain("account_id: string");
436 expect(tool!.metadata!.displayArgsType).toContain("direction?: \"desc\" | \"asc\"");
437 expect(tool!.metadata!.displayArgsType).not.toContain("...");
438 expect(tool!.metadata!.displayReturnsType).toContain('messages: components["schemas"]["access_messages"]');
439 expect(tool!.metadata!.displayReturnsType).toContain('result?: components["schemas"]["access_access-requests"][]');
440 expect(tool!.metadata!.displayReturnsType).not.toContain("...");
441 },
442 300_000,
443 );
444
445 test(
446 "cloudflare: delete access application keeps concrete compact args/returns",
447 async () => {
448 const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";
449
450 const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare", { includeDts: false });
451 const tools = buildOpenApiToolsFromPrepared(
452 {
453 type: "openapi",
454 name: "cloudflare",
455 spec: cloudflareUrl,
456 baseUrl: prepared.servers[0] || "https://api.cloudflare.com/client/v4",
457 },
458 prepared,
459 );
460
461 const tool = tools.find(
462 (t) => t.metadata?.operationId === "access-applications-delete-an-access-application",
463 );
464
465 expect(tool).toBeDefined();
466 expect(tool!.metadata!.displayArgsType).toContain("app_id:");
467 expect(tool!.metadata!.displayArgsType).toContain("account_id: string");
468 expect(tool!.metadata!.displayArgsType).not.toContain("...");
469 expect(tool!.metadata!.displayReturnsType).toContain('result?: { id?: components["schemas"]["access_uuid"] }');
470 expect(tool!.metadata!.displayReturnsType).not.toContain("num...");
471 },
472 300_000,
473 );
474
475 test(
476 "cloudflare: add access application keeps compact display hints for large strict types",
477 async () => {
478 const cloudflareUrl = "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml";
479
480 const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare");
481 const tools = buildOpenApiToolsFromPrepared(
482 {
483 type: "openapi",
484 name: "cloudflare",
485 spec: cloudflareUrl,
486 baseUrl: prepared.servers[0] || "https://api.cloudflare.com/client/v4",
487 },
488 prepared,
489 );
490
491 const tool = tools.find(
492 (t) => t.metadata?.operationId === "access-applications-add-an-application",
493 );
494
495 expect(tool).toBeDefined();
496 expect(prepared.dts).toBeDefined();
497 expect(tool!.metadata!.displayArgsType).toContain("account_id");
498 expect(tool!.metadata!.displayArgsType!.length).toBeLessThan(240);
499 expect(tool!.metadata!.argsType!.length).toBeGreaterThan(1_000);
500 expect(tool!.metadata!.argsType).toContain('components["schemas"]["access_saas_props"]');
501 expect(tool!.metadata!.argsType).not.toContain("unknown | unknown");
502 },266 },
503 300_000,267 300_000,
504 );268 );
executor/packages/core/src/openapi/schema-hints.test.ts+305−0
@@ -0,0 +1,305 @@
1import { expect, test } from "bun:test";
2import { jsonSchemaTypeHintFallback } from "./schema-hints";
3
4test("jsonSchemaTypeHintFallback collapses simple oneOf object union", () => {
5 const schema = {
6 oneOf: [
7 {
8 type: "object",
9 properties: {
10 uid: { type: "string" },
11 updated: { type: "number" },
12 },
13 required: ["uid", "updated"],
14 },
15 {
16 type: "object",
17 properties: {
18 uid: { type: "string" },
19 },
20 required: ["uid"],
21 },
22 ],
23 };
24
25 expect(jsonSchemaTypeHintFallback(schema)).toBe("{ uid: string; updated?: number }");
26});
27
28test("jsonSchemaTypeHintFallback factors common object fields in oneOf", () => {
29 const schema = {
30 oneOf: [
31 {
32 type: "object",
33 properties: {
34 domain: { type: "string" },
35 type: { enum: ["A"] },
36 value: { type: "string" },
37 },
38 required: ["domain", "type", "value"],
39 },
40 {
41 type: "object",
42 properties: {
43 domain: { type: "string" },
44 type: { enum: ["AAAA"] },
45 value: { type: "string" },
46 },
47 required: ["domain", "type", "value"],
48 },
49 ],
50 };
51
52 const hint = jsonSchemaTypeHintFallback(schema);
53 expect(hint).toContain("domain: string");
54 expect(hint).toContain("value: string");
55 // Variants only differ by the discriminant; they should be merged.
56 expect(hint).toContain("type: \"A\" | \"AAAA\"");
57});
58
59test("jsonSchemaTypeHintFallback parenthesizes union inside intersection", () => {
60 const schema = {
61 allOf: [
62 {
63 type: "object",
64 properties: { domain: { type: "string" } },
65 required: ["domain"],
66 },
67 {
68 oneOf: [
69 { type: "object", properties: { type: { enum: ["A"] }, value: { type: "string" } }, required: ["type", "value"] },
70 { type: "object", properties: { type: { enum: ["B"] }, id: { type: "number" } }, required: ["type", "id"] },
71 ],
72 },
73 ],
74 };
75
76 const hint = jsonSchemaTypeHintFallback(schema);
77 expect(hint).toContain("& (");
78 expect(hint).toContain("| ");
79});
80
81test("jsonSchemaTypeHintFallback inlines small component schema refs at depth threshold", () => {
82 const componentSchemas = {
83 Pagination: {
84 type: "object",
85 properties: {
86 count: { type: "number" },
87 next: { type: "number", nullable: true },
88 prev: { type: "number", nullable: true },
89 },
90 required: ["count", "next", "prev"],
91 },
92 };
93
94 const hint = jsonSchemaTypeHintFallback(
95 { $ref: "#/components/schemas/Pagination" },
96 2,
97 componentSchemas,
98 );
99 expect(hint).toContain("count");
100 expect(hint).not.toContain("components[\"schemas\"][\"Pagination\"]");
101});
102
103test("jsonSchemaTypeHintFallback drops plain scalar variants when union has multiple object shapes", () => {
104 const schema = {
105 oneOf: [
106 { type: "string" },
107 {
108 type: "object",
109 properties: {
110 records: { type: "string" },
111 },
112 required: ["records"],
113 },
114 {
115 type: "object",
116 properties: {
117 records: { type: "string" },
118 pagination: { type: "number" },
119 },
120 required: ["records", "pagination"],
121 },
122 ],
123 };
124
125 // string variant is pruned; remaining two objects collapse into optional pagination.
126 expect(jsonSchemaTypeHintFallback(schema)).toBe("{ records: string; pagination?: number }");
127});
128
129test("jsonSchemaTypeHintFallback drops empty object variant when a more specific object is present", () => {
130 const schema = {
131 oneOf: [
132 { type: "object" },
133 {
134 type: "object",
135 properties: {
136 a: { type: "string" },
137 },
138 required: ["a"],
139 },
140 ],
141 };
142
143 expect(jsonSchemaTypeHintFallback(schema)).toBe("{ a: string }");
144});
145
146test("jsonSchemaTypeHintFallback keeps scalar | object when union has only one object variant", () => {
147 const schema = {
148 oneOf: [
149 { type: "string" },
150 {
151 type: "object",
152 properties: {
153 a: { type: "string" },
154 },
155 required: ["a"],
156 },
157 ],
158 };
159
160 expect(jsonSchemaTypeHintFallback(schema)).toBe("string | { a: string }");
161});
162
163test("jsonSchemaTypeHintFallback partially factors repeated required keys from large unions", () => {
164 const schema = {
165 oneOf: [
166 {
167 type: "object",
168 properties: {
169 name: { type: "string" },
170 type: { enum: ["A"] },
171 value: { type: "string" },
172 },
173 required: ["name", "type", "value"],
174 },
175 {
176 type: "object",
177 properties: {
178 name: { type: "string" },
179 type: { enum: ["AAAA"] },
180 value: { type: "string" },
181 },
182 required: ["name", "type", "value"],
183 },
184 {
185 type: "object",
186 properties: {
187 name: { type: "string" },
188 type: { enum: ["CNAME"] },
189 value: { type: "string" },
190 },
191 required: ["name", "type"],
192 },
193 {
194 type: "object",
195 properties: {
196 type: { enum: ["SRV"] },
197 srv: { type: "object", properties: { target: { type: "string" } }, required: ["target"] },
198 },
199 required: ["type", "srv"],
200 },
201 ],
202 };
203
204 const hint = jsonSchemaTypeHintFallback(schema);
205 // `name` should only appear once after factoring.
206 expect(hint.split("name: string").length - 1).toBe(1);
207 // Still must mention SRV variant.
208 expect(hint).toContain("type: \"SRV\"");
209 // The factored form uses an intersection.
210 expect(hint).toContain("& (");
211});
212
213test("jsonSchemaTypeHintFallback factors common fields and then partially factors repeated keys inside residual union", () => {
214 const schema = {
215 anyOf: [
216 {
217 type: "object",
218 properties: {
219 name: { type: "string" },
220 type: { enum: ["A"] },
221 value: { type: "string" },
222 ttl: { type: "number" },
223 comment: { type: "string" },
224 },
225 required: ["name", "type", "value"],
226 },
227 {
228 type: "object",
229 properties: {
230 name: { type: "string" },
231 type: { enum: ["AAAA"] },
232 value: { type: "string" },
233 ttl: { type: "number" },
234 comment: { type: "string" },
235 },
236 required: ["name", "type", "value"],
237 },
238 {
239 type: "object",
240 properties: {
241 type: { enum: ["SRV"] },
242 srv: {
243 type: "object",
244 properties: { target: { type: "string" } },
245 required: ["target"],
246 },
247 ttl: { type: "number" },
248 comment: { type: "string" },
249 },
250 required: ["type", "srv"],
251 },
252 {
253 type: "object",
254 properties: {
255 name: { type: "string" },
256 type: { enum: ["NS"] },
257 value: { type: "string" },
258 ttl: { type: "number" },
259 comment: { type: "string" },
260 },
261 required: ["name", "type"],
262 },
263 ],
264 };
265
266 const hint = jsonSchemaTypeHintFallback(schema);
267 // `ttl`/`comment` appear once as common fields.
268 expect(hint.split("ttl?: number").length - 1).toBe(1);
269 expect(hint.split("comment?: string").length - 1).toBe(1);
270 // `name` should also be factored once inside the residual union.
271 expect(hint.split("name: string").length - 1).toBe(1);
272});
273
274test("jsonSchemaTypeHintFallback merges discriminated object variants that only differ by type enum", () => {
275 const schema = {
276 oneOf: [
277 {
278 type: "object",
279 properties: {
280 name: { type: "string" },
281 type: { type: "string", enum: ["A"] },
282 value: { type: "string" },
283 },
284 required: ["name", "type", "value"],
285 },
286 {
287 type: "object",
288 properties: {
289 name: { type: "string" },
290 type: { type: "string", enum: ["AAAA"] },
291 value: { type: "string" },
292 },
293 required: ["name", "type", "value"],
294 },
295 ],
296 };
297
298 const hint = jsonSchemaTypeHintFallback(schema);
299 expect(hint).toContain("name: string");
300 expect(hint).toContain("value: string");
301 expect(hint).toContain("type: \"A\" | \"AAAA\"");
302 // Should not render as two separate object variants.
303 expect(hint).not.toContain("type: \"A\"; value");
304 expect(hint).not.toContain("type: \"AAAA\"; value");
305});
executor/packages/core/src/openapi/schema-hints.ts+540−10
@@ -3,6 +3,18 @@ import { asRecord } from "../utils"; · 2 unmodified lines
3type JsonSchema = Record<string, unknown>;3type JsonSchema = Record<string, unknown>;
4const COMPONENT_REF_INLINE_DEPTH = 2;4const COMPONENT_REF_INLINE_DEPTH = 2;
5 5
6function isSmallInlineableComponentSchema(schema: Record<string, unknown>): boolean {
7 const shape = schema as JsonSchema;
8 const type = typeof shape.type === "string" ? shape.type : undefined;
9 const props = asRecord(shape.properties);
10 const propCount = Object.keys(props).length;
11 if (type !== "object" && propCount === 0) return false;
12 if (propCount === 0) return false;
13 if (propCount > 8) return false;
14 if (Array.isArray(shape.oneOf) || Array.isArray(shape.anyOf) || Array.isArray(shape.allOf)) return false;
15 return true;
16}
17
6export type OpenApiParameterHint = {18export type OpenApiParameterHint = {
7 name: string;19 name: string;
8 required: boolean;20 required: boolean;
@@ -224,11 +236,454 @@ function joinUnion(parts: string[]): string { · 215 unmodified lines
224 .join(" | ");236 .join(" | ");
225}237}
226 238
239function isObjectSchema(schema: JsonSchema): boolean {
240 const type = typeof schema.type === "string" ? schema.type : undefined;
241 if (type === "object") return true;
242 return Object.keys(asRecord(schema.properties)).length > 0;
243}
244
245function isEmptyObjectSchema(schema: JsonSchema): boolean {
246 const type = typeof schema.type === "string" ? schema.type : undefined;
247 if (type !== "object") return false;
248 return Object.keys(asRecord(schema.properties)).length === 0;
249}
250
251function isPlainScalarSchema(schema: JsonSchema): boolean {
252 const type = typeof schema.type === "string" ? schema.type : undefined;
253 if (type !== "string" && type !== "number" && type !== "integer" && type !== "boolean") {
254 return false;
255 }
256 if (Array.isArray(schema.enum) && schema.enum.length > 0) return false;
257
258 // If this scalar has any meaningful constraints, keep it.
259 const allowedKeys = new Set([
260 "type",
261 "title",
262 "description",
263 "deprecated",
264 "examples",
265 "example",
266 "nullable",
267 "default",
268 ]);
269 for (const key of Object.keys(schema)) {
270 if (!allowedKeys.has(key)) return false;
271 }
272
273 return true;
274}
275
276function normalizeUnionSchemaVariants(variants: JsonSchema[]): JsonSchema[] {
277 if (variants.length < 2) return variants;
278
279 // Many real-world OpenAPI specs include `oneOf: [{type:"object"}, { ...specific... }]`
280 // or even `oneOf: [{type:"string"}, { ...object... }, { ...object... }]`.
281 // These broad scalar/empty-object variants add noise without helping the UI/LLM.
282 const hasNonEmptyObject = variants.some((v) => isObjectSchema(v) && Object.keys(asRecord(v.properties)).length > 0);
283
284 let filtered = variants;
285 if (hasNonEmptyObject) {
286 filtered = filtered.filter((v) => !isEmptyObjectSchema(v));
287 }
288
289 const objectCount = filtered.filter(isObjectSchema).length;
290 if (objectCount >= 2) {
291 filtered = filtered.filter((v) => !isPlainScalarSchema(v));
292 }
293
294 return filtered.length > 0 ? filtered : variants;
295}
296
297function extractStringLiteralFromSchema(schema: JsonSchema): string | null {
298 // Support the two common encodings we see in OpenAPI:
299 // - { type: "string", enum: ["A"] }
300 // - { const: "A" }
301 const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined;
302 if (enumValues && enumValues.length === 1 && typeof enumValues[0] === "string") {
303 return enumValues[0];
304 }
305 if (typeof (schema as Record<string, unknown>).const === "string") {
306 return String((schema as Record<string, unknown>).const);
307 }
308 return null;
309}
310
311function mergeDiscriminatedObjectUnionVariants(
312 variants: JsonSchema[],
313 discriminantKey: string,
314 depth: number,
315 componentSchemas?: Record<string, unknown>,
316 seenRefs: Set<string> = new Set(),
317): JsonSchema[] {
318 if (variants.length < 2) return variants;
319 if (!variants.every(isObjectSchema)) return variants;
320
321 type VariantInfo = {
322 idx: number;
323 schema: JsonSchema;
324 disc: string;
325 signature: string;
326 };
327
328 const infos: VariantInfo[] = [];
329
330 for (let i = 0; i < variants.length; i++) {
331 const variant = variants[i]!;
332 const props = asRecord(variant.properties);
333 const discSchema = asRecord(props[discriminantKey]);
334 const disc = extractStringLiteralFromSchema(discSchema);
335 if (!disc) {
336 return variants;
337 }
338
339 const required = new Set(
340 (Array.isArray(variant.required) ? variant.required : []).filter(
341 (k): k is string => typeof k === "string",
342 ),
343 );
344
345 const otherKeys = Object.keys(props)
346 .filter((k) => k !== discriminantKey)
347 .sort();
348 const requiredOther = [...required]
349 .filter((k) => k !== discriminantKey)
350 .sort();
351
352 const keyHints = otherKeys.map((k) => {
353 const hint = jsonSchemaTypeHintFallback(props[k], depth + 1, componentSchemas, seenRefs);
354 return `${k}:${hint}`;
355 });
356
357 // Group by: same other keys + same required set + same per-key hints.
358 const signature = JSON.stringify({ otherKeys, requiredOther, keyHints });
359 infos.push({ idx: i, schema: variant, disc, signature });
360 }
361
362 const bySig = new Map<string, VariantInfo[]>();
363 for (const info of infos) {
364 const bucket = bySig.get(info.signature);
365 if (bucket) bucket.push(info);
366 else bySig.set(info.signature, [info]);
367 }
368
369 let changed = false;
370 const used = new Set<number>();
371 const out: JsonSchema[] = [];
372
373 // Preserve stable order: walk original variants and emit merged groups
374 // when we encounter their first member.
375 for (let i = 0; i < variants.length; i++) {
376 if (used.has(i)) continue;
377 const info = infos.find((x) => x.idx === i);
378 if (!info) {
379 out.push(variants[i]!);
380 continue;
381 }
382
383 const group = bySig.get(info.signature) ?? [info];
384 if (group.length < 2) {
385 out.push(variants[i]!);
386 used.add(i);
387 continue;
388 }
389
390 // Merge group: same schema shape, only discriminant differs.
391 changed = true;
392 for (const member of group) used.add(member.idx);
393
394 const base = group[0]!.schema;
395 const baseProps = asRecord(base.properties);
396 const discSchema = asRecord(baseProps[discriminantKey]);
397 const discType = typeof discSchema.type === "string" ? discSchema.type : "string";
398
399 const mergedDiscriminant: JsonSchema = {
400 type: discType,
401 enum: group.map((m) => m.disc),
402 };
403
404 const mergedProps = {
405 ...baseProps,
406 [discriminantKey]: mergedDiscriminant,
407 };
408
409 out.push({
410 ...base,
411 properties: mergedProps,
412 });
413 }
414
415 return changed ? out : variants;
416}
417
418function subsetKeys(left: string[], right: string[]): boolean {
419 const rightSet = new Set(right);
420 for (const key of left) {
421 if (!rightSet.has(key)) return false;
422 }
423 return true;
424}
425
426function tryCollapseSimpleObjectUnion(
427 variants: JsonSchema[],
428 depth: number,
429 componentSchemas?: Record<string, unknown>,
430 seenRefs: Set<string> = new Set(),
431): string | null {
432 if (variants.length !== 2) return null;
433 const left = variants[0]!;
434 const right = variants[1]!;
435 if (!isObjectSchema(left) || !isObjectSchema(right)) return null;
436
437 const leftProps = asRecord(left.properties);
438 const rightProps = asRecord(right.properties);
439 const leftKeys = Object.keys(leftProps);
440 const rightKeys = Object.keys(rightProps);
441 if (leftKeys.length === 0 || rightKeys.length === 0) return null;
442
443 // Only collapse the simplest case: one object is a strict subset of the other
444 // and they differ by exactly one additional property.
445 const leftSubRight = subsetKeys(leftKeys, rightKeys);
446 const rightSubLeft = subsetKeys(rightKeys, leftKeys);
447 if (!leftSubRight && !rightSubLeft) return null;
448
449 const sup = leftSubRight ? right : left;
450 const sub = leftSubRight ? left : right;
451 const supProps = asRecord(sup.properties);
452 const subProps = asRecord(sub.properties);
453 const supKeys = Object.keys(supProps);
454 const subKeys = Object.keys(subProps);
455 const extraKeys = supKeys.filter((k) => !subKeys.includes(k));
456 if (extraKeys.length !== 1) return null;
457
458 // Shared keys must have identical type hints.
459 for (const key of subKeys) {
460 const supHint = jsonSchemaTypeHintFallback(supProps[key], depth + 1, componentSchemas, seenRefs);
461 const subHint = jsonSchemaTypeHintFallback(subProps[key], depth + 1, componentSchemas, seenRefs);
462 if (supHint !== subHint) return null;
463 }
464
465 const requiredSup = new Set((Array.isArray(sup.required) ? sup.required : []).filter((v): v is string => typeof v === "string"));
466 const requiredSub = new Set((Array.isArray(sub.required) ? sub.required : []).filter((v): v is string => typeof v === "string"));
467 const requiredBoth = new Set<string>();
468 for (const key of subKeys) {
469 if (requiredSup.has(key) && requiredSub.has(key)) {
470 requiredBoth.add(key);
471 }
472 }
473
474 const inner = supKeys
475 .map((key) => {
476 const hint = jsonSchemaTypeHintFallback(supProps[key], depth + 1, componentSchemas, seenRefs);
477 return `${formatTsPropertyKey(key)}${requiredBoth.has(key) ? "" : "?"}: ${hint}`;
478 })
479 .join("; ");
480
481 return `{ ${inner} }`;
482}
483
484function intersectKeys(variants: Array<Record<string, unknown>>): string[] {
485 if (variants.length === 0) return [];
486 const sets = variants.map((props) => new Set(Object.keys(props)));
487 const [first, ...rest] = sets;
488 if (!first) return [];
489 const out: string[] = [];
490 for (const key of first) {
491 if (rest.every((set) => set.has(key))) {
492 out.push(key);
493 }
494 }
495 return out;
496}
497
498function tryFactorCommonObjectFields(
499 variants: JsonSchema[],
500 depth: number,
501 componentSchemas?: Record<string, unknown>,
502 seenRefs: Set<string> = new Set(),
503): string | null {
504 if (variants.length < 2) return null;
505 if (!variants.every(isObjectSchema)) return null;
506
507 const propsList = variants.map((v) => asRecord(v.properties));
508 const commonKeys = intersectKeys(propsList);
509 if (commonKeys.length === 0) return null;
510
511 const commonEntries: Array<{ key: string; hint: string; required: boolean }> = [];
512 for (const key of commonKeys) {
513 const hints = variants.map((v) => jsonSchemaTypeHintFallback(asRecord(v.properties)[key], depth + 1, componentSchemas, seenRefs));
514 const firstHint = hints[0];
515 if (!firstHint || hints.some((h) => h !== firstHint)) continue;
516
517 const requiredEverywhere = variants.every((v) => {
518 const req = Array.isArray(v.required) ? v.required : [];
519 return req.includes(key);
520 });
521 commonEntries.push({ key, hint: firstHint, required: requiredEverywhere });
522 }
523
524 if (commonEntries.length === 0) return null;
525
526 const commonProps = new Set(commonEntries.map((e) => e.key));
527 const residualSchemas: JsonSchema[] = [];
528 for (const variant of variants) {
529 const props = asRecord(variant.properties);
530 const residualProps = Object.fromEntries(Object.entries(props).filter(([key]) => !commonProps.has(key)));
531 const req = Array.isArray(variant.required) ? variant.required : [];
532 const residualRequired = req.filter((key) => typeof key === "string" && !commonProps.has(key));
533 residualSchemas.push({
534 type: "object",
535 properties: residualProps,
536 ...(residualRequired.length > 0 ? { required: residualRequired } : {}),
537 } as unknown as JsonSchema);
538 }
539
540 // If factoring doesn't reduce anything, bail.
541 const reduces = residualSchemas.some((s) => Object.keys(asRecord(s.properties)).length > 0);
542 if (!reduces) return null;
543
544 const commonInner = commonEntries
545 .map((e) => `${formatTsPropertyKey(e.key)}${e.required ? "" : "?"}: ${e.hint}`)
546 .join("; ");
547 const commonType = `{ ${commonInner} }`;
548
549 const partiallyFactoredResidual = tryFactorPartialCommonObjectFields(
550 residualSchemas,
551 depth,
552 componentSchemas,
553 seenRefs,
554 );
555
556 const residualType = partiallyFactoredResidual
557 ?? joinUnion(residualSchemas.map((s) => jsonSchemaTypeHintFallback(s, depth + 1, componentSchemas, seenRefs)));
558 if (residualType === "unknown") return commonType;
559
560 return `${commonType} & (${residualType})`;
561}
562
563function tryFactorPartialCommonObjectFields(
564 variants: JsonSchema[],
565 depth: number,
566 componentSchemas?: Record<string, unknown>,
567 seenRefs: Set<string> = new Set(),
568): string | null {
569 // If there's a large union where a field repeats across many (but not all)
570 // object variants (e.g. Vercel DNS createRecord repeats `name` in most
571 // variants), pull it out as:
572 // ({ name: string } & (<union-without-name>)) | <remaining>
573 // This is purely a *hint* readability improvement.
574
575 if (variants.length < 3) return null;
576 if (!variants.every(isObjectSchema)) return null;
577
578 const propsList = variants.map((v) => asRecord(v.properties));
579 const requiredList = variants.map((v) => new Set(
580 (Array.isArray(v.required) ? v.required : []).filter((k): k is string => typeof k === "string"),
581 ));
582
583 type Candidate = {
584 key: string;
585 hint: string;
586 indices: number[];
587 };
588
589 const candidates: Candidate[] = [];
590 const minGroupSize = variants.length >= 4 ? 3 : 2;
591
592 // Collect keys present in at least minGroupSize variants.
593 const keyCounts = new Map<string, number>();
594 for (const props of propsList) {
595 for (const key of Object.keys(props)) {
596 keyCounts.set(key, (keyCounts.get(key) ?? 0) + 1);
597 }
598 }
599
600 for (const [key, count] of keyCounts.entries()) {
601 if (count < minGroupSize) continue;
602
603 // Consider only keys that are required in the variants we factor.
604 const indicesWithKey = propsList
605 .map((props, idx) => (props[key] !== undefined ? idx : -1))
606 .filter((idx) => idx !== -1);
607
608 // Group by identical type hint.
609 const byHint = new Map<string, number[]>();
610 for (const idx of indicesWithKey) {
611 const variant = variants[idx]!;
612 if (!requiredList[idx]!.has(key)) continue;
613 const props = propsList[idx]!;
614 const hint = jsonSchemaTypeHintFallback(props[key], depth + 1, componentSchemas, seenRefs);
615 const bucket = byHint.get(hint);
616 if (bucket) bucket.push(idx);
617 else byHint.set(hint, [idx]);
618 }
619
620 for (const [hint, indices] of byHint.entries()) {
621 if (indices.length < minGroupSize) continue;
622 if (indices.length === variants.length) continue;
623 candidates.push({ key, hint, indices });
624 }
625 }
626
627 if (candidates.length === 0) return null;
628
629 // Pick the best candidate: largest group first, then stable key order.
630 candidates.sort((a, b) => {
631 if (b.indices.length !== a.indices.length) return b.indices.length - a.indices.length;
632 if (a.key !== b.key) return a.key.localeCompare(b.key);
633 return a.hint.localeCompare(b.hint);
634 });
635 const best = candidates[0]!;
636
637 // Build the factored group.
638 const commonType = `{ ${formatTsPropertyKey(best.key)}: ${best.hint} }`;
639 const bestSet = new Set(best.indices);
640
641 const residualSchemas: JsonSchema[] = [];
642 const remainingSchemas: JsonSchema[] = [];
643
644 for (let i = 0; i < variants.length; i++) {
645 const variant = variants[i]!;
646 const props = propsList[i]!;
647 if (!bestSet.has(i)) {
648 remainingSchemas.push(variant);
649 continue;
650 }
651
652 const residualProps = Object.fromEntries(
653 Object.entries(props).filter(([k]) => k !== best.key),
654 );
655 const req = Array.isArray(variant.required) ? variant.required : [];
656 const residualRequired = req.filter(
657 (k) => typeof k === "string" && k !== best.key,
658 );
659
660 residualSchemas.push({
661 type: "object",
662 properties: residualProps,
663 ...(residualRequired.length > 0 ? { required: residualRequired } : {}),
664 } as unknown as JsonSchema);
665 }
666
667 // If factoring doesn't actually remove anything useful, bail.
668 const anyResidualHasProps = residualSchemas.some(
669 (s) => Object.keys(asRecord(s.properties)).length > 0,
670 );
671 if (!anyResidualHasProps) return null;
672
673 const residualType = joinUnion(residualSchemas.map((s) => jsonSchemaTypeHintFallback(s, depth + 1, componentSchemas, seenRefs)));
674 const groupType = `${commonType} & (${residualType})`;
675
676 if (remainingSchemas.length === 0) return groupType;
677 const remainingType = joinUnion(remainingSchemas.map((s) => jsonSchemaTypeHintFallback(s, depth + 1, componentSchemas, seenRefs)));
678 return joinUnion([groupType, remainingType]);
679}
680
227function joinIntersection(parts: string[]): string {681function joinIntersection(parts: string[]): string {
228 const expanded = parts.flatMap((part) => splitTopLevelBy(part, "&"));682 const expanded = parts.flatMap((part) => splitTopLevelBy(part, "&"));
229 const unique = dedupeTypeParts(expanded).filter((part) => part !== "unknown");683 const unique = dedupeTypeParts(expanded).filter((part) => part !== "unknown");
230 if (unique.length === 0) return "unknown";684 if (unique.length === 0) return "unknown";
231 return unique.length === 1 ? unique[0]! : unique.join(" & ");685 const wrapped = unique.map((part) => (part.includes(" | ") ? `(${part})` : part));
686 return wrapped.length === 1 ? wrapped[0]! : wrapped.join(" & ");
232}687}
233 688
234function maybeParenthesizeArrayElement(typeHint: string): string {689function maybeParenthesizeArrayElement(typeHint: string): string {
@@ -252,19 +707,18 @@ export function jsonSchemaTypeHintFallback( · 17 unmodified lines
252 const prefix = "#/components/schemas/";707 const prefix = "#/components/schemas/";
253 if (ref.startsWith(prefix)) {708 if (ref.startsWith(prefix)) {
254 const key = ref.slice(prefix.length);709 const key = ref.slice(prefix.length);
255 if (depth >= COMPONENT_REF_INLINE_DEPTH) {
256 return formatComponentSchemaRefType(key);
257 }
258 if (seenRefs.has(ref)) {
259 return "unknown";
260 }
261 const resolved = componentSchemas ? asRecord(componentSchemas[key]) : {};710 const resolved = componentSchemas ? asRecord(componentSchemas[key]) : {};
262 if (Object.keys(resolved).length > 0) {711 const canInline = Object.keys(resolved).length > 0
712 && !seenRefs.has(ref)
713 && (depth < COMPONENT_REF_INLINE_DEPTH || isSmallInlineableComponentSchema(resolved));
714
715 if (canInline) {
263 const nextSeen = new Set(seenRefs);716 const nextSeen = new Set(seenRefs);
264 nextSeen.add(ref);717 nextSeen.add(ref);
265 return jsonSchemaTypeHintFallback(resolved, depth + 1, componentSchemas, nextSeen);718 return jsonSchemaTypeHintFallback(resolved, depth + 1, componentSchemas, nextSeen);
266 }719 }
267 720
721 // Fall back to a stable named reference.
268 return formatComponentSchemaRefType(key);722 return formatComponentSchemaRefType(key);
269 }723 }
270 }724 }
@@ -276,12 +730,88 @@ export function jsonSchemaTypeHintFallback( · 5 unmodified lines
276 730
277 const oneOf = Array.isArray(shape.oneOf) ? shape.oneOf : undefined;731 const oneOf = Array.isArray(shape.oneOf) ? shape.oneOf : undefined;
278 if (oneOf && oneOf.length > 0) {732 if (oneOf && oneOf.length > 0) {
279 return joinUnion(oneOf.map((entry) => jsonSchemaTypeHintFallback(entry, depth + 1, componentSchemas, seenRefs)));733 const variants = normalizeUnionSchemaVariants(
734 oneOf.filter((entry): entry is JsonSchema => Boolean(entry && typeof entry === "object")),
735 );
736 const objectVariantsMerged = mergeDiscriminatedObjectUnionVariants(
737 variants.filter(isObjectSchema),
738 "type",
739 depth,
740 componentSchemas,
741 seenRefs,
742 );
743 const mergedVariants = variants.some((v) => !isObjectSchema(v))
744 ? variants.filter((v) => !isObjectSchema(v)).concat(objectVariantsMerged)
745 : objectVariantsMerged;
746 const objectVariants = mergedVariants.filter(isObjectSchema);
747
748 const collapsed = tryCollapseSimpleObjectUnion(
749 objectVariants,
750 depth,
751 componentSchemas,
752 seenRefs,
753 );
754 if (collapsed) return collapsed;
755
756 const factored = tryFactorCommonObjectFields(
757 objectVariants,
758 depth,
759 componentSchemas,
760 seenRefs,
761 );
762 if (factored) return factored;
763
764 const partial = tryFactorPartialCommonObjectFields(
765 objectVariants,
766 depth,
767 componentSchemas,
768 seenRefs,
769 );
770 if (partial) return partial;
771 return joinUnion(mergedVariants.map((entry) => jsonSchemaTypeHintFallback(entry, depth + 1, componentSchemas, seenRefs)));
280 }772 }
281 773
282 const anyOf = Array.isArray(shape.anyOf) ? shape.anyOf : undefined;774 const anyOf = Array.isArray(shape.anyOf) ? shape.anyOf : undefined;
283 if (anyOf && anyOf.length > 0) {775 if (anyOf && anyOf.length > 0) {
284 return joinUnion(anyOf.map((entry) => jsonSchemaTypeHintFallback(entry, depth + 1, componentSchemas, seenRefs)));776 const variants = normalizeUnionSchemaVariants(
777 anyOf.filter((entry): entry is JsonSchema => Boolean(entry && typeof entry === "object")),
778 );
779 const objectVariantsMerged = mergeDiscriminatedObjectUnionVariants(
780 variants.filter(isObjectSchema),
781 "type",
782 depth,
783 componentSchemas,
784 seenRefs,
785 );
786 const mergedVariants = variants.some((v) => !isObjectSchema(v))
787 ? variants.filter((v) => !isObjectSchema(v)).concat(objectVariantsMerged)
788 : objectVariantsMerged;
789 const objectVariants = mergedVariants.filter(isObjectSchema);
790
791 const collapsed = tryCollapseSimpleObjectUnion(
792 objectVariants,
793 depth,
794 componentSchemas,
795 seenRefs,
796 );
797 if (collapsed) return collapsed;
798
799 const factored = tryFactorCommonObjectFields(
800 objectVariants,
801 depth,
802 componentSchemas,
803 seenRefs,
804 );
805 if (factored) return factored;
806
807 const partial = tryFactorPartialCommonObjectFields(
808 objectVariants,
809 depth,
810 componentSchemas,
811 seenRefs,
812 );
813 if (partial) return partial;
814 return joinUnion(mergedVariants.map((entry) => jsonSchemaTypeHintFallback(entry, depth + 1, componentSchemas, seenRefs)));
285 }815 }
286 816
287 const allOf = Array.isArray(shape.allOf) ? shape.allOf : undefined;817 const allOf = Array.isArray(shape.allOf) ? shape.allOf : undefined;
executor/packages/core/src/openapi/tool-builder.ts+28−75
@@ -9,9 +9,10 @@ import { · 8 unmodified lines
9import { buildOpenApiToolPath } from "./tool-path";9import { buildOpenApiToolPath } from "./tool-path";
10import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";10import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
11import { executeOpenApiRequest } from "../tool/source-execution";11import { executeOpenApiRequest } from "../tool/source-execution";
12import { compactArgDisplayHint, compactReturnTypeHint } from "../type-hints";
13import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "../tool/source-types";12import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "../tool/source-types";
14import type { ToolDefinition } from "../types";13import type { ToolDefinition } from "../types";
14import type { ToolTypedRef } from "../types";
15import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
15import { asRecord } from "../utils";16import { asRecord } from "../utils";
16import type { SerializedTool } from "../tool/source-serialization";17import type { SerializedTool } from "../tool/source-serialization";
17 18
@@ -22,12 +23,6 @@ type OpenApiOperationParameter = { · 4 unmodified lines
22 schema: Record<string, unknown>;23 schema: Record<string, unknown>;
23};24};
24 25
25type OpenApiOperationTypeHints = {
26 argsType: string;
27 returnsType: string;
28 argPreviewKeys: string[];
29};
30
31function buildOpenApiOperationParameters(26function buildOpenApiOperationParameters(
32 sharedParameters: Array<Record<string, unknown>>,27 sharedParameters: Array<Record<string, unknown>>,
33 operation: Record<string, unknown>,28 operation: Record<string, unknown>,
@@ -45,51 +40,6 @@ function buildOpenApiOperationParameters( · 11 unmodified lines
45 }));40 }));
46}41}
47 42
48function buildOpenApiOperationTypeHints(
49 operation: Record<string, unknown>,
50 parameters: OpenApiOperationParameter[],
51): OpenApiOperationTypeHints {
52 let argPreviewKeys: string[] = Array.isArray(operation._argPreviewKeys)
53 ? operation._argPreviewKeys.filter((value): value is string => typeof value === "string")
54 : [];
55
56 if (typeof operation._argsTypeHint === "string" && typeof operation._returnsTypeHint === "string") {
57 return {
58 argsType: operation._argsTypeHint,
59 returnsType: operation._returnsTypeHint,
60 argPreviewKeys,
61 };
62 }
63
64 const requestBody = asRecord(operation.requestBody);
65 const requestBodyContent = asRecord(requestBody.content);
66 const requestBodySchema = getPreferredContentSchema(requestBodyContent);
67
68 const responses = asRecord(operation.responses);
69 let responseSchema: Record<string, unknown> = {};
70 let responseStatus = "";
71 for (const [status, responseValue] of Object.entries(responses)) {
72 if (!status.startsWith("2")) continue;
73 responseSchema = getPreferredResponseSchema(asRecord(responseValue));
74 responseStatus = status;
75 if (Object.keys(responseSchema).length > 0) break;
76 }
77
78 const hasInputSchema = parameters.length > 0 || Object.keys(requestBodySchema).length > 0;
79 const combinedSchema = buildOpenApiInputSchema(parameters, requestBodySchema);
80 const argsType = hasInputSchema ? jsonSchemaTypeHintFallback(combinedSchema) : "{}";
81 const returnsType = responseTypeHintFromSchema(responseSchema, responseStatus);
82 if (argPreviewKeys.length === 0) {
83 argPreviewKeys = buildOpenApiArgPreviewKeys(parameters, requestBodySchema);
84 }
85
86 return {
87 argsType,
88 returnsType,
89 argPreviewKeys,
90 };
91}
92
93export function buildOpenApiToolsFromPrepared(43export function buildOpenApiToolsFromPrepared(
94 config: OpenApiToolSourceConfig,44 config: OpenApiToolSourceConfig,
95 prepared: PreparedOpenApiSpec,45 prepared: PreparedOpenApiSpec,
@@ -107,13 +57,6 @@ export function buildOpenApiToolsFromPrepared( · 11 unmodified lines
107 const paths = asRecord(prepared.paths);57 const paths = asRecord(prepared.paths);
108 const tools: ToolDefinition[] = [];58 const tools: ToolDefinition[] = [];
109 59
110 // The raw .d.ts is attached to the first tool only (one per source to avoid duplication).
111 // The typechecker/Monaco use this directly via indexed access types.
112 const sourceDts = prepared.dts
113 ? prepared.dts.replace(/^export /gm, "")
114 : undefined;
115 let sourceDtsEmitted = false;
116
117 const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const;60 const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const;
118 const readMethods = new Set(["get", "head", "options"]);61 const readMethods = new Set(["get", "head", "options"]);
119 const usedToolPaths = new Set<string>();62 const usedToolPaths = new Set<string>();
@@ -133,10 +76,24 @@ export function buildOpenApiToolsFromPrepared( · 13 unmodified lines
133 const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`);76 const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`);
134 const parameters = buildOpenApiOperationParameters(sharedParameters, operation);77 const parameters = buildOpenApiOperationParameters(sharedParameters, operation);
135 78
136 const { argsType, returnsType, argPreviewKeys } = buildOpenApiOperationTypeHints(operation, parameters);79 const inputSchema = asRecord(operation._inputSchema);
137 80 const outputSchema = asRecord(operation._outputSchema);
138 const displayArgsType = compactArgDisplayHint(argsType, argPreviewKeys);81 const inputHint = typeof operation._argsTypeHint === "string" && operation._argsTypeHint.trim().length > 0
139 const displayReturnsType = compactReturnTypeHint(returnsType);82 ? operation._argsTypeHint.trim()
83 : undefined;
84 const outputHint = typeof operation._returnsTypeHint === "string" && operation._returnsTypeHint.trim().length > 0
85 ? operation._returnsTypeHint.trim()
86 : undefined;
87 const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema);
88 const previewInputKeys = Array.isArray(operation._previewInputKeys)
89 ? operation._previewInputKeys.filter((value): value is string => typeof value === "string")
90 : buildPreviewKeys(inputSchema);
91
92 const typedRef: ToolTypedRef = {
93 kind: "openapi_operation",
94 sourceKey: sourceLabel,
95 operationId: operationIdRaw,
96 };
140 97
141 const approval = config.overrides?.[operationIdRaw]?.approval98 const approval = config.overrides?.[operationIdRaw]?.approval
142 ?? (readMethods.has(method)99 ?? (readMethods.has(method)
@@ -157,14 +114,14 @@ export function buildOpenApiToolsFromPrepared( · 14 unmodified lines
157 source: sourceLabel,114 source: sourceLabel,
158 approval,115 approval,
159 description: String(operation.summary ?? operation.description ?? `${method.toUpperCase()} ${pathTemplate}`),116 description: String(operation.summary ?? operation.description ?? `${method.toUpperCase()} ${pathTemplate}`),
160 metadata: {117 typing: {
161 argsType,118 ...(Object.keys(inputSchema).length > 0 ? { inputSchema } : {}),
162 returnsType,119 ...(Object.keys(outputSchema).length > 0 ? { outputSchema } : {}),
163 displayArgsType,120 ...(inputHint ? { inputHint } : {}),
164 displayReturnsType,121 ...(outputHint ? { outputHint } : {}),
165 ...(argPreviewKeys.length > 0 ? { argPreviewKeys } : {}),122 ...(requiredInputKeys.length > 0 ? { requiredInputKeys } : {}),
166 operationId: operationIdRaw,123 ...(previewInputKeys.length > 0 ? { previewInputKeys } : {}),
167 ...(sourceDts && !sourceDtsEmitted ? { sourceDts } : {}),124 typedRef,
168 },125 },
169 credential: credentialSpec,126 credential: credentialSpec,
170 _runSpec: runSpec,127 _runSpec: runSpec,
@@ -173,10 +130,6 @@ export function buildOpenApiToolsFromPrepared( · 2 unmodified lines
173 },130 },
174 };131 };
175 tools.push(tool);132 tools.push(tool);
176
177 if (sourceDts && !sourceDtsEmitted) {
178 sourceDtsEmitted = true;
179 }
180 }133 }
181 }134 }
182 135
executor/packages/core/src/tool-discovery.test.ts+120−122
@@ -1,7 +1,6 @@
1import { expect, test } from "bun:test";1import { expect, test } from "bun:test";
2import type { Id } from "../../convex/_generated/dataModel.d.ts";2import type { Id } from "../../convex/_generated/dataModel.d.ts";
3import { createCatalogTools, createDiscoverTool } from "./tool-discovery";3import { createCatalogTools, createDiscoverTool } from "./tool-discovery";
4import { extractSourceSchemaTypesFromDts } from "./tool-discovery/schema-registry";
5import type { ToolDefinition } from "./types";4import type { ToolDefinition } from "./types";
6 5
7const TEST_WORKSPACE_ID = "w" as Id<"workspaces">;6const TEST_WORKSPACE_ID = "w" as Id<"workspaces">;
@@ -13,9 +12,24 @@ test("discover returns aliases and example calls", async () => { · 5 unmodified lines
13 description: "Add numbers",12 description: "Add numbers",
14 approval: "auto",13 approval: "auto",
15 source: "openapi:calc",14 source: "openapi:calc",
16 metadata: {15 typing: {
17 argsType: "{ a: number; b: number }",16 inputSchema: {
18 returnsType: "{ sum: number }",17 type: "object",
18 properties: {
19 a: { type: "number" },
20 b: { type: "number" },
21 },
22 required: ["a", "b"],
23 },
24 outputSchema: {
25 type: "object",
26 properties: {
27 sum: { type: "number" },
28 },
29 required: ["sum"],
30 },
31 requiredInputKeys: ["a", "b"],
32 previewInputKeys: ["a", "b"],
19 },33 },
20 run: async () => ({ sum: 0 }),34 run: async () => ({ sum: 0 }),
21 } satisfies ToolDefinition,35 } satisfies ToolDefinition,
@@ -32,7 +46,10 @@ test("discover returns aliases and example calls", async () => { · 10 unmodified lines
32 exampleCall: string;46 exampleCall: string;
33 signature: string;47 signature: string;
34 canonicalSignature: string;48 canonicalSignature: string;
35 expandedShape: { input: string; output: string };49 signatureInfo: {
50 requiredKeys: string[];
51 previewKeys: string[];
52 };
36 }>;53 }>;
37 total: number;54 total: number;
38 };55 };
@@ -43,10 +60,10 @@ test("discover returns aliases and example calls", async () => { · 4 unmodified lines
43 expect(result.results[0]?.aliases).toContain("calc.math.addNumbers");60 expect(result.results[0]?.aliases).toContain("calc.math.addNumbers");
44 expect(result.results[0]?.aliases).toContain("calc.math.addnumbers");61 expect(result.results[0]?.aliases).toContain("calc.math.addnumbers");
45 expect(result.results[0]?.exampleCall).toBe("await tools.calc.math.add_numbers({ a: ..., b: ... });");62 expect(result.results[0]?.exampleCall).toBe("await tools.calc.math.add_numbers({ a: ..., b: ... });");
46 expect(result.results[0]?.signature).toContain("Promise<{ sum: number }>");63 expect(result.results[0]?.signature).toContain("Promise<");
47 expect(result.results[0]?.canonicalSignature).toContain("Promise<{ sum: number }>");64 expect(result.results[0]?.signature).toContain("sum");
48 expect(result.results[0]?.expandedShape.input).toContain("a:");65 expect(result.results[0]?.canonicalSignature).toContain("sum");
49 expect(result.results[0]?.expandedShape.output).toContain("sum");66 expect(result.results[0]?.signatureInfo.requiredKeys).toEqual(["a", "b"]);
50});67});
51 68
52test("discover example call handles input-shaped args", async () => {69test("discover example call handles input-shaped args", async () => {
@@ -56,9 +73,23 @@ test("discover example call handles input-shaped args", async () => { · 3 unmodified lines
56 description: "Create issue",73 description: "Create issue",
57 approval: "required",74 approval: "required",
58 source: "graphql:linear",75 source: "graphql:linear",
59 metadata: {76 typing: {
60 argsType: "{ input: { teamId: string; title: string } }",77 inputSchema: {
61 returnsType: "{ data: { id: string }; errors: unknown[] }",78 type: "object",
79 properties: {
80 input: {
81 type: "object",
82 properties: {
83 teamId: { type: "string" },
84 title: { type: "string" },
85 },
86 required: ["teamId", "title"],
87 },
88 },
89 required: ["input"],
90 },
91 requiredInputKeys: ["input"],
92 previewInputKeys: ["input"],
62 },93 },
63 run: async () => ({ data: { id: "x" }, errors: [] }),94 run: async () => ({ data: { id: "x" }, errors: [] }),
64 } satisfies ToolDefinition,95 } satisfies ToolDefinition,
@@ -85,9 +116,28 @@ test("discover uses compact signatures by default and allows full mode", async ( · 20 unmodified lines
85 description: "All teams whose issues can be accessed by the user. Compact output should trim this long explanation before it reaches the trailing marker text to keep discover results concise for models. TRAILING_MARKER_TEXT",116 description: "All teams whose issues can be accessed by the user. Compact output should trim this long explanation before it reaches the trailing marker text to keep discover results concise for models. TRAILING_MARKER_TEXT",
86 approval: "auto",117 approval: "auto",
87 source: "graphql:linear",118 source: "graphql:linear",
88 metadata: {119 typing: {
89 argsType: "{ filter?: TeamFilter; before?: string; after?: string; first?: number; last?: number; includeArchived?: boolean; orderBy?: PaginationOrderBy }",120 inputSchema: {
90 returnsType: "{ data: TeamConnection; errors: unknown[] }",121 type: "object",
122 properties: {
123 filter: {},
124 before: { type: "string" },
125 after: { type: "string" },
126 first: { type: "number" },
127 last: { type: "number" },
128 includeArchived: { type: "boolean" },
129 orderBy: {},
130 },
131 },
132 outputSchema: {
133 type: "object",
134 properties: {
135 data: {},
136 errors: { type: "array", items: {} },
137 },
138 required: ["data"],
139 },
140 previewInputKeys: ["filter", "before", "after", "first", "last"],
91 },141 },
92 run: async () => ({ data: {}, errors: [] }),142 run: async () => ({ data: {}, errors: [] }),
93 } satisfies ToolDefinition,143 } satisfies ToolDefinition,
@@ -102,7 +152,7 @@ test("discover uses compact signatures by default and allows full mode", async ( · 8 unmodified lines
102 description: string;152 description: string;
103 signature: string;153 signature: string;
104 canonicalSignature: string;154 canonicalSignature: string;
105 expandedShape: { input: string; output: string };155 signatureInfo: { previewKeys: string[] };
106 }>;156 }>;
107 };157 };
108 158
@@ -115,94 +165,21 @@ test("discover uses compact signatures by default and allows full mode", async ( · 6 unmodified lines
115 description: string;165 description: string;
116 signature: string;166 signature: string;
117 canonicalSignature: string;167 canonicalSignature: string;
118 expandedShape: { input: string; output: string };168 signatureInfo: { previewKeys: string[] };
119 }>;169 }>;
120 };170 };
121 171
122 expect(compactResult.bestPath).toBe("linear.query.teams");172 expect(compactResult.bestPath).toBe("linear.query.teams");
123 expect(fullResult.bestPath).toBe("linear.query.teams");173 expect(fullResult.bestPath).toBe("linear.query.teams");
124 expect(compactResult.results[0]?.signature).toContain("Promise<{ data: ...; errors: unknown[] }>");174 expect(compactResult.results[0]?.signature).toContain("Promise<");
125 expect(compactResult.results[0]?.canonicalSignature).toContain("TeamConnection");175 expect(compactResult.results[0]?.signature).toContain("errors");
126 expect(compactResult.results[0]?.expandedShape.input).toContain("filter");176 expect(compactResult.results[0]?.signatureInfo.previewKeys).toContain("filter");
127 expect(compactResult.results[0]?.description).not.toContain("TRAILING_MARKER_TEXT");177 expect(compactResult.results[0]?.description).not.toContain("TRAILING_MARKER_TEXT");
128 178
129 expect(fullResult.results[0]?.signature).toContain("TeamFilter");179 expect(fullResult.results[0]?.signature).toContain("Promise<");
130 expect(fullResult.results[0]?.signature).toContain("TeamConnection");
131 expect(fullResult.results[0]?.description).toContain("TRAILING_MARKER_TEXT");180 expect(fullResult.results[0]?.description).toContain("TRAILING_MARKER_TEXT");
132});181});
133 182
134test("discover includes deduped schema registry for component refs", async () => {
135 const sourceDts = `
136interface components {
137 schemas: {
138 "runner-label": { id?: number; name?: string };
139 runner: { labels?: components["schemas"]["runner-label"][] };
140 };
141}
142interface operations {}
143`;
144
145 const tool = createDiscoverTool([
146 {
147 path: "github.actions.add_custom_labels_to_self_hosted_runner_for_org",
148 description: "Add custom labels to org runner",
149 approval: "required",
150 source: "openapi:github",
151 metadata: {
152 argsType: "{ org: string; runner_id: number; labels: string[] }",
153 returnsType: "{ labels: components[\"schemas\"][\"runner-label\"][] }",
154 sourceDts,
155 },
156 run: async () => ({ labels: [] }),
157 } satisfies ToolDefinition,
158 {
159 path: "github.actions.list_labels_for_self_hosted_runner_for_org",
160 description: "List labels for org runner",
161 approval: "auto",
162 source: "openapi:github",
163 metadata: {
164 argsType: "{ org: string; runner_id: number }",
165 returnsType: "{ labels: components[\"schemas\"][\"runner-label\"][] }",
166 },
167 run: async () => ({ labels: [] }),
168 } satisfies ToolDefinition,
169 ]);
170
171 const result = await tool.run(
172 { query: "runner labels", compact: false, depth: 2 },
173 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
174 ) as {
175 schemas?: Record<string, Record<string, string>>;
176 total: number;
177 };
178
179 expect(result.total).toBe(2);
180 expect(result.schemas?.["openapi:github"]).toBeDefined();
181 const sourceSchemas = result.schemas?.["openapi:github"] ?? {};
182 expect(sourceSchemas['components["schemas"]["runner-label"]']).toContain("name");
183 expect(Object.keys(sourceSchemas)).toHaveLength(1);
184});
185
186test("schema parser handles semicolons inside schema comments", () => {
187 const dts = `
188interface components {
189 schemas: {
190 root: {
191 /** this comment has a semicolon; and should not terminate parsing */
192 id: string;
193 };
194 "runner-label": {
195 name: string;
196 };
197 };
198}
199`;
200
201 const schemaTypes = extractSourceSchemaTypesFromDts(dts);
202 expect(schemaTypes["root"]).toContain("id: string");
203 expect(schemaTypes["runner-label"]).toContain("name: string");
204});
205
206test("discover returns null bestPath when there are no matches", async () => {183test("discover returns null bestPath when there are no matches", async () => {
207 const tool = createDiscoverTool([184 const tool = createDiscoverTool([
208 {185 {
@@ -210,9 +187,15 @@ test("discover returns null bestPath when there are no matches", async () => { · 1 unmodified line
210 description: "Add numbers",187 description: "Add numbers",
211 approval: "auto",188 approval: "auto",
212 source: "openapi:calc",189 source: "openapi:calc",
213 metadata: {190 typing: {
214 argsType: "{ a: number; b: number }",191 inputSchema: {
215 returnsType: "{ sum: number }",192 type: "object",
193 properties: {
194 a: { type: "number" },
195 b: { type: "number" },
196 },
197 required: ["a", "b"],
198 },
216 },199 },
217 run: async () => ({ sum: 0 }),200 run: async () => ({ sum: 0 }),
218 } satisfies ToolDefinition,201 } satisfies ToolDefinition,
@@ -235,9 +218,14 @@ test("discover bestPath prefers simpler exact intent operation", async () => { · 16 unmodified lines
235 description: "Create issue-to-release join",218 description: "Create issue-to-release join",
236 approval: "required",219 approval: "required",
237 source: "graphql:linear",220 source: "graphql:linear",
238 metadata: {221 typing: {
239 argsType: "{ input: { issueId: string; releaseId: string } }",222 inputSchema: {
240 returnsType: "{ data: IssueToReleasePayload; errors: unknown[] }",223 type: "object",
224 properties: { input: { type: "object", properties: { issueId: { type: "string" }, releaseId: { type: "string" } }, required: ["issueId", "releaseId"] } },
225 required: ["input"],
226 },
227 requiredInputKeys: ["input"],
228 previewInputKeys: ["input"],
241 },229 },
242 run: async () => ({ data: {}, errors: [] }),230 run: async () => ({ data: {}, errors: [] }),
243 } satisfies ToolDefinition,231 } satisfies ToolDefinition,
@@ -246,9 +234,14 @@ test("discover bestPath prefers simpler exact intent operation", async () => { · 2 unmodified lines
246 description: "Create issue",234 description: "Create issue",
247 approval: "required",235 approval: "required",
248 source: "graphql:linear",236 source: "graphql:linear",
249 metadata: {237 typing: {
250 argsType: "{ input: { teamId: string; title: string } }",238 inputSchema: {
251 returnsType: "{ data: IssuePayload; errors: unknown[] }",239 type: "object",
240 properties: { input: { type: "object", properties: { teamId: { type: "string" }, title: { type: "string" } }, required: ["teamId", "title"] } },
241 required: ["input"],
242 },
243 requiredInputKeys: ["input"],
244 previewInputKeys: ["input"],
252 },245 },
253 run: async () => ({ data: {}, errors: [] }),246 run: async () => ({ data: {}, errors: [] }),
254 } satisfies ToolDefinition,247 } satisfies ToolDefinition,
@@ -270,9 +263,10 @@ test("discover namespace hint suppresses cross-namespace bestPath", async () => · 15 unmodified lines
270 description: "List teams",263 description: "List teams",
271 approval: "auto",264 approval: "auto",
272 source: "openapi:github",265 source: "openapi:github",
273 metadata: {266 typing: {
274 argsType: "{ org: string }",267 inputSchema: { type: "object", properties: { org: { type: "string" } }, required: ["org"] },
275 returnsType: "Array<Team>",268 requiredInputKeys: ["org"],
269 previewInputKeys: ["org"],
276 },270 },
277 run: async () => ([]),271 run: async () => ([]),
278 } satisfies ToolDefinition,272 } satisfies ToolDefinition,
@@ -281,9 +275,9 @@ test("discover namespace hint suppresses cross-namespace bestPath", async () => · 2 unmodified lines
281 description: "List teams in Linear",275 description: "List teams in Linear",
282 approval: "auto",276 approval: "auto",
283 source: "graphql:linear",277 source: "graphql:linear",
284 metadata: {278 typing: {
285 argsType: "{}",279 inputSchema: { type: "object", properties: {} },
286 returnsType: "{ data: TeamConnection; errors: unknown[] }",280 outputSchema: { type: "object", properties: { data: {}, errors: { type: "array", items: {} } }, required: ["data"] },
287 },281 },
288 run: async () => ({ data: {}, errors: [] }),282 run: async () => ({ data: {}, errors: [] }),
289 } satisfies ToolDefinition,283 } satisfies ToolDefinition,
@@ -305,9 +299,11 @@ test("discover prefers simplified alias path for ugly namespaces", async () => { · 15 unmodified lines
305 description: "Get Vercel domain",299 description: "Get Vercel domain",
306 approval: "auto",300 approval: "auto",
307 source: "openapi:vercel",301 source: "openapi:vercel",
308 metadata: {302 typing: {
309 argsType: "{ domain: string; teamId?: string }",303 inputSchema: { type: "object", properties: { domain: { type: "string" }, teamId: { type: "string" } }, required: ["domain"] },
310 returnsType: "{ domain: string }",304 outputSchema: { type: "object", properties: { domain: { type: "string" } }, required: ["domain"] },
305 requiredInputKeys: ["domain"],
306 previewInputKeys: ["domain", "teamId"],
311 },307 },
312 run: async () => ({ domain: "executor.sh" }),308 run: async () => ({ domain: "executor.sh" }),
313 } satisfies ToolDefinition,309 } satisfies ToolDefinition,
@@ -334,9 +330,11 @@ test("catalog tools list namespaces and typed signatures", async () => { · 20 unmodified lines
334 description: "Get domain",330 description: "Get domain",
335 approval: "auto",331 approval: "auto",
336 source: "openapi:vercel",332 source: "openapi:vercel",
337 metadata: {333 typing: {
338 argsType: "{ domain: string; teamId?: string }",334 inputSchema: { type: "object", properties: { domain: { type: "string" }, teamId: { type: "string" } }, required: ["domain"] },
339 returnsType: "{ domain: string }",335 outputSchema: { type: "object", properties: { domain: { type: "string" } }, required: ["domain"] },
336 requiredInputKeys: ["domain"],
337 previewInputKeys: ["domain", "teamId"],
340 },338 },
341 run: async () => ({ domain: "executor.sh" }),339 run: async () => ({ domain: "executor.sh" }),
342 } satisfies ToolDefinition,340 } satisfies ToolDefinition,
@@ -345,9 +343,9 @@ test("catalog tools list namespaces and typed signatures", async () => { · 2 unmodified lines
345 description: "Get time",343 description: "Get time",
346 approval: "auto",344 approval: "auto",
347 source: "local",345 source: "local",
348 metadata: {346 typing: {
349 argsType: "{}",347 inputSchema: { type: "object", properties: {} },
350 returnsType: "{ iso: string; unix: number }",348 outputSchema: { type: "object", properties: { iso: { type: "string" }, unix: { type: "number" } }, required: ["iso", "unix"] },
351 },349 },
352 run: async () => ({ iso: "", unix: 0 }),350 run: async () => ({ iso: "", unix: 0 }),
353 } satisfies ToolDefinition,351 } satisfies ToolDefinition,
@@ -365,14 +363,14 @@ test("catalog tools list namespaces and typed signatures", async () => { · 11 unmodified lines
365 { namespace: "vercel", compact: false, depth: 2, limit: 10 },363 { namespace: "vercel", compact: false, depth: 2, limit: 10 },
366 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },364 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
367 ) as {365 ) as {
368 results: Array<{ path: string; aliases: string[]; signature: string; argsType: string; returnsType: string }>;366 results: Array<{ path: string; aliases: string[]; signatureText: string; signatureInfo: { input: string; output: string } }>;
369 total: number;367 total: number;
370 };368 };
371 369
372 expect(listed.total).toBe(1);370 expect(listed.total).toBe(1);
373 expect(listed.results[0]?.path).toBe("vercel.domains.get_domain");371 expect(listed.results[0]?.path).toBe("vercel.domains.get_domain");
374 expect(listed.results[0]?.aliases).toContain("vercel.domains.get_domain");372 expect(listed.results[0]?.aliases).toContain("vercel.domains.get_domain");
375 expect(listed.results[0]?.signature).toContain("domain: string");373 expect(listed.results[0]?.signatureText).toContain("domain");
376 expect(listed.results[0]?.argsType).toContain("domain: string");374 expect(listed.results[0]?.signatureInfo.input).toContain("domain");
377 expect(listed.results[0]?.returnsType).toContain("domain: string");375 expect(listed.results[0]?.signatureInfo.output).toContain("domain");
378});376});
executor/packages/core/src/tool-discovery.ts+55−28
@@ -4,12 +4,10 @@ import { · 3 unmodified lines
4} from "./type-hints";4} from "./type-hints";
5import {5import {
6 buildExampleCall,6 buildExampleCall,
7 buildExpandedShape,
8 formatCanonicalSignature,7 formatCanonicalSignature,
9 formatSignature,8 formatSignature,
10} from "./tool-discovery/formatting";9} from "./tool-discovery/formatting";
11import { buildIndex, getTopLevelNamespace, listIndexForContext } from "./tool-discovery/indexing";10import { buildIndex, getTopLevelNamespace, listIndexForContext } from "./tool-discovery/indexing";
12import { buildSchemaRegistryForEntries, buildSourceSchemaTypeMap } from "./tool-discovery/schema-registry";
13import { chooseBestPath, deriveIntentPhrase, extractNamespaceHints, scoreEntry } from "./tool-discovery/ranking";11import { chooseBestPath, deriveIntentPhrase, extractNamespaceHints, scoreEntry } from "./tool-discovery/ranking";
14 12
15export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] {13export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] {
@@ -20,11 +18,9 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 4 unmodified lines
20 source: "system",18 source: "system",
21 approval: "auto",19 approval: "auto",
22 description: "List available tool namespaces with counts and sample callable paths.",20 description: "List available tool namespaces with counts and sample callable paths.",
23 metadata: {21 typing: {
24 argsType: "{}",22 inputSchema: { type: "object", properties: {} },
25 returnsType: "{ namespaces: Array<{ namespace: string; toolCount: number; samplePaths: string[] }>; total: number }",23 outputSchema: { type: "object", properties: { namespaces: { type: "array" }, total: { type: "number" } }, required: ["namespaces", "total"] },
26 displayArgsType: "{}",
27 displayReturnsType: "{ namespaces: ...; total: number }",
28 },24 },
29 run: async (_input: unknown, context) => {25 run: async (_input: unknown, context) => {
30 const visible = listIndexForContext(index, context.isToolAllowed);26 const visible = listIndexForContext(index, context.isToolAllowed);
@@ -57,12 +53,19 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 26 unmodified lines
57 source: "system",53 source: "system",
58 approval: "auto",54 approval: "auto",
59 description: "List tools with typed signatures. Supports namespace and query filters in one call.",55 description: "List tools with typed signatures. Supports namespace and query filters in one call.",
60 metadata: {56 typing: {
61 argsType: "{ namespace?: string; query?: string; depth?: number; limit?: number; compact?: boolean }",57 inputSchema: {
62 returnsType:58 type: "object",
63 "{ results: Array<{ path: string; aliases: string[]; source: string; approval: 'auto' | 'required'; description: string; argsType: string; returnsType: string; signature: string; exampleCall: string }>; total: number }",59 properties: {
64 displayArgsType: "{ namespace?: string; query?: string; depth?: number; limit?: number; compact?: boolean }",60 namespace: { type: "string" },
65 displayReturnsType: "{ results: ...; total: number }",61 query: { type: "string" },
62 depth: { type: "number" },
63 limit: { type: "number" },
64 compact: { type: "boolean" },
65 includeSchemas: { type: "boolean" },
66 },
67 },
68 outputSchema: { type: "object", properties: { results: { type: "array" }, total: { type: "number" } }, required: ["results", "total"] },
66 },69 },
67 run: async (input: unknown, context) => {70 run: async (input: unknown, context) => {
68 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};71 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
@@ -71,6 +74,7 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 2 unmodified lines
71 const depth = Math.max(0, Math.min(2, Number(payload.depth ?? 1)));74 const depth = Math.max(0, Math.min(2, Number(payload.depth ?? 1)));
72 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));75 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));
73 const compact = payload.compact === false ? false : true;76 const compact = payload.compact === false ? false : true;
77 const includeSchemas = payload.includeSchemas === true;
74 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];78 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];
75 79
76 const visible = listIndexForContext(index, context.isToolAllowed);80 const visible = listIndexForContext(index, context.isToolAllowed);
@@ -97,9 +101,15 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 20 unmodified lines
97 source: entry.source,101 source: entry.source,
98 approval: entry.approval,102 approval: entry.approval,
99 description: compact ? compactDescriptionLine(entry.description) : entry.description,103 description: compact ? compactDescriptionLine(entry.description) : entry.description,
100 argsType: compact ? entry.displayArgsType : entry.argsType,104 signatureInfo: {
101 returnsType: compact ? entry.displayReturnsType : entry.returnsType,105 input: entry.displayInputHint,
102 signature: formatSignature(entry, depth, compact),106 output: entry.displayOutputHint,
107 requiredKeys: entry.requiredInputKeys,
108 previewKeys: entry.previewInputKeys,
109 ...(includeSchemas && !compact ? { inputSchema: entry.inputSchema, outputSchema: entry.outputSchema } : {}),
110 },
111 signatureText: formatSignature(entry, depth, compact),
112 canonicalSignature: formatCanonicalSignature(entry),
103 exampleCall: buildExampleCall(entry),113 exampleCall: buildExampleCall(entry),
104 }));114 }));
105 115
@@ -115,7 +125,6 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 9 unmodified lines
115 125
116export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition {126export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition {
117 const index = buildIndex(tools);127 const index = buildIndex(tools);
118 const sourceSchemaTypes = buildSourceSchemaTypeMap(tools);
119 128
120 return {129 return {
121 path: "discover",130 path: "discover",
@@ -123,10 +132,27 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 1 unmodified line
123 approval: "auto",132 approval: "auto",
124 description:133 description:
125 "Search available tools by keyword. Returns preferred path aliases, signature hints, and ready-to-copy call examples. Compact mode is enabled by default.",134 "Search available tools by keyword. Returns preferred path aliases, signature hints, and ready-to-copy call examples. Compact mode is enabled by default.",
126 metadata: {135 typing: {
127 argsType: "{ query: string; depth?: number; limit?: number; compact?: boolean }",136 inputSchema: {
128 returnsType:137 type: "object",
129 "{ bestPath: string | null; results: Array<{ path: string; aliases: string[]; source: string; approval: 'auto' | 'required'; description: string; signature: string; canonicalSignature: string; expandedShape: { input: string; output: string }; exampleCall: string }>; schemas?: Record<string, Record<string, string>>; total: number }",138 properties: {
139 query: { type: "string" },
140 depth: { type: "number" },
141 limit: { type: "number" },
142 compact: { type: "boolean" },
143 includeSchemas: { type: "boolean" },
144 },
145 required: ["query"],
146 },
147 outputSchema: {
148 type: "object",
149 properties: {
150 bestPath: {},
151 results: { type: "array" },
152 total: { type: "number" },
153 },
154 required: ["bestPath", "results", "total"],
155 },
130 },156 },
131 run: async (input: unknown, context) => {157 run: async (input: unknown, context) => {
132 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};158 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
@@ -134,6 +160,7 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 1 unmodified line
134 const depth = Math.max(0, Math.min(2, Number(payload.depth ?? 1)));160 const depth = Math.max(0, Math.min(2, Number(payload.depth ?? 1)));
135 const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));161 const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));
136 const compact = payload.compact === false ? false : true;162 const compact = payload.compact === false ? false : true;
163 const includeSchemas = payload.includeSchemas === true;
137 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];164 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];
138 const namespaces = new Set(index.map((entry) => getTopLevelNamespace(entry.path)).filter(Boolean));165 const namespaces = new Set(index.map((entry) => getTopLevelNamespace(entry.path)).filter(Boolean));
139 const namespaceHints = extractNamespaceHints(terms, namespaces);166 const namespaceHints = extractNamespaceHints(terms, namespaces);
@@ -159,19 +186,19 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 19 unmodified lines
159 description: compact ? compactDescriptionLine(entry.description) : entry.description,186 description: compact ? compactDescriptionLine(entry.description) : entry.description,
160 signature: formatSignature(entry, depth, compact),187 signature: formatSignature(entry, depth, compact),
161 canonicalSignature: formatCanonicalSignature(entry),188 canonicalSignature: formatCanonicalSignature(entry),
162 expandedShape: buildExpandedShape(entry),189 signatureInfo: {
190 input: entry.displayInputHint,
191 output: entry.displayOutputHint,
192 requiredKeys: entry.requiredInputKeys,
193 previewKeys: entry.previewInputKeys,
194 ...(includeSchemas && !compact ? { inputSchema: entry.inputSchema, outputSchema: entry.outputSchema } : {}),
195 },
163 exampleCall: buildExampleCall(entry),196 exampleCall: buildExampleCall(entry),
164 }));197 }));
165 198
166 const schemas = buildSchemaRegistryForEntries(
167 ranked.map(({ entry }) => entry),
168 sourceSchemaTypes,
169 );
170
171 return {199 return {
172 bestPath: chooseBestPath(ranked, terms.length),200 bestPath: chooseBestPath(ranked, terms.length),
173 results,201 results,
174 ...(Object.keys(schemas).length > 0 ? { schemas } : {}),
175 total: results.length,202 total: results.length,
176 };203 };
177 },204 },
executor/packages/core/src/tool-discovery/formatting.ts+8−18
@@ -1,6 +1,3 @@
1import {
2 extractTopLevelTypeKeys,
3} from "../type-hints";
4import type { DiscoverIndexEntry } from "./types";1import type { DiscoverIndexEntry } from "./types";
5 2
6export function buildExampleCall(entry: DiscoverIndexEntry): string {3export function buildExampleCall(entry: DiscoverIndexEntry): string {
@@ -9,11 +6,11 @@ export function buildExampleCall(entry: DiscoverIndexEntry): string { · 2 unmodified lines
9 return `await tools.${callPath}({ query: "query { __typename }", variables: {} });`;6 return `await tools.${callPath}({ query: "query { __typename }", variables: {} });`;
10 }7 }
11 8
12 if (entry.argsType === "{}") {9 if (entry.displayInputHint === "{}") {
13 return `await tools.${callPath}({});`;10 return `await tools.${callPath}({});`;
14 }11 }
15 12
16 const keys = entry.argPreviewKeys.length > 0 ? entry.argPreviewKeys : extractTopLevelTypeKeys(entry.argsType);13 const keys = entry.previewInputKeys;
17 if (keys.length > 0) {14 if (keys.length > 0) {
18 const argsSnippet = keys15 const argsSnippet = keys
19 .slice(0, 5)16 .slice(0, 5)
@@ -32,8 +29,8 @@ export function formatSignature(entry: DiscoverIndexEntry, depth: number, compac · 12 unmodified lines
32 return "(input: ...): Promise<...>";29 return "(input: ...): Promise<...>";
33 }30 }
34 31
35 const args = entry.displayArgsType;32 const args = entry.displayInputHint;
36 const returns = entry.displayReturnsType;33 const returns = entry.displayOutputHint;
37 34
38 if (depth === 1) {35 if (depth === 1) {
39 return `(input: ${args}): Promise<${returns}>`;36 return `(input: ${args}): Promise<${returns}>`;
@@ -43,23 +40,16 @@ export function formatSignature(entry: DiscoverIndexEntry, depth: number, compac · 3 unmodified lines
43 }40 }
44 41
45 if (depth <= 0) {42 if (depth <= 0) {
46 return `(input: ${entry.argsType}): Promise<...>`;43 return `(input: ${entry.displayInputHint}): Promise<...>`;
47 }44 }
48 45
49 if (depth === 1) {46 if (depth === 1) {
50 return `(input: ${entry.argsType}): Promise<${entry.returnsType}>`;47 return `(input: ${entry.displayInputHint}): Promise<${entry.displayOutputHint}>`;
51 }48 }
52 49
53 return `(input: ${entry.argsType}): Promise<${entry.returnsType}> [source=${entry.source}]`;50 return `(input: ${entry.displayInputHint}): Promise<${entry.displayOutputHint}> [source=${entry.source}]`;
54}51}
55 52
56export function formatCanonicalSignature(entry: DiscoverIndexEntry): string {53export function formatCanonicalSignature(entry: DiscoverIndexEntry): string {
57 return `(input: ${entry.argsType}): Promise<${entry.returnsType}>`;54 return `(input: ${entry.displayInputHint}): Promise<${entry.displayOutputHint}>`;
58}
59
60export function buildExpandedShape(entry: DiscoverIndexEntry): { input: string; output: string } {
61 return {
62 input: entry.expandedArgsShape,
63 output: entry.expandedReturnsShape,
64 };
65}55}
executor/packages/core/src/tool-discovery/indexing.ts+39−31
@@ -1,10 +1,7 @@
1import type { ToolDefinition } from "../types";1import type { ToolDefinition } from "../types";
2import {2import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";
3 compactArgDisplayHint,3import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
4 compactReturnTypeHint,4import { sanitizeJsonSchemaForConvex } from "../tool-typing/convex-sanitize";
5 llmExpandedArgShapeHint,
6 llmExpandedReturnShapeHint,
7} from "../type-hints";
8import type { DiscoverIndexEntry } from "./types";5import type { DiscoverIndexEntry } from "./types";
9 6
10const GENERIC_NAMESPACE_SUFFIXES = new Set([7const GENERIC_NAMESPACE_SUFFIXES = new Set([
@@ -16,10 +13,17 @@ const GENERIC_NAMESPACE_SUFFIXES = new Set([ · 5 unmodified lines
16 "services",13 "services",
17]);14]);
18 15
19function normalizeType(type?: string): string {16function normalizeHint(type?: string): string {
20 return type && type.trim().length > 0 ? type : "unknown";17 return type && type.trim().length > 0 ? type : "unknown";
21}18}
22 19
20function isEmptyObjectSchema(schema: Record<string, unknown>): boolean {
21 if (Object.keys(schema).length === 0) return true;
22 const props = schema.properties && typeof schema.properties === "object" ? schema.properties as Record<string, unknown> : {};
23 const required = Array.isArray(schema.required) ? schema.required : [];
24 return Object.keys(props).length === 0 && required.length === 0;
25}
26
23export function normalizeSearchToken(value: string): string {27export function normalizeSearchToken(value: string): string {
24 return value.toLowerCase().replace(/[^a-z0-9]/g, "");28 return value.toLowerCase().replace(/[^a-z0-9]/g, "");
25}29}
@@ -99,24 +103,29 @@ export function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] { · 73 unmodified lines
99 const preferredPath = preferredToolPath(tool.path);103 const preferredPath = preferredToolPath(tool.path);
100 const aliases = getPathAliases(tool.path);104 const aliases = getPathAliases(tool.path);
101 const searchText = `${tool.path} ${preferredPath} ${aliases.join(" ")} ${tool.description} ${tool.source ?? ""}`.toLowerCase();105 const searchText = `${tool.path} ${preferredPath} ${aliases.join(" ")} ${tool.description} ${tool.source ?? ""}`.toLowerCase();
102 const argsType = normalizeType(tool.metadata?.argsType);106
103 const returnsType = normalizeType(tool.metadata?.returnsType);107 const inputSchema = (tool.typing?.inputSchema && typeof tool.typing.inputSchema === "object")
104 const argPreviewKeys = Array.isArray(tool.metadata?.argPreviewKeys)108 ? tool.typing.inputSchema
105 ? tool.metadata.argPreviewKeys.filter((value): value is string => typeof value === "string")109 : {};
106 : [];110 const outputSchema = (tool.typing?.outputSchema && typeof tool.typing.outputSchema === "object")
107 const displayArgsType = normalizeType(111 ? tool.typing.outputSchema
108 tool.metadata?.displayArgsType112 : {};
109 ?? compactArgDisplayHint(argsType, argPreviewKeys),113
110 );114 const safeInputSchema = sanitizeJsonSchemaForConvex(inputSchema);
111 const displayReturnsType = normalizeType(115 const safeOutputSchema = sanitizeJsonSchemaForConvex(outputSchema);
112 tool.metadata?.displayReturnsType116
113 ?? compactReturnTypeHint(returnsType),117 const requiredInputKeys = Array.isArray(tool.typing?.requiredInputKeys)
114 );118 ? tool.typing!.requiredInputKeys!.filter((value): value is string => typeof value === "string")
115 const expandedArgsShape = normalizeType(119 : extractTopLevelRequiredKeys(inputSchema);
116 llmExpandedArgShapeHint(argsType, argPreviewKeys),120 const previewInputKeys = Array.isArray(tool.typing?.previewInputKeys)
121 ? tool.typing!.previewInputKeys!.filter((value): value is string => typeof value === "string")
122 : buildPreviewKeys(inputSchema);
123
124 const displayInputHint = normalizeHint(
125 isEmptyObjectSchema(inputSchema) ? "{}" : jsonSchemaTypeHintFallback(inputSchema),
117 );126 );
118 const expandedReturnsShape = normalizeType(127 const displayOutputHint = normalizeHint(
119 llmExpandedReturnShapeHint(returnsType),128 Object.keys(outputSchema).length === 0 ? "unknown" : jsonSchemaTypeHintFallback(outputSchema),
120 );129 );
121 130
122 return {131 return {
@@ -126,13 +135,12 @@ export function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] { · 3 unmodified lines
126 description: tool.description,135 description: tool.description,
127 approval: tool.approval,136 approval: tool.approval,
128 source: tool.source ?? "local",137 source: tool.source ?? "local",
129 argsType,138 inputSchema: safeInputSchema,
130 returnsType,139 outputSchema: safeOutputSchema,
131 displayArgsType,140 requiredInputKeys,
132 displayReturnsType,141 previewInputKeys,
133 expandedArgsShape,142 displayInputHint,
134 expandedReturnsShape,143 displayOutputHint,
135 argPreviewKeys,
136 searchText,144 searchText,
137 normalizedPath: normalizeSearchToken(tool.path),145 normalizedPath: normalizeSearchToken(tool.path),
138 normalizedSearchText: normalizeSearchToken(searchText),146 normalizedSearchText: normalizeSearchToken(searchText),
executor/packages/core/src/tool-discovery/schema-registry.ts+0−420
@@ -1,420 +0,0 @@
1import type { ToolDefinition } from "../types";
2import type { DiscoverIndexEntry } from "./types";
3
4function skipWhitespaceAndComments(input: string, start: number): number {
5 let index = start;
6
7 while (index < input.length) {
8 const char = input[index];
9 const next = input[index + 1];
10
11 if (char && /\s/.test(char)) {
12 index += 1;
13 continue;
14 }
15
16 if (char === "/" && next === "/") {
17 index += 2;
18 while (index < input.length && input[index] !== "\n") {
19 index += 1;
20 }
21 continue;
22 }
23
24 if (char === "/" && next === "*") {
25 index += 2;
26 while (index < input.length) {
27 if (input[index] === "*" && input[index + 1] === "/") {
28 index += 2;
29 break;
30 }
31 index += 1;
32 }
33 continue;
34 }
35
36 break;
37 }
38
39 return index;
40}
41
42function findMatchingBrace(input: string, openIndex: number): number {
43 let depth = 0;
44 let quote: "\"" | "'" | "`" | null = null;
45 let inLineComment = false;
46 let inBlockComment = false;
47
48 for (let index = openIndex; index < input.length; index += 1) {
49 const char = input[index]!;
50 const next = input[index + 1];
51
52 if (inLineComment) {
53 if (char === "\n") {
54 inLineComment = false;
55 }
56 continue;
57 }
58
59 if (inBlockComment) {
60 if (char === "*" && next === "/") {
61 inBlockComment = false;
62 index += 1;
63 }
64 continue;
65 }
66
67 if (quote) {
68 if (char === "\\") {
69 index += 1;
70 continue;
71 }
72 if (char === quote) {
73 quote = null;
74 }
75 continue;
76 }
77
78 if (char === "/" && next === "/") {
79 inLineComment = true;
80 index += 1;
81 continue;
82 }
83
84 if (char === "/" && next === "*") {
85 inBlockComment = true;
86 index += 1;
87 continue;
88 }
89
90 if (char === "\"" || char === "'" || char === "`") {
91 quote = char;
92 continue;
93 }
94
95 if (char === "{") {
96 depth += 1;
97 continue;
98 }
99 if (char === "}") {
100 depth -= 1;
101 if (depth === 0) {
102 return index;
103 }
104 }
105 }
106
107 return -1;
108}
109
110function extractInterfaceBody(dts: string, interfaceName: string): string {
111 const pattern = new RegExp(`\\b(?:export\\s+)?interface\\s+${interfaceName}\\s*\\{`, "g");
112 const match = pattern.exec(dts);
113 if (!match) return "";
114
115 const openIndex = dts.indexOf("{", match.index);
116 if (openIndex === -1) return "";
117
118 const closeIndex = findMatchingBrace(dts, openIndex);
119 if (closeIndex === -1) return "";
120
121 return dts.slice(openIndex + 1, closeIndex);
122}
123
124function extractNestedObjectBody(input: string, key: string): string {
125 const pattern = new RegExp(`\\b${key}\\??\\s*:\\s*\\{`, "g");
126 const match = pattern.exec(input);
127 if (!match) return "";
128
129 const openIndex = input.indexOf("{", match.index);
130 if (openIndex === -1) return "";
131
132 const closeIndex = findMatchingBrace(input, openIndex);
133 if (closeIndex === -1) return "";
134
135 return input.slice(openIndex + 1, closeIndex);
136}
137
138function parseStringLiteral(raw: string): string {
139 if (raw.startsWith("\"") && raw.endsWith("\"")) {
140 try {
141 return JSON.parse(raw);
142 } catch {
143 return raw.slice(1, -1);
144 }
145 }
146
147 if (raw.startsWith("'") && raw.endsWith("'")) {
148 return raw.slice(1, -1).replace(/\\'/g, "'");
149 }
150
151 return raw;
152}
153
154function readQuotedToken(input: string, start: number): { value: string; next: number } | null {
155 const quote = input[start];
156 if (quote !== "\"" && quote !== "'") return null;
157
158 let index = start + 1;
159 while (index < input.length) {
160 const char = input[index];
161 if (char === "\\") {
162 index += 2;
163 continue;
164 }
165 if (char === quote) {
166 const raw = input.slice(start, index + 1);
167 return { value: parseStringLiteral(raw), next: index + 1 };
168 }
169 index += 1;
170 }
171
172 return null;
173}
174
175function readIdentifierToken(input: string, start: number): { value: string; next: number } | null {
176 const first = input[start];
177 if (!first || !/[A-Za-z_$]/.test(first)) return null;
178
179 let index = start + 1;
180 while (index < input.length && /[A-Za-z0-9_$]/.test(input[index]!)) {
181 index += 1;
182 }
183
184 return { value: input.slice(start, index), next: index };
185}
186
187function readTypeExpression(input: string, start: number): { expression: string; next: number } {
188 let index = start;
189 let depthCurly = 0;
190 let depthSquare = 0;
191 let depthParen = 0;
192 let depthAngle = 0;
193 let quote: "\"" | "'" | "`" | null = null;
194 let inLineComment = false;
195 let inBlockComment = false;
196
197 while (index < input.length) {
198 const char = input[index]!;
199 const next = input[index + 1];
200
201 if (inLineComment) {
202 if (char === "\n") {
203 inLineComment = false;
204 }
205 index += 1;
206 continue;
207 }
208
209 if (inBlockComment) {
210 if (char === "*" && next === "/") {
211 inBlockComment = false;
212 index += 2;
213 continue;
214 }
215 index += 1;
216 continue;
217 }
218
219 if (quote) {
220 if (char === "\\") {
221 index += 2;
222 continue;
223 }
224 if (char === quote) {
225 quote = null;
226 }
227 index += 1;
228 continue;
229 }
230
231 if (char === "/" && next === "/") {
232 inLineComment = true;
233 index += 2;
234 continue;
235 }
236
237 if (char === "/" && next === "*") {
238 inBlockComment = true;
239 index += 2;
240 continue;
241 }
242
243 if (char === "\"" || char === "'" || char === "`") {
244 quote = char;
245 index += 1;
246 continue;
247 }
248
249 if (char === "{") depthCurly += 1;
250 else if (char === "}") depthCurly = Math.max(0, depthCurly - 1);
251 else if (char === "[") depthSquare += 1;
252 else if (char === "]") depthSquare = Math.max(0, depthSquare - 1);
253 else if (char === "(") depthParen += 1;
254 else if (char === ")") depthParen = Math.max(0, depthParen - 1);
255 else if (char === "<") depthAngle += 1;
256 else if (char === ">") depthAngle = Math.max(0, depthAngle - 1);
257 else if (
258 char === ";"
259 && depthCurly === 0
260 && depthSquare === 0
261 && depthParen === 0
262 && depthAngle === 0
263 ) {
264 break;
265 }
266
267 index += 1;
268 }
269
270 return {
271 expression: input.slice(start, index).trim(),
272 next: index,
273 };
274}
275
276function parseSchemaTypeMap(schemasBlock: string): Record<string, string> {
277 const map: Record<string, string> = {};
278 let index = 0;
279
280 while (index < schemasBlock.length) {
281 index = skipWhitespaceAndComments(schemasBlock, index);
282 if (index >= schemasBlock.length) break;
283
284 const char = schemasBlock[index];
285 if (!char) break;
286
287 if (char === ";" || char === ",") {
288 index += 1;
289 continue;
290 }
291
292 if (char === "[") {
293 while (index < schemasBlock.length && schemasBlock[index] !== ";") {
294 index += 1;
295 }
296 if (schemasBlock[index] === ";") index += 1;
297 continue;
298 }
299
300 const keyToken = readQuotedToken(schemasBlock, index) ?? readIdentifierToken(schemasBlock, index);
301 if (!keyToken) {
302 index += 1;
303 continue;
304 }
305
306 const key = keyToken.value.trim();
307 index = skipWhitespaceAndComments(schemasBlock, keyToken.next);
308 if (schemasBlock[index] === "?") {
309 index = skipWhitespaceAndComments(schemasBlock, index + 1);
310 }
311
312 if (schemasBlock[index] !== ":") {
313 index += 1;
314 continue;
315 }
316
317 index = skipWhitespaceAndComments(schemasBlock, index + 1);
318 const { expression, next } = readTypeExpression(schemasBlock, index);
319 if (expression) {
320 map[key] = expression;
321 }
322
323 index = next;
324 if (schemasBlock[index] === ";" || schemasBlock[index] === ",") {
325 index += 1;
326 }
327 }
328
329 return map;
330}
331
332export function extractSchemaRefKeys(typeExpression: string): string[] {
333 const refs = new Set<string>();
334 const pattern = /components\["schemas"\]\["((?:\\.|[^"\\])*)"\]/g;
335
336 for (const match of typeExpression.matchAll(pattern)) {
337 const raw = match[1];
338 if (!raw) continue;
339
340 let key = raw;
341 try {
342 key = JSON.parse(`"${raw}"`);
343 } catch {
344 // Keep original escaped key if decode fails.
345 }
346
347 refs.add(key);
348 }
349
350 return [...refs];
351}
352
353function formatSchemaRefToken(schemaKey: string): string {
354 return `components["schemas"][${JSON.stringify(schemaKey)}]`;
355}
356
357export function extractSourceSchemaTypesFromDts(dts: string): Record<string, string> {
358 const componentsBody = extractInterfaceBody(dts, "components");
359 if (!componentsBody) return {};
360
361 const schemasBody = extractNestedObjectBody(componentsBody, "schemas");
362 if (!schemasBody) return {};
363
364 return parseSchemaTypeMap(schemasBody);
365}
366
367export function buildSourceSchemaTypeMap(tools: ToolDefinition[]): Record<string, Record<string, string>> {
368 const sourceDtsBySource: Record<string, string> = {};
369
370 for (const tool of tools) {
371 if (!tool.source || sourceDtsBySource[tool.source]) continue;
372 const dts = tool.metadata?.sourceDts?.trim();
373 if (dts) {
374 sourceDtsBySource[tool.source] = dts;
375 }
376 }
377
378 const bySource: Record<string, Record<string, string>> = {};
379
380 for (const [source, dts] of Object.entries(sourceDtsBySource)) {
381 const schemaMap = extractSourceSchemaTypesFromDts(dts);
382 if (Object.keys(schemaMap).length > 0) {
383 bySource[source] = schemaMap;
384 }
385 }
386
387 return bySource;
388}
389
390export function buildSchemaRegistryForEntries(
391 entries: DiscoverIndexEntry[],
392 sourceSchemaTypes: Record<string, Record<string, string>>,
393): Record<string, Record<string, string>> {
394 const registry: Record<string, Record<string, string>> = {};
395
396 for (const entry of entries) {
397 const sourceSchemas = sourceSchemaTypes[entry.source];
398 if (!sourceSchemas) continue;
399
400 const refKeys = new Set<string>([
401 ...extractSchemaRefKeys(entry.argsType),
402 ...extractSchemaRefKeys(entry.returnsType),
403 ]);
404
405 if (refKeys.size === 0) continue;
406
407 const sourceRegistry = registry[entry.source] ?? {};
408 for (const key of refKeys) {
409 const schemaType = sourceSchemas[key];
410 if (!schemaType) continue;
411 sourceRegistry[formatSchemaRefToken(key)] = schemaType;
412 }
413
414 if (Object.keys(sourceRegistry).length > 0) {
415 registry[entry.source] = sourceRegistry;
416 }
417 }
418
419 return registry;
420}
executor/packages/core/src/tool-discovery/types.ts+7−8
@@ -1,4 +1,4 @@
1import type { ToolDefinition } from "../types";1import type { JsonSchema, ToolDefinition } from "../types";
2 2
3export interface DiscoverIndexEntry {3export interface DiscoverIndexEntry {
4 path: string;4 path: string;
@@ -7,13 +7,12 @@ export interface DiscoverIndexEntry { · 2 unmodified lines
7 description: string;7 description: string;
8 approval: ToolDefinition["approval"];8 approval: ToolDefinition["approval"];
9 source: string;9 source: string;
10 argsType: string;10 inputSchema: JsonSchema;
11 returnsType: string;11 outputSchema: JsonSchema;
12 displayArgsType: string;12 requiredInputKeys: string[];
13 displayReturnsType: string;13 previewInputKeys: string[];
14 expandedArgsShape: string;14 displayInputHint: string;
15 expandedReturnsShape: string;15 displayOutputHint: string;
16 argPreviewKeys: string[];
17 searchText: string;16 searchText: string;
18 normalizedPath: string;17 normalizedPath: string;
19 normalizedSearchText: string;18 normalizedSearchText: string;
executor/packages/core/src/tool-source-loaders/graphql-loader.ts+136−18
@@ -2,8 +2,6 @@ · 1 unmodified line
2 2
3import {3import {
4 buildFieldQuery,4 buildFieldQuery,
5 gqlFieldArgsTypeHint,
6 gqlTypeToHint,
7 normalizeGraphqlFieldVariables,5 normalizeGraphqlFieldVariables,
8 selectGraphqlFieldEnvelope,6 selectGraphqlFieldEnvelope,
9 type GqlSchema,7 type GqlSchema,
@@ -16,7 +14,7 @@ import { · 6 unmodified lines
16import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";14import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
17import { sanitizeSegment } from "../tool/path-utils";15import { sanitizeSegment } from "../tool/path-utils";
18import type { GraphqlToolSourceConfig } from "../tool/source-types";16import type { GraphqlToolSourceConfig } from "../tool/source-types";
19import { compactArgTypeHint } from "../type-hints";17import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
20import type { ToolDefinition } from "../types";18import type { ToolDefinition } from "../types";
21import { asRecord } from "../utils";19import { asRecord } from "../utils";
22 20
@@ -56,6 +54,107 @@ const INTROSPECTION_QUERY = ` · 33 unmodified lines
56 }54 }
57`;55`;
58 56
57function unwrapNonNull(ref: { kind: string; ofType?: unknown } | null | undefined): unknown {
58 if (!ref || typeof ref !== "object") return ref;
59 if (ref.kind === "NON_NULL" && ref.ofType) return unwrapNonNull(ref.ofType as any);
60 return ref;
61}
62
63function schemaForNamedScalar(name: string): Record<string, unknown> {
64 switch (name) {
65 case "String":
66 case "ID":
67 case "DateTime":
68 case "Date":
69 case "UUID":
70 case "TimelessDate":
71 return { type: "string" };
72 case "Int":
73 case "Float":
74 return { type: "number" };
75 case "Boolean":
76 return { type: "boolean" };
77 case "JSON":
78 case "JSONObject":
79 case "JSONString":
80 return {};
81 default:
82 return {};
83 }
84}
85
86function schemaForTypeRef(
87 ref: { kind: string; name: string | null; ofType?: unknown } | null | undefined,
88 typeMap: Map<string, GqlType>,
89 depth = 0,
90): Record<string, unknown> {
91 if (!ref || typeof ref !== "object") return {};
92
93 if (ref.kind === "NON_NULL" && ref.ofType) {
94 return schemaForTypeRef(ref.ofType as any, typeMap, depth);
95 }
96
97 if (ref.kind === "LIST" && ref.ofType) {
98 return {
99 type: "array",
100 items: schemaForTypeRef(ref.ofType as any, typeMap, depth),
101 };
102 }
103
104 const name = typeof ref.name === "string" ? ref.name : "";
105 if (!name) return {};
106
107 const resolved = typeMap.get(name);
108 if (resolved?.kind === "ENUM" && resolved.enumValues && resolved.enumValues.length > 0) {
109 return { type: "string", enum: resolved.enumValues.map((v) => v.name).slice(0, 200) };
110 }
111
112 if (resolved?.kind === "INPUT_OBJECT" && resolved.inputFields && depth < 2) {
113 const props: Record<string, unknown> = {};
114 const required: string[] = [];
115 for (const field of resolved.inputFields) {
116 if (!field?.name) continue;
117 props[field.name] = schemaForTypeRef(field.type as any, typeMap, depth + 1);
118 const unwrapped = unwrapNonNull(field.type as any) as any;
119 if ((field.type as any)?.kind === "NON_NULL") {
120 required.push(field.name);
121 }
122 // Prevent unused variable lint: unwrapped is kept for future enhancements.
123 void unwrapped;
124 }
125 return {
126 type: "object",
127 properties: props,
128 ...(required.length > 0 ? { required } : {}),
129 };
130 }
131
132 return schemaForNamedScalar(name);
133}
134
135function buildArgsObjectSchema(
136 args: Array<{ name: string; type: unknown }>,
137 typeMap: Map<string, GqlType>,
138): Record<string, unknown> {
139 const props: Record<string, unknown> = {};
140 const required: string[] = [];
141
142 for (const arg of args) {
143 const name = typeof arg?.name === "string" ? arg.name : "";
144 if (!name) continue;
145 props[name] = schemaForTypeRef(arg.type as any, typeMap);
146 if ((arg.type as any)?.kind === "NON_NULL") {
147 required.push(name);
148 }
149 }
150
151 return {
152 type: "object",
153 properties: props,
154 ...(required.length > 0 ? { required } : {}),
155 };
156}
157
59export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDefinition[]> {158export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDefinition[]> {
60 const authHeaders = buildStaticAuthHeaders(config.auth);159 const authHeaders = buildStaticAuthHeaders(config.auth);
61 const sourceKey = `graphql:${config.name}`;160 const sourceKey = `graphql:${config.name}`;
@@ -101,12 +200,25 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 39 unmodified lines
101 source: sourceKey,200 source: sourceKey,
102 description: `Execute a GraphQL query or mutation against ${config.name}. Returns { data, errors }. Use ${sourceName}.query.* and ${sourceName}.mutation.* helpers when available.`,201 description: `Execute a GraphQL query or mutation against ${config.name}. Returns { data, errors }. Use ${sourceName}.query.* and ${sourceName}.mutation.* helpers when available.`,
103 approval: "auto", // Actual approval is determined dynamically per-invocation202 approval: "auto", // Actual approval is determined dynamically per-invocation
104 metadata: {203 typing: {
105 argsType: "{ query: string; variables?: Record<string, unknown> }",204 inputSchema: {
106 returnsType: "{ data: unknown; errors: unknown[] }",205 type: "object",
107 displayArgsType: "{ query: string; variables?: ... }",206 properties: {
108 displayReturnsType: "{ data: ...; errors: unknown[] }",207 query: { type: "string" },
109 argPreviewKeys: ["query", "variables"],208 variables: {},
209 },
210 required: ["query"],
211 },
212 outputSchema: {
213 type: "object",
214 properties: {
215 data: {},
216 errors: { type: "array", items: {} },
217 },
218 required: ["data"],
219 },
220 requiredInputKeys: ["query"],
221 previewInputKeys: ["query", "variables"],
110 },222 },
111 credential: credentialSpec,223 credential: credentialSpec,
112 // Tag as graphql source so invokeTool knows to do dynamic path extraction224 // Tag as graphql source so invokeTool knows to do dynamic path extraction
@@ -155,9 +267,9 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 42 unmodified lines
155 267
156 const fieldPath = `${sourceName}.${operationType}.${sanitizeSegment(field.name)}`;268 const fieldPath = `${sourceName}.${operationType}.${sanitizeSegment(field.name)}`;
157 const approval = config.overrides?.[field.name]?.approval ?? defaultApproval;269 const approval = config.overrides?.[field.name]?.approval ?? defaultApproval;
158 const argsType = gqlFieldArgsTypeHint(field.args, typeMap);270 const inputSchema = buildArgsObjectSchema(field.args as any, typeMap);
159 const returnsType = `{ data: ${gqlTypeToHint(field.type, typeMap)}; errors: unknown[] }`;271 const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema);
160 const argPreviewKeys = field.args.map((arg) => arg.name).filter((name) => name.length > 0);272 const previewInputKeys = buildPreviewKeys(inputSchema);
161 273
162 // Build the example query for the description274 // Build the example query for the description
163 const exampleQuery = buildFieldQuery(operationType, field.name, field.args, field.type, typeMap);275 const exampleQuery = buildFieldQuery(operationType, field.name, field.args, field.type, typeMap);
@@ -173,12 +285,18 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 9 unmodified lines
173 : `GraphQL ${operationType}: ${field.name}\n\nPreferred: ${directCallExample}\nReturns: { data, errors }\nRaw GraphQL: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`,285 : `GraphQL ${operationType}: ${field.name}\n\nPreferred: ${directCallExample}\nReturns: { data, errors }\nRaw GraphQL: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`,
174 approval,286 approval,
175 credential: credentialSpec,287 credential: credentialSpec,
176 metadata: {288 typing: {
177 argsType,289 inputSchema,
178 returnsType,290 outputSchema: {
179 displayArgsType: compactArgTypeHint(argsType),291 type: "object",
180 displayReturnsType: "{ data: ...; errors: unknown[] }",292 properties: {
181 ...(argPreviewKeys.length > 0 ? { argPreviewKeys } : {}),293 data: {},
294 errors: { type: "array", items: {} },
295 },
296 required: ["data"],
297 },
298 ...(requiredInputKeys.length > 0 ? { requiredInputKeys } : {}),
299 ...(previewInputKeys.length > 0 ? { previewInputKeys } : {}),
182 },300 },
183 _runSpec: {301 _runSpec: {
184 kind: "graphql_field" as const,302 kind: "graphql_field" as const,
executor/packages/core/src/tool-source-loaders/mcp-loader.ts+8−13
@@ -1,12 +1,11 @@
1"use node";1"use node";
2 2
3import { connectMcp, extractMcpResult } from "../mcp-runtime";3import { connectMcp, extractMcpResult } from "../mcp-runtime";
4import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";
5import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";4import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
6import { callMcpToolWithReconnect } from "../tool/source-execution";5import { callMcpToolWithReconnect } from "../tool/source-execution";
7import { sanitizeSegment } from "../tool/path-utils";6import { sanitizeSegment } from "../tool/path-utils";
8import type { McpToolSourceConfig } from "../tool/source-types";7import type { McpToolSourceConfig } from "../tool/source-types";
9import { compactArgTypeHint, compactReturnTypeHint } from "../type-hints";8import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
10import type { ToolDefinition } from "../types";9import type { ToolDefinition } from "../types";
11import { asRecord } from "../utils";10import { asRecord } from "../utils";
12import type { SerializedTool } from "../tool/source-serialization";11import type { SerializedTool } from "../tool/source-serialization";
@@ -59,22 +58,18 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 46 unmodified lines
59 const toolName = String(tool.name ?? "tool");58 const toolName = String(tool.name ?? "tool");
60 const inputSchema = asRecord(tool.inputSchema);59 const inputSchema = asRecord(tool.inputSchema);
61 const outputSchema = asRecord(tool.outputSchema);60 const outputSchema = asRecord(tool.outputSchema);
62 const argPreviewKeys = Object.keys(asRecord(inputSchema.properties)).filter((key) => key.length > 0);61 const previewInputKeys = buildPreviewKeys(inputSchema).filter((key) => key.length > 0);
63 const argsType = jsonSchemaTypeHintFallback(inputSchema);62 const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema);
64 const returnsType = Object.keys(outputSchema).length > 0
65 ? jsonSchemaTypeHintFallback(outputSchema)
66 : "unknown";
67 return {63 return {
68 path: `${sanitizeSegment(config.name)}.${sanitizeSegment(toolName)}`,64 path: `${sanitizeSegment(config.name)}.${sanitizeSegment(toolName)}`,
69 source: `mcp:${config.name}`,65 source: `mcp:${config.name}`,
70 approval: config.overrides?.[toolName]?.approval ?? config.defaultApproval ?? "auto",66 approval: config.overrides?.[toolName]?.approval ?? config.defaultApproval ?? "auto",
71 description: String(tool.description ?? `MCP tool ${toolName}`),67 description: String(tool.description ?? `MCP tool ${toolName}`),
72 metadata: {68 typing: {
73 argsType,69 inputSchema,
74 returnsType,70 ...(Object.keys(outputSchema).length > 0 ? { outputSchema } : {}),
75 displayArgsType: compactArgTypeHint(argsType),71 ...(requiredInputKeys.length > 0 ? { requiredInputKeys } : {}),
76 displayReturnsType: compactReturnTypeHint(returnsType),72 ...(previewInputKeys.length > 0 ? { previewInputKeys } : {}),
77 ...(argPreviewKeys.length > 0 ? { argPreviewKeys } : {}),
78 },73 },
79 credential: credentialSpec,74 credential: credentialSpec,
80 _runSpec: {75 _runSpec: {
executor/packages/core/src/tool-source-loaders/openapi-loader.ts+14−10
@@ -13,7 +13,6 @@ import { · 12 unmodified lines
13import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman-runtime";13import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman-runtime";
14import { prepareOpenApiSpec } from "../openapi-prepare";14import { prepareOpenApiSpec } from "../openapi-prepare";
15import type { OpenApiToolSourceConfig } from "../tool/source-types";15import type { OpenApiToolSourceConfig } from "../tool/source-types";
16import { compactArgTypeHint, compactReturnTypeHint } from "../type-hints";
17import type { ToolDefinition } from "../types";16import type { ToolDefinition } from "../types";
18import { asRecord } from "../utils";17import { asRecord } from "../utils";
19import type { SerializedTool } from "../tool/source-serialization";18import type { SerializedTool } from "../tool/source-serialization";
@@ -84,8 +83,16 @@ async function loadPostmanCollectionTools( · 64 unmodified lines
84 const readMethods = new Set(["get", "head", "options"]);83 const readMethods = new Set(["get", "head", "options"]);
85 const usedPaths = new Set<string>();84 const usedPaths = new Set<string>();
86 const collectionVariables = extractPostmanVariableMap(collection.variables);85 const collectionVariables = extractPostmanVariableMap(collection.variables);
87 const argsType = "{ variables?: Record<string, string | number | boolean>; query?: Record<string, string | number | boolean>; headers?: Record<string, string>; body?: unknown }";86 const inputSchema: Record<string, unknown> = {
88 const returnsType = "unknown";87 type: "object",
88 properties: {
89 variables: {},
90 query: {},
91 headers: {},
92 body: {},
93 },
94 };
95 const previewInputKeys = ["variables", "query", "headers", "body"];
89 96
90 const tools: ToolDefinition[] = [];97 const tools: ToolDefinition[] = [];
91 98
@@ -130,13 +137,10 @@ async function loadPostmanCollectionTools( · 38 unmodified lines
130 description: typeof request.description === "string" && request.description.trim().length > 0137 description: typeof request.description === "string" && request.description.trim().length > 0
131 ? request.description138 ? request.description
132 : `${method.toUpperCase()} ${url}`,139 : `${method.toUpperCase()} ${url}`,
133 metadata: {140 typing: {
134 argsType,141 inputSchema,
135 returnsType,142 outputSchema: {},
136 displayArgsType: compactArgTypeHint(argsType),143 previewInputKeys,
137 displayReturnsType: compactReturnTypeHint(returnsType),
138 argPreviewKeys: ["variables", "query", "headers", "body"],
139 operationId: requestId || requestName,
140 },144 },
141 credential: credentialSpec,145 credential: credentialSpec,
142 _runSpec: runSpec,146 _runSpec: runSpec,
executor/packages/core/src/tool-sources.test.ts+10−4
@@ -124,9 +124,12 @@ test("mcp tools use outputSchema for return type hints when available", async () · 123 unmodified lines
124 124
125 const tool = tools[0];125 const tool = tools[0];
126 expect(tool?.path).toBe("status.get_status");126 expect(tool?.path).toBe("status.get_status");
127 expect(tool?.metadata?.returnsType).toContain("ok");127 const output = tool?.typing?.outputSchema as Record<string, unknown> | undefined;
128 expect(tool?.metadata?.returnsType).toContain("retries");128 expect(output).toBeDefined();
129 expect(tool?.metadata?.displayReturnsType).toContain("ok");129 const props = output && typeof output === "object" ? (output.properties as Record<string, unknown> | undefined) : undefined;
130 expect(props).toBeDefined();
131 expect(Object.keys(props ?? {})).toContain("ok");
132 expect(Object.keys(props ?? {})).toContain("retries");
130 } finally {133 } finally {
131 mcp.server.stop(true);134 mcp.server.stop(true);
132 }135 }
@@ -791,7 +794,10 @@ test("openapi fallback type hints include index signature when truncated", async · 658 unmodified lines
791 expect(warnings).toHaveLength(0);794 expect(warnings).toHaveLength(0);
792 const getItems = tools.find((tool) => tool.path === "wide.items.get_items");795 const getItems = tools.find((tool) => tool.path === "wide.items.get_items");
793 expect(getItems).toBeDefined();796 expect(getItems).toBeDefined();
794 expect(getItems?.metadata?.returnsType).toContain("[key: string]: any");797 const outputSchema = getItems?.typing?.outputSchema as Record<string, unknown> | undefined;
798 expect(outputSchema).toBeDefined();
799 const props = outputSchema && typeof outputSchema === "object" ? (outputSchema.properties as Record<string, unknown> | undefined) : undefined;
800 expect(Object.keys(props ?? {}).length).toBeGreaterThan(12);
795});801});
796 802
797test("graphql helper tools inherit credential spec from source auth", async () => {803test("graphql helper tools inherit credential spec from source auth", async () => {
executor/packages/core/src/tool-typing/agent-signature-typebundle.e2e.test.ts+540−0
@@ -0,0 +1,540 @@
1import { describe, expect, test } from "bun:test";
2import { OPENAPI_HELPER_TYPES } from "../openapi/helper-types";
3import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";
4import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "../tool-sources";
5import { connectMcp } from "../mcp-runtime";
6import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
8
9type ToolApproval = "auto" | "required";
10
11type AgentToolSignature = {
12 path: string;
13 description: string;
14 approval: ToolApproval;
15 sourceKey: string;
16 inputSchema?: Record<string, unknown>;
17 outputSchema?: Record<string, unknown>;
18 typedRef?: {
19 kind: "openapi_operation";
20 namespace: string;
21 operationId: string;
22 };
23};
24
25function requireTypeScript(): typeof import("typescript") {
26 // eslint-disable-next-line @typescript-eslint/no-var-requires
27 return require("typescript") as typeof import("typescript");
28}
29
30function safeNamespaceSegment(value: string): string {
31 const cleaned = value.replace(/[^A-Za-z0-9_]/g, "_");
32 return cleaned.length > 0 ? cleaned : "source";
33}
34
35function indentBlock(value: string, spaces: number): string {
36 const pad = " ".repeat(spaces);
37 return value
38 .split("\n")
39 .map((line) => (line.trim().length === 0 ? line : pad + line))
40 .join("\n");
41}
42
43function wrapDtsInNamespace(namespace: string, rawDts: string): string {
44 const stripped = rawDts.replace(/^export /gm, "").trim();
45 return `declare namespace ${namespace} {\n${indentBlock(stripped, 2)}\n}`;
46}
47
48type NamespaceNode = {
49 children: Map<string, NamespaceNode>;
50 tools: AgentToolSignature[];
51};
52
53function buildTree(signatures: AgentToolSignature[]): NamespaceNode {
54 const root: NamespaceNode = { children: new Map(), tools: [] };
55 for (const sig of signatures) {
56 const parts = sig.path.split(".");
57 if (parts.length <= 1) {
58 root.tools.push(sig);
59 continue;
60 }
61
62 let node = root;
63 for (let i = 0; i < parts.length - 1; i += 1) {
64 const part = parts[i]!;
65 if (!node.children.has(part)) {
66 node.children.set(part, { children: new Map(), tools: [] });
67 }
68 node = node.children.get(part)!;
69 }
70 node.tools.push(sig);
71 }
72 return root;
73}
74
75const memberNameRegex = /^[$A-Z_][0-9A-Z_$]*$/i;
76function emitMemberName(name: string): string {
77 return memberNameRegex.test(name) ? name : JSON.stringify(name);
78}
79
80function countAllTools(node: NamespaceNode): number {
81 let count = node.tools.length;
82 for (const child of node.children.values()) {
83 count += countAllTools(child);
84 }
85 return count;
86}
87
88function tsTypeFromSchema(schema: Record<string, unknown> | undefined, fallback: string): string {
89 if (!schema || Object.keys(schema).length === 0) return fallback;
90 return jsonSchemaTypeHintFallback(schema);
91}
92
93function emitToolMethod(sig: AgentToolSignature): string {
94 const methodName = emitMemberName(sig.path.split(".").at(-1) ?? "tool");
95 const approvalNote = sig.approval === "required" ? " **Requires approval**" : "";
96 const desc = (sig.description || "Call this tool.") + approvalNote;
97
98 let inputType = "Record<string, unknown>";
99 let outputType = "unknown";
100
101 if (sig.typedRef?.kind === "openapi_operation") {
102 const opKey = JSON.stringify(sig.typedRef.operationId);
103 inputType = `ToolInput<${sig.typedRef.namespace}.operations[${opKey}]>`;
104 outputType = `ToolOutput<${sig.typedRef.namespace}.operations[${opKey}]>`;
105 } else {
106 inputType = tsTypeFromSchema(sig.inputSchema, "Record<string, unknown>");
107 outputType = tsTypeFromSchema(sig.outputSchema, "unknown");
108 }
109
110 const isOptionalInput = inputType === "{}";
111 const inputParam = isOptionalInput ? `input?: ${inputType}` : `input: ${inputType}`;
112
113 return ` /**\n * ${desc}\n * @source ${sig.sourceKey}\n */\n ${methodName}(${inputParam}): Promise<${outputType}>;`;
114}
115
116function emitNamespaceInterface(name: string, node: NamespaceNode, out: string[]): void {
117 for (const [childName, childNode] of node.children) {
118 emitNamespaceInterface(`${name}_${childName}`, childNode, out);
119 }
120
121 const members: string[] = [];
122 for (const [childName, childNode] of node.children) {
123 const toolCount = childNode.tools.length + countAllTools(childNode);
124 members.push(
125 ` /** ${toolCount} tool${toolCount !== 1 ? "s" : ""} in the \`${childName}\` namespace */\n readonly ${emitMemberName(childName)}: ToolNS_${name}_${childName};`,
126 );
127 }
128 for (const tool of node.tools) {
129 members.push(emitToolMethod(tool));
130 }
131
132 out.push(`interface ToolNS_${name} {\n${members.join("\n\n")}\n}`);
133}
134
135function emitToolsProxyDts(signatures: AgentToolSignature[]): string {
136 const root = buildTree(signatures);
137
138 const interfaces: string[] = [];
139 for (const [name, node] of root.children) {
140 emitNamespaceInterface(name, node, interfaces);
141 }
142
143 const rootMembers: string[] = [];
144 for (const [name] of root.children) {
145 rootMembers.push(` readonly ${emitMemberName(name)}: ToolNS_${name};`);
146 }
147 for (const tool of root.tools) {
148 rootMembers.push(emitToolMethod(tool));
149 }
150
151 return [
152 interfaces.join("\n\n"),
153 "",
154 "interface ToolsProxy {",
155 rootMembers.join("\n\n"),
156 "}",
157 "",
158 "declare const tools: ToolsProxy;",
159 ]
160 .filter((part) => part.trim().length > 0)
161 .join("\n");
162}
163
164async function fetchGraphqlSchema(endpoint: string): Promise<Record<string, unknown>> {
165 const INTROSPECTION_QUERY = `
166 query IntrospectionQuery {
167 __schema {
168 queryType { name }
169 mutationType { name }
170 types {
171 kind name
172 fields {
173 name description
174 args { name description type { ...TypeRef } defaultValue }
175 type { ...TypeRef }
176 }
177 inputFields {
178 name description
179 type { ...TypeRef }
180 defaultValue
181 }
182 enumValues { name description }
183 }
184 }
185 }
186 fragment TypeRef on __Type {
187 kind name
188 ofType {
189 kind name
190 ofType {
191 kind name
192 ofType {
193 kind name
194 ofType { kind name }
195 }
196 }
197 }
198 }
199 `;
200
201 const resp = await fetch(endpoint, {
202 method: "POST",
203 headers: { "content-type": "application/json" },
204 body: JSON.stringify({ query: INTROSPECTION_QUERY }),
205 });
206 if (!resp.ok) {
207 const text = await resp.text().catch(() => "");
208 throw new Error(`GraphQL introspection failed: HTTP ${resp.status}: ${text.slice(0, 200)}`);
209 }
210 const json = (await resp.json()) as { data?: { __schema?: unknown }; errors?: unknown };
211 if (json.errors) {
212 throw new Error(`GraphQL introspection errors: ${JSON.stringify(json.errors).slice(0, 200)}`);
213 }
214 const schema = json.data?.__schema;
215 if (!schema || typeof schema !== "object") {
216 throw new Error("GraphQL introspection returned no schema");
217 }
218 return schema as Record<string, unknown>;
219}
220
221function gqlUnwrapNamedType(ref: any): string | null {
222 if (!ref || typeof ref !== "object") return null;
223 if (ref.kind === "NON_NULL" && ref.ofType) return gqlUnwrapNamedType(ref.ofType);
224 if (ref.kind === "LIST" && ref.ofType) return gqlUnwrapNamedType(ref.ofType);
225 return typeof ref.name === "string" ? ref.name : null;
226}
227
228function gqlIsNonNull(ref: any): boolean {
229 return Boolean(ref && typeof ref === "object" && ref.kind === "NON_NULL");
230}
231
232function gqlScalarToJsonSchemaType(name: string): Record<string, unknown> {
233 switch (name) {
234 case "String":
235 case "ID":
236 case "DateTime":
237 case "Date":
238 case "UUID":
239 return { type: "string" };
240 case "Int":
241 case "Float":
242 return { type: "number" };
243 case "Boolean":
244 return { type: "boolean" };
245 default:
246 return {};
247 }
248}
249
250function buildGraphqlArgsSchema(fieldArgs: any[]): Record<string, unknown> {
251 const properties: Record<string, unknown> = {};
252 const required: string[] = [];
253 for (const arg of fieldArgs) {
254 const name = typeof arg?.name === "string" ? arg.name : "";
255 if (!name) continue;
256 const typeRef = arg.type;
257 const named = gqlUnwrapNamedType(typeRef);
258 properties[name] = named ? gqlScalarToJsonSchemaType(named) : {};
259 if (gqlIsNonNull(typeRef)) {
260 required.push(name);
261 }
262 }
263 return {
264 type: "object",
265 properties,
266 ...(required.length > 0 ? { required } : {}),
267 };
268}
269
270async function startLocalMcpServer(): Promise<{ url: string; stop: () => Promise<void> }> {
271 const server = Bun.serve({
272 port: 0,
273 fetch: async (request) => {
274 const transport = new WebStandardStreamableHTTPServerTransport({
275 sessionIdGenerator: undefined,
276 enableJsonResponse: true,
277 });
278 const mcp = new McpServer(
279 { name: "mcp-typing-fixture", version: "0.0.1" },
280 { capabilities: { tools: {} } },
281 );
282
283 mcp.registerTool(
284 "create_issue",
285 {
286 description: "Create an issue in a repository (fixture).",
287 // MCP SDK typing expects a Zod-ish schema type; runtime accepts JSON Schema.
288 inputSchema: {
289 type: "object",
290 properties: {
291 owner: { type: "string" },
292 repo: { type: "string" },
293 title: { type: "string" },
294 body: { type: "string" },
295 labels: { type: "array", items: { type: "string" } },
296 state: { type: "string", enum: ["open", "closed"] },
297 },
298 required: ["owner", "repo", "title"],
299 } as unknown as any,
300 },
301 async () => {
302 return { content: [{ type: "text" as const, text: "ok" }] };
303 },
304 );
305
306 try {
307 await mcp.connect(transport);
308 return await transport.handleRequest(request);
309 } finally {
310 await transport.close().catch(() => {});
311 await mcp.close().catch(() => {});
312 }
313 },
314 });
315
316 const url = `http://127.0.0.1:${server.port}`;
317 return {
318 url,
319 stop: async () => {
320 server.stop(true);
321 },
322 };
323}
324
325describe("agent signature -> monaco typebundle (exploration)", () => {
326 test(
327 "real OpenAPI + real GraphQL + real MCP server can produce a parseable .d.ts bundle",
328 async () => {
329 // --- OpenAPI (real) ---
330 const openApiFixtures = [
331 {
332 name: "vercel",
333 spec: "https://openapi.vercel.sh",
334 defaultBaseUrl: "https://api.vercel.com",
335 },
336 {
337 // Small, stable spec used by many OpenAPI toolchains.
338 name: "petstore",
339 spec: "https://petstore3.swagger.io/api/v3/openapi.json",
340 defaultBaseUrl: "https://petstore3.swagger.io",
341 },
342 ] as const;
343
344 const preparedSpecs = await Promise.all(
345 openApiFixtures.map(async (fixture) => {
346 const prepared = await prepareOpenApiSpec(fixture.spec, fixture.name);
347 expect(prepared.dts).toBeDefined();
348 expect(prepared.dts!.length).toBeGreaterThan(0);
349
350 const tools = buildOpenApiToolsFromPrepared(
351 {
352 type: "openapi",
353 name: fixture.name,
354 spec: fixture.spec,
355 baseUrl: prepared.servers[0] ?? fixture.defaultBaseUrl,
356 },
357 prepared,
358 );
359
360 const tool = tools.find((t) => t.typing?.typedRef?.kind === "openapi_operation")!;
361 expect(tool).toBeDefined();
362 return { fixture, prepared, tools, tool };
363 }),
364 );
365
366 const openApiSignatures: AgentToolSignature[] = preparedSpecs.map(({ fixture, prepared: _prepared, tool }) => {
367 const sourceKey = `openapi:${fixture.name}`;
368 const namespace = `OpenApi_${safeNamespaceSegment(sourceKey)}`;
369 const sig: AgentToolSignature = {
370 path: tool.path,
371 description: tool.description,
372 approval: "auto",
373 sourceKey,
374 typedRef: {
375 kind: "openapi_operation",
376 namespace,
377 operationId: tool.typing!.typedRef!.operationId,
378 },
379 };
380 expect(sig.typedRef).toBeDefined();
381 expect(sig.typedRef!.namespace).toBe(namespace);
382 return sig;
383 });
384
385 // --- GraphQL (real) ---
386 const countriesEndpoint = "https://countries.trevorblades.com/";
387 const gqlSchema = await fetchGraphqlSchema(countriesEndpoint);
388 const types = Array.isArray(gqlSchema.types) ? gqlSchema.types : [];
389 const queryTypeName = (gqlSchema.queryType as any)?.name as string | undefined;
390 expect(typeof queryTypeName).toBe("string");
391
392 const queryType = types.find((t: any) => t && t.name === queryTypeName);
393 const fields = Array.isArray((queryType as any)?.fields) ? (queryType as any).fields : [];
394 const countryField = fields.find((f: any) => f && f.name === "country");
395 expect(countryField).toBeDefined();
396
397 const gqlArgsSchema = buildGraphqlArgsSchema(countryField.args);
398 const gqlSignature: AgentToolSignature = {
399 path: "countries.query.country",
400 description: "GraphQL query helper (fixture from real schema).",
401 approval: "auto",
402 sourceKey: "graphql:countries",
403 inputSchema: gqlArgsSchema,
404 outputSchema: {
405 type: "object",
406 properties: {
407 data: {},
408 errors: { type: "array", items: {} },
409 },
410 required: ["data"],
411 },
412 };
413
414 // --- MCP (real server instance) ---
415 const mcp = await startLocalMcpServer();
416 let mcpTool: any;
417 try {
418 const conn = await connectMcp(`${mcp.url}/mcp`, undefined, "streamable-http");
419 try {
420 const listed = await conn.client.listTools();
421 const tools = Array.isArray((listed as any).tools) ? (listed as any).tools : [];
422 mcpTool = tools.find((t: any) => t && t.name === "create_issue");
423 expect(mcpTool).toBeDefined();
424 } finally {
425 await conn.close();
426 }
427 } finally {
428 await mcp.stop();
429 }
430
431 const mcpSignature: AgentToolSignature = {
432 path: "mcp_fixture.create_issue",
433 description: String(mcpTool.description ?? "MCP create_issue"),
434 approval: "required",
435 sourceKey: "mcp:fixture",
436 inputSchema: (mcpTool.inputSchema ?? {}) as Record<string, unknown>,
437 outputSchema: {},
438 };
439
440 // --- Emit bundle ---
441 const toolsDts = emitToolsProxyDts([...openApiSignatures, gqlSignature, mcpSignature]);
442 for (const sig of openApiSignatures) {
443 expect(toolsDts).toContain(`@source ${sig.sourceKey}`);
444 }
445
446 const openApiDtsBlocks = preparedSpecs.map(({ fixture, prepared }) => {
447 const sourceKey = `openapi:${fixture.name}`;
448 const namespace = `OpenApi_${safeNamespaceSegment(sourceKey)}`;
449 return wrapDtsInNamespace(namespace, prepared.dts!);
450 });
451 const bundle = [
452 "// Generated for exploration test only",
453 OPENAPI_HELPER_TYPES.trim(),
454 ...openApiDtsBlocks,
455 toolsDts,
456 ].join("\n\n");
457
458 // Parse with TypeScript to ensure the .d.ts is syntactically valid.
459 const ts = requireTypeScript();
460
461 const fileName = "bundle.d.ts";
462 const host: import("typescript").CompilerHost = {
463 getSourceFile: (name, languageVersion) => {
464 if (name === fileName) {
465 return ts.createSourceFile(name, bundle, languageVersion, true, ts.ScriptKind.TS);
466 }
467 return undefined as any;
468 },
469 writeFile: () => {},
470 getDefaultLibFileName: () => "lib.d.ts",
471 useCaseSensitiveFileNames: () => true,
472 getCanonicalFileName: (name) => name,
473 getCurrentDirectory: () => ".",
474 getNewLine: () => "\n",
475 fileExists: (name) => name === fileName,
476 readFile: (name) => (name === fileName ? bundle : undefined),
477 directoryExists: () => true,
478 getDirectories: () => [],
479 };
480
481 const program = ts.createProgram({
482 rootNames: [fileName],
483 options: { noLib: true, skipLibCheck: true, noEmit: true },
484 host,
485 });
486
487 const syntactic = program.getSyntacticDiagnostics();
488 expect(syntactic.length).toBe(0);
489
490 const sf = program.getSourceFile(fileName);
491 expect(sf).toBeDefined();
492
493 // Ensure we didn't leak top-level `interface operations` (it should be namespaced).
494 const topLevelOperations = (sf!.statements ?? []).filter(
495 (stmt) => ts.isInterfaceDeclaration(stmt) && stmt.name.text === "operations",
496 );
497 expect(topLevelOperations.length).toBe(0);
498
499 // Spot-check OpenAPI types are namespaced (avoids `operations` collisions).
500 for (const sig of openApiSignatures) {
501 const ns = sig.typedRef!.namespace;
502 expect(bundle).toContain(`declare namespace ${ns}`);
503 expect(bundle).toContain(`@source ${sig.sourceKey}`);
504 }
505 // Ensure both OpenAPI namespaces made it into the ToolsProxy.
506 expect(bundle).toContain("readonly vercel: ToolNS_vercel;");
507 expect(bundle).toContain("readonly petstore: ToolNS_petstore;");
508 // Ensure the tool methods reference the namespaced OpenAPI operations map.
509 expect(bundle).toMatch(/OpenApi_[A-Za-z0-9_]+\.operations\[/);
510 expect(bundle).toMatch(/ToolInput<OpenApi_[A-Za-z0-9_]+\.operations\[/);
511 expect(bundle).toMatch(/ToolOutput<OpenApi_[A-Za-z0-9_]+\.operations\[/);
512 expect(bundle).toContain("declare const tools");
513 // Spot-check JSON-schema-derived MCP signature made it into the bundle.
514 expect(bundle).toContain("owner: string");
515 // Spot-check JSON-schema-derived GraphQL args schema made it into the bundle.
516 expect(bundle).toContain("code: string");
517 },
518 300_000,
519 );
520
521 test("JSON schema fallback produces useful MCP-ish arg hints", () => {
522 const schema = {
523 type: "object",
524 properties: {
525 owner: { type: "string" },
526 repo: { type: "string" },
527 labels: { type: "array", items: { type: "string" } },
528 state: { type: "string", enum: ["open", "closed"] },
529 },
530 required: ["owner", "repo"],
531 } satisfies Record<string, unknown>;
532
533 const hint = jsonSchemaTypeHintFallback(schema);
534 expect(hint).toContain("owner");
535 expect(hint).toContain("repo");
536 expect(hint).toContain("labels");
537 expect(hint).toContain("string[]");
538 expect(hint).toContain("\"open\"");
539 });
540});
executor/packages/core/src/tool-typing/convex-sanitize.ts+32−0
@@ -0,0 +1,32 @@
1import type { JsonSchema } from "../types";
2
3function stripDollarKeysDeep(value: unknown): unknown {
4 if (Array.isArray(value)) {
5 return value.map(stripDollarKeysDeep);
6 }
7
8 if (value && typeof value === "object") {
9 const out: Record<string, unknown> = {};
10 for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
11 if (key.startsWith("$")) continue;
12 out[key] = stripDollarKeysDeep(child);
13 }
14 return out;
15 }
16
17 return value;
18}
19
20/**
21 * Convex value encoding forbids field names starting with `$`.
22 * JSON Schema frequently contains `$ref`, `$schema`, `$id`, etc.
23 *
24 * This returns a best-effort schema subset safe to return from Convex actions
25 * and to store in task outputs.
26 */
27export function sanitizeJsonSchemaForConvex(schema: JsonSchema | undefined): JsonSchema {
28 if (!schema || typeof schema !== "object" || Array.isArray(schema)) return {};
29 const cleaned = stripDollarKeysDeep(schema);
30 if (!cleaned || typeof cleaned !== "object" || Array.isArray(cleaned)) return {};
31 return cleaned as JsonSchema;
32}
executor/packages/core/src/tool-typing/env-types.ts+18−0
@@ -0,0 +1,18 @@
1export const BASE_ENVIRONMENT_DTS = `
2interface Console {
3 /** Console output is discarded; use explicit return values for results. */
4 log(...args: any[]): void;
5 /** Console output is discarded; use explicit return values for results. */
6 error(...args: any[]): void;
7 /** Console output is discarded; use explicit return values for results. */
8 warn(...args: any[]): void;
9 info(...args: any[]): void;
10 debug(...args: any[]): void;
11}
12declare var console: Console;
13
14declare function setTimeout(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
15declare function clearTimeout(id: number): void;
16declare function setInterval(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
17declare function clearInterval(id: number): void;
18`;
executor/packages/core/src/tool-typing/schema-utils.ts+55−0
@@ -0,0 +1,55 @@
1import type { JsonSchema } from "../types";
2import { asRecord } from "../utils";
3
4function uniq(values: string[]): string[] {
5 return [...new Set(values.filter((v) => v.trim().length > 0))];
6}
7
8function collectTopLevelRequiredKeys(schema: JsonSchema, out: string[]): void {
9 const required = Array.isArray((schema as Record<string, unknown>).required)
10 ? ((schema as Record<string, unknown>).required as unknown[]).filter((v): v is string => typeof v === "string")
11 : [];
12 out.push(...required);
13
14 const allOf = Array.isArray((schema as Record<string, unknown>).allOf)
15 ? ((schema as Record<string, unknown>).allOf as unknown[])
16 : [];
17 for (const entry of allOf) {
18 if (!entry || typeof entry !== "object") continue;
19 collectTopLevelRequiredKeys(entry as JsonSchema, out);
20 }
21}
22
23function collectTopLevelPropertyKeys(schema: JsonSchema, out: string[]): void {
24 const props = asRecord((schema as Record<string, unknown>).properties);
25 out.push(...Object.keys(props));
26
27 const allOf = Array.isArray((schema as Record<string, unknown>).allOf)
28 ? ((schema as Record<string, unknown>).allOf as unknown[])
29 : [];
30 for (const entry of allOf) {
31 if (!entry || typeof entry !== "object") continue;
32 collectTopLevelPropertyKeys(entry as JsonSchema, out);
33 }
34}
35
36export function extractTopLevelRequiredKeys(schema?: JsonSchema): string[] {
37 if (!schema || typeof schema !== "object") return [];
38 const required: string[] = [];
39 collectTopLevelRequiredKeys(schema, required);
40 return uniq(required);
41}
42
43export function extractTopLevelPropertyKeys(schema?: JsonSchema): string[] {
44 if (!schema || typeof schema !== "object") return [];
45 const keys: string[] = [];
46 collectTopLevelPropertyKeys(schema, keys);
47 return uniq(keys);
48}
49
50export function buildPreviewKeys(schema?: JsonSchema): string[] {
51 const required = extractTopLevelRequiredKeys(schema);
52 const props = extractTopLevelPropertyKeys(schema);
53 const remaining = props.filter((k) => !required.includes(k));
54 return [...required, ...remaining];
55}
executor/packages/core/src/tool-typing/typebundle.ts+189−0
@@ -0,0 +1,189 @@
1import type { ToolDefinition } from "../types";
2import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";
3import { OPENAPI_HELPER_TYPES } from "../openapi/helper-types";
4import { BASE_ENVIRONMENT_DTS } from "./env-types";
5
6const memberNameRegex = /^[$A-Z_][0-9A-Z_$]*$/i;
7
8function emitMemberName(name: string): string {
9 return memberNameRegex.test(name) ? name : JSON.stringify(name);
10}
11
12function safeNamespaceSegment(value: string): string {
13 const cleaned = value.replace(/[^A-Za-z0-9_]/g, "_");
14 return cleaned.length > 0 ? cleaned : "source";
15}
16
17function indentBlock(value: string, spaces: number): string {
18 const pad = " ".repeat(spaces);
19 return value
20 .split("\n")
21 .map((line) => (line.trim().length === 0 ? line : pad + line))
22 .join("\n");
23}
24
25function wrapDtsInNamespace(namespace: string, rawDts: string): string {
26 const stripped = rawDts.replace(/^export /gm, "").trim();
27 return `declare namespace ${namespace} {\n${indentBlock(stripped, 2)}\n}`;
28}
29
30function typeHintFromSchema(schema: Record<string, unknown> | undefined, fallback: string): string {
31 if (!schema || Object.keys(schema).length === 0) return fallback;
32 // Special-case empty object input to keep signatures tidy.
33 const props = schema.properties && typeof schema.properties === "object" ? schema.properties as Record<string, unknown> : {};
34 const required = Array.isArray(schema.required) ? schema.required : [];
35 if (Object.keys(props).length === 0 && required.length === 0) {
36 return fallback === "{}" ? "{}" : fallback;
37 }
38 return jsonSchemaTypeHintFallback(schema);
39}
40
41type NamespaceNode = {
42 children: Map<string, NamespaceNode>;
43 tools: ToolDefinition[];
44};
45
46function buildTree(tools: ToolDefinition[]): NamespaceNode {
47 const root: NamespaceNode = { children: new Map(), tools: [] };
48 for (const tool of tools) {
49 const parts = tool.path.split(".");
50 if (parts.length <= 1) {
51 root.tools.push(tool);
52 continue;
53 }
54
55 let node = root;
56 for (let i = 0; i < parts.length - 1; i += 1) {
57 const part = parts[i]!;
58 if (!node.children.has(part)) {
59 node.children.set(part, { children: new Map(), tools: [] });
60 }
61 node = node.children.get(part)!;
62 }
63 node.tools.push(tool);
64 }
65 return root;
66}
67
68function countAllTools(node: NamespaceNode): number {
69 let count = node.tools.length;
70 for (const child of node.children.values()) {
71 count += countAllTools(child);
72 }
73 return count;
74}
75
76function emitToolMethod(tool: ToolDefinition, openApiSourcesWithDts: Set<string>): string {
77 const methodName = emitMemberName(tool.path.split(".").at(-1) ?? "tool");
78 const approvalNote = tool.approval === "required" ? " **Requires approval**" : "";
79 const desc = (tool.description || "Call this tool.") + approvalNote;
80
81 const typing = tool.typing;
82 const typedRef = typing?.typedRef;
83
84 let inputType = "Record<string, unknown>";
85 let outputType = "unknown";
86
87 if (typedRef?.kind === "openapi_operation" && openApiSourcesWithDts.has(typedRef.sourceKey)) {
88 const ns = `OpenApi_${safeNamespaceSegment(typedRef.sourceKey)}`;
89 const opKey = JSON.stringify(typedRef.operationId);
90 inputType = `ToolInput<${ns}.operations[${opKey}]>`;
91 outputType = `ToolOutput<${ns}.operations[${opKey}]>`;
92 } else {
93 inputType = typeHintFromSchema(typing?.inputSchema, "{}");
94 outputType = typeHintFromSchema(typing?.outputSchema, "unknown");
95 }
96
97 const isOptionalInput = inputType === "{}";
98 const inputParam = isOptionalInput ? `input?: ${inputType}` : `input: ${inputType}`;
99
100 return ` /**\n * ${desc}\n */\n ${methodName}(${inputParam}): Promise<${outputType}>;`;
101}
102
103function emitNamespaceInterface(
104 name: string,
105 node: NamespaceNode,
106 openApiSourcesWithDts: Set<string>,
107 out: string[],
108): void {
109 for (const [childName, childNode] of node.children) {
110 emitNamespaceInterface(`${name}_${childName}`, childNode, openApiSourcesWithDts, out);
111 }
112
113 const members: string[] = [];
114 for (const [childName, childNode] of node.children) {
115 const toolCount = childNode.tools.length + countAllTools(childNode);
116 members.push(
117 ` /** ${toolCount} tool${toolCount !== 1 ? "s" : ""} in the \`${childName}\` namespace */\n readonly ${emitMemberName(childName)}: ToolNS_${name}_${childName};`,
118 );
119 }
120 for (const tool of node.tools) {
121 members.push(emitToolMethod(tool, openApiSourcesWithDts));
122 }
123
124 out.push(`interface ToolNS_${name} {\n${members.join("\n\n")}\n}`);
125}
126
127function emitToolsProxyDts(tools: ToolDefinition[], openApiSourcesWithDts: Set<string>): string {
128 const root = buildTree(tools);
129
130 const interfaces: string[] = [];
131 for (const [name, node] of root.children) {
132 emitNamespaceInterface(name, node, openApiSourcesWithDts, interfaces);
133 }
134
135 const rootMembers: string[] = [];
136 for (const [name] of root.children) {
137 rootMembers.push(` readonly ${emitMemberName(name)}: ToolNS_${name};`);
138 }
139 for (const tool of root.tools) {
140 rootMembers.push(emitToolMethod(tool, openApiSourcesWithDts));
141 }
142
143 return [
144 interfaces.join("\n\n"),
145 "",
146 "interface ToolsProxy {",
147 rootMembers.join("\n\n"),
148 "}",
149 "",
150 "declare const tools: ToolsProxy;",
151 ]
152 .filter((part) => part.trim().length > 0)
153 .join("\n");
154}
155
156export interface BuildWorkspaceTypeBundleInput {
157 tools: ToolDefinition[];
158 /** Map of OpenAPI sourceKey -> openapi-typescript .d.ts string */
159 openApiDtsBySource?: Record<string, string>;
160}
161
162/**
163 * Builds a single `.d.ts` bundle suitable for Monaco's `addExtraLib`.
164 *
165 * - Includes base environment declarations.
166 * - Includes OpenAPI helper types (`ToolInput`/`ToolOutput`).
167 * - Namespaces each OpenAPI source `.d.ts` to avoid global `operations` collisions.
168 * - Emits `declare const tools: ToolsProxy` for IntelliSense.
169 */
170export function buildWorkspaceTypeBundle(input: BuildWorkspaceTypeBundleInput): string {
171 const openApiDtsBySource = input.openApiDtsBySource ?? {};
172 const openApiSourcesWithDts = new Set(Object.keys(openApiDtsBySource));
173
174 const openApiBlocks = Object.entries(openApiDtsBySource)
175 .sort(([a], [b]) => a.localeCompare(b))
176 .map(([sourceKey, dts]) => {
177 const ns = `OpenApi_${safeNamespaceSegment(sourceKey)}`;
178 return wrapDtsInNamespace(ns, dts);
179 });
180
181 return [
182 "// Generated workspace type bundle",
183 BASE_ENVIRONMENT_DTS.trim(),
184 OPENAPI_HELPER_TYPES.trim(),
185 ...openApiBlocks,
186 emitToolsProxyDts(input.tools, openApiSourcesWithDts),
187 "",
188 ].join("\n\n");
189}
executor/packages/core/src/tool/source-serialization.ts+4−4
@@ -2,7 +2,7 @@ import { connectMcp, extractMcpResult } from "../mcp-runtime"; · 1 unmodified line
2import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman-runtime";2import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman-runtime";
3import { normalizeGraphqlFieldVariables, selectGraphqlFieldEnvelope } from "../graphql/field-tools";3import { normalizeGraphqlFieldVariables, selectGraphqlFieldEnvelope } from "../graphql/field-tools";
4import { callMcpToolWithReconnect, executeGraphqlRequest, executeOpenApiRequest } from "./source-execution";4import { callMcpToolWithReconnect, executeGraphqlRequest, executeOpenApiRequest } from "./source-execution";
5import type { ToolApprovalMode, ToolCredentialSpec, ToolDefinition, ToolTypeMetadata } from "../types";5import type { ToolApprovalMode, ToolCredentialSpec, ToolDefinition, ToolTyping } from "../types";
6import { asRecord } from "../utils";6import { asRecord } from "../utils";
7 7
8export interface SerializedTool {8export interface SerializedTool {
@@ -10,7 +10,7 @@ export interface SerializedTool { · 1 unmodified line
10 description: string;10 description: string;
11 approval: ToolApprovalMode;11 approval: ToolApprovalMode;
12 source?: string;12 source?: string;
13 metadata?: ToolTypeMetadata;13 typing?: ToolTyping;
14 credential?: ToolCredentialSpec;14 credential?: ToolCredentialSpec;
15 _graphqlSource?: string;15 _graphqlSource?: string;
16 _pseudoTool?: boolean;16 _pseudoTool?: boolean;
@@ -91,7 +91,7 @@ export function serializeTools(tools: ToolDefinition[]): SerializedTool[] { · 74 unmodified lines
91 description: tool.description,91 description: tool.description,
92 approval: tool.approval,92 approval: tool.approval,
93 source: tool.source,93 source: tool.source,
94 metadata: tool.metadata,94 typing: tool.typing,
95 credential: tool.credential,95 credential: tool.credential,
96 _graphqlSource: tool._graphqlSource,96 _graphqlSource: tool._graphqlSource,
97 _pseudoTool: tool._pseudoTool,97 _pseudoTool: tool._pseudoTool,
@@ -111,7 +111,7 @@ export function rehydrateTools( · 13 unmodified lines
111 description: st.description,111 description: st.description,
112 approval: st.approval,112 approval: st.approval,
113 source: st.source,113 source: st.source,
114 metadata: st.metadata,114 typing: st.typing,
115 credential: st.credential,115 credential: st.credential,
116 _graphqlSource: st._graphqlSource,116 _graphqlSource: st._graphqlSource,
117 _pseudoTool: st._pseudoTool,117 _pseudoTool: st._pseudoTool,
executor/packages/core/src/types.ts+44−28
@@ -11,6 +11,8 @@ export type CredentialProvider = "local-convex" | "workos-vault"; · 10 unmodified lines
11export type ToolApprovalMode = "auto" | "required";11export type ToolApprovalMode = "auto" | "required";
12export type ToolSourceType = "mcp" | "openapi" | "graphql";12export type ToolSourceType = "mcp" | "openapi" | "graphql";
13 13
14export type JsonSchema = Record<string, unknown>;
15
14export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";16export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";
15 17
16export interface SourceAuthProfile {18export interface SourceAuthProfile {
@@ -126,17 +128,13 @@ export interface ToolDescriptor { · 109 unmodified lines
126 description: string;128 description: string;
127 approval: ToolApprovalMode;129 approval: ToolApprovalMode;
128 source?: string;130 source?: string;
129 /** Compact display type hint for tool arguments. */131 /** Canonical tool typing/signature info for clients (schema-less Convex-safe subset). */
130 argsType?: string;132 typing?: ToolDescriptorTyping;
131 /** Compact display type hint for tool return value. */133 /** Lightweight, human-readable signature hints (derived from schema/typed refs). */
132 returnsType?: string;134 display?: {
133 /** Full-fidelity args type hint for strict editor/typecheck usage. */135 input?: string;
134 strictArgsType?: string;136 output?: string;
135 /** Full-fidelity return type hint for strict editor/typecheck usage. */137 };
136 strictReturnsType?: string;
137 /** Top-level argument key preview for examples/UI. */
138 argPreviewKeys?: string[];
139 operationId?: string;
140}138}
141 139
142export interface OpenApiSourceQuality {140export interface OpenApiSourceQuality {
@@ -246,24 +244,42 @@ export interface ToolRunContext { · 103 unmodified lines
246 isToolAllowed: (toolPath: string) => boolean;244 isToolAllowed: (toolPath: string) => boolean;
247}245}
248 246
249export interface ToolTypeMetadata {247export type ToolTypedRef =
250 /** Lightweight TS type hint for args (for LLM prompt / discover tool). */248 | {
251 argsType?: string;249 kind: "openapi_operation";
252 /** Lightweight TS type hint for return value (for LLM prompt / discover tool). */250 /** Source key (e.g. "openapi:github") used for namespacing in type bundles. */
253 returnsType?: string;251 sourceKey: string;
254 /** Compact args hint intended for UI/discovery display. */252 /** OperationId in the OpenAPI spec (key for `operations[...]`). */
255 displayArgsType?: string;253 operationId: string;
256 /** Compact return hint intended for UI/discovery display. */254 };
257 displayReturnsType?: string;255
258 /** Top-level argument keys for examples/UI without parsing type strings. */256export interface ToolTyping {
259 argPreviewKeys?: string[];257 /** JSON Schema describing the tool input payload. */
260 /** Raw operationId from the OpenAPI spec (used to generate typechecker wrapper). */258 inputSchema?: JsonSchema;
261 operationId?: string;259 /** JSON Schema describing the tool output payload. */
260 outputSchema?: JsonSchema;
262 /**261 /**
263 * Raw .d.ts from openapi-typescript for this tool's source.262 * Optional human-readable type hints derived from schema/OpenAPI.
264 * Only set on the FIRST tool per source to avoid duplication.263 * Used for discover/catalog outputs and UI signatures.
265 */264 */
266 sourceDts?: string;265 inputHint?: string;
266 outputHint?: string;
267 /** Required top-level keys for quick validation and examples. */
268 requiredInputKeys?: string[];
269 /** Preview keys for UI/examples (required keys first, then common keys). */
270 previewInputKeys?: string[];
271 /** Optional high-fidelity typed reference for sources with native type maps (e.g. OpenAPI). */
272 typedRef?: ToolTypedRef;
273}
274
275/**
276 * Convex cannot serialize objects with `$`-prefixed keys.
277 * Keep ToolDescriptor typing limited to Convex-safe scalar/array fields.
278 */
279export interface ToolDescriptorTyping {
280 requiredInputKeys?: string[];
281 previewInputKeys?: string[];
282 typedRef?: ToolTypedRef;
267}283}
268 284
269export interface ToolDefinition {285export interface ToolDefinition {
@@ -271,7 +287,7 @@ export interface ToolDefinition { · 1 unmodified line
271 description: string;287 description: string;
272 approval: ToolApprovalMode;288 approval: ToolApprovalMode;
273 source?: string;289 source?: string;
274 metadata?: ToolTypeMetadata;290 typing?: ToolTyping;
275 credential?: ToolCredentialSpec;291 credential?: ToolCredentialSpec;
276 /** For GraphQL sources: the source name used for dynamic path extraction */292 /** For GraphQL sources: the source name used for dynamic path extraction */
277 _graphqlSource?: string;293 _graphqlSource?: string;
executor/packages/core/src/workspace/tool-cache.test.ts+24−19
@@ -29,7 +29,12 @@ function makeBaseTools(): Map<string, ToolDefinition> { · 28 unmodified lines
29 description: "Echo input",29 description: "Echo input",
30 approval: "auto" as const,30 approval: "auto" as const,
31 source: "system",31 source: "system",
32 metadata: { argsType: "{ message: string }", returnsType: "string" },32 typing: {
33 inputSchema: { type: "object", properties: { message: { type: "string" } }, required: ["message"] },
34 outputSchema: { type: "string" },
35 requiredInputKeys: ["message"],
36 previewInputKeys: ["message"],
37 },
33 run: async (input: unknown) => {38 run: async (input: unknown) => {
34 const payload = input as Record<string, unknown>;39 const payload = input as Record<string, unknown>;
35 return payload.message;40 return payload.message;
@@ -190,9 +195,8 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 154 unmodified lines
190 for (const tool of rehydrated) {195 for (const tool of rehydrated) {
191 expect(typeof tool.run).toBe("function");196 expect(typeof tool.run).toBe("function");
192 expect(tool.path).toContain("test_api.");197 expect(tool.path).toContain("test_api.");
193 expect(tool.metadata).toBeDefined();198 expect(tool.typing).toBeDefined();
194 expect(tool.metadata!.argsType).toBeDefined();199 expect(tool.typing!.inputSchema).toBeDefined();
195 expect(tool.metadata!.returnsType).toBeDefined();
196 }200 }
197 });201 });
198 202
@@ -218,8 +222,8 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 19 unmodified lines
218 expect(rehydrated[i]!.description).toBe(tools[i]!.description);222 expect(rehydrated[i]!.description).toBe(tools[i]!.description);
219 expect(rehydrated[i]!.approval).toBe(tools[i]!.approval);223 expect(rehydrated[i]!.approval).toBe(tools[i]!.approval);
220 expect(rehydrated[i]!.source).toBe(tools[i]!.source);224 expect(rehydrated[i]!.source).toBe(tools[i]!.source);
221 expect(rehydrated[i]!.metadata?.argsType).toBe(tools[i]!.metadata?.argsType);225 expect(rehydrated[i]!.typing?.typedRef?.operationId).toBe(tools[i]!.typing?.typedRef?.operationId);
222 expect(rehydrated[i]!.metadata?.returnsType).toBe(tools[i]!.metadata?.returnsType);226 expect(rehydrated[i]!.typing?.requiredInputKeys).toEqual(tools[i]!.typing?.requiredInputKeys);
223 }227 }
224 });228 });
225 229
@@ -251,7 +255,11 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 25 unmodified lines
251 description: "An MCP tool",255 description: "An MCP tool",
252 approval: "auto",256 approval: "auto",
253 source: "mcp:my_mcp",257 source: "mcp:my_mcp",
254 metadata: { argsType: "{ input: string }", returnsType: "unknown" },258 typing: {
259 inputSchema: { type: "object", properties: { input: { type: "string" } }, required: ["input"] },
260 requiredInputKeys: ["input"],
261 previewInputKeys: ["input"],
262 },
255 _runSpec: {263 _runSpec: {
256 kind: "mcp" as const,264 kind: "mcp" as const,
257 url: "https://mcp.example.com/sse",265 url: "https://mcp.example.com/sse",
@@ -282,9 +290,10 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 24 unmodified lines
282 description: "Execute GraphQL",290 description: "Execute GraphQL",
283 approval: "auto",291 approval: "auto",
284 source: "graphql:linear",292 source: "graphql:linear",
285 metadata: {293 typing: {
286 argsType: "{ query: string; variables?: Record<string, unknown> }",294 inputSchema: { type: "object", properties: { query: { type: "string" }, variables: {} }, required: ["query"] },
287 returnsType: "unknown",295 requiredInputKeys: ["query"],
296 previewInputKeys: ["query", "variables"],
288 },297 },
289 _runSpec: {298 _runSpec: {
290 kind: "graphql_raw" as const,299 kind: "graphql_raw" as const,
@@ -298,9 +307,8 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 7 unmodified lines
298 description: "List teams",307 description: "List teams",
299 approval: "auto",308 approval: "auto",
300 source: "graphql:linear",309 source: "graphql:linear",
301 metadata: {310 typing: {
302 argsType: "{}",311 inputSchema: { type: "object", properties: {} },
303 returnsType: "unknown",
304 },312 },
305 _pseudoTool: true,313 _pseudoTool: true,
306 _runSpec: {314 _runSpec: {
@@ -404,11 +412,11 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 97 unmodified lines
404 expect(restoredTools.some((tool) => tool.path === "echo")).toBe(false);412 expect(restoredTools.some((tool) => tool.path === "echo")).toBe(false);
405 413
406 // OpenAPI tools should have correct metadata414 // OpenAPI tools should have correct metadata
407 const listTool = restoredTools.find((t) => t.metadata?.operationId === "listWidgets")!;415 const listTool = restoredTools.find((t) => t.typing?.typedRef?.operationId === "listWidgets")!;
408 expect(listTool.approval).toBe("auto"); // GET = auto416 expect(listTool.approval).toBe("auto"); // GET = auto
409 expect(listTool.metadata!.argsType).toBeDefined();417 expect(listTool.typing!.inputSchema).toBeDefined();
410 418
411 const createTool = restoredTools.find((t) => t.metadata?.operationId === "createWidget")!;419 const createTool = restoredTools.find((t) => t.typing?.typedRef?.operationId === "createWidget")!;
412 expect(createTool.approval).toBe("required"); // POST = required420 expect(createTool.approval).toBe("required"); // POST = required
413 });421 });
414 422
@@ -529,7 +537,6 @@ describe("workspace tool cache table", () => { · 114 unmodified lines
529 storageId,537 storageId,
530 toolCount: 0,538 toolCount: 0,
531 sizeBytes: 27,539 sizeBytes: 27,
532 dtsStorageIds: [],
533 });540 });
534 541
535 // Hit542 // Hit
@@ -563,7 +570,6 @@ describe("workspace tool cache table", () => { · 27 unmodified lines
563 storageId: storageId1,570 storageId: storageId1,
564 toolCount: 5,571 toolCount: 5,
565 sizeBytes: 3,572 sizeBytes: 3,
566 dtsStorageIds: [],
567 });573 });
568 574
569 const storageId2 = await t.run(async (ctx) => {575 const storageId2 = await t.run(async (ctx) => {
@@ -576,7 +582,6 @@ describe("workspace tool cache table", () => { · 6 unmodified lines
576 storageId: storageId2,582 storageId: storageId2,
577 toolCount: 10,583 toolCount: 10,
578 sizeBytes: 3,584 sizeBytes: 3,
579 dtsStorageIds: [],
580 });585 });
581 586
582 // New entry587 // New entry
executor/scripts/dev/wipe/convex-db.ts+7−0
@@ -13,8 +13,15 @@ const tables = [ · 12 unmodified lines
13 "organizations",13 "organizations",
14 "sourceCredentials",14 "sourceCredentials",
15 "taskEvents",15 "taskEvents",
16 "toolCalls",
16 "tasks",17 "tasks",
18 "openApiSpecCache",
17 "toolSources",19 "toolSources",
20 "workspaceToolRegistryState",
21 "workspaceToolRegistry",
22 "workspaceToolNamespaces",
23 "workspaceMembers",
24 "workspaceToolCache",
18 "workspaces",25 "workspaces",
19] as const;26] as const;
20 27
executor/scripts/e2e/install.sandbox.e2e.test.ts+60−37
@@ -35,6 +35,16 @@ async function runSandboxBash( · 34 unmodified lines
35 }35 }
36}36}
37 37
38function isSandboxStoppedError(error: unknown): boolean {
39 if (!error) return false;
40 const anyErr = error as any;
41 const status = typeof anyErr?.response?.status === "number" ? anyErr.response.status : null;
42 if (status === 410) return true;
43
44 const message = error instanceof Error ? error.message : String(error);
45 return message.includes("sandbox_stopped") || message.includes("Status code 410");
46}
47
38function assertSuccess(result: CommandResult, label: string): void {48function assertSuccess(result: CommandResult, label: string): void {
39 if (result.exitCode === 0) {49 if (result.exitCode === 0) {
40 return;50 return;
@@ -59,52 +69,65 @@ test("installer works in fresh Vercel sandbox", async () => { · 18 unmodified lines
59 }69 }
60 : {};70 : {};
61 71
62 let sandbox: Sandbox | null = null;72 const createSandbox = async () => {
73 try {
74 return await Sandbox.create({
75 runtime: "node22",
76 timeout: 30 * 60 * 1000,
77 ...credentials,
78 });
79 } catch (error) {
80 const message = error instanceof Error ? error.message : String(error);
81 throw new Error(
82 `Could not create Vercel sandbox. Configure auth via VERCEL_TOKEN/VERCEL_TEAM_ID/VERCEL_PROJECT_ID or VERCEL_OIDC_TOKEN.\n\n${message}`,
83 );
84 }
85 };
63 86
64 try {87 let sandbox: Sandbox | null = await createSandbox();
65 sandbox = await Sandbox.create({
66 runtime: "node22",
67 timeout: 30 * 60 * 1000,
68 ...credentials,
69 });
70 } catch (error) {
71 const message = error instanceof Error ? error.message : String(error);
72 throw new Error(
73 `Could not create Vercel sandbox. Configure auth via VERCEL_TOKEN/VERCEL_TEAM_ID/VERCEL_PROJECT_ID or VERCEL_OIDC_TOKEN.\n\n${message}`,
74 );
75 }
76 88
77 try {89 try {
78 const install = await runSandboxBash(90 const installScript = [
79 sandbox,91 "set -euo pipefail",
80 [92 "cd ~",
81 "set -euo pipefail",93 "if [ -x ~/.executor/bin/executor ]; then ~/.executor/bin/executor uninstall --yes || true; fi",
82 "cd ~",94 "rm -rf ~/.executor",
83 "if [ -x ~/.executor/bin/executor ]; then ~/.executor/bin/executor uninstall --yes || true; fi",95 "start=$(date +%s)",
84 "rm -rf ~/.executor",96 "curl -fsSL https://executor.sh/install | bash",
85 "start=$(date +%s)",97 "end=$(date +%s)",
86 "curl -fsSL https://executor.sh/install | bash",98 "echo INSTALL_SECONDS=$((end-start))",
87 "end=$(date +%s)",99 "~/.executor/bin/executor doctor --verbose",
88 "echo INSTALL_SECONDS=$((end-start))",100 ].join("; ");
89 "~/.executor/bin/executor doctor --verbose",101
90 ].join("; "),102 let install: CommandResult;
91 900_000,103 try {
92 );104 install = await runSandboxBash(sandbox, installScript, 900_000);
105 } catch (error) {
106 if (!isSandboxStoppedError(error)) {
107 throw error;
108 }
109 // Vercel sandboxes can stop unexpectedly; recreate once and retry.
110 try {
111 await sandbox.stop();
112 } catch {
113 // ignore
114 }
115 sandbox = await createSandbox();
116 install = await runSandboxBash(sandbox, installScript, 900_000);
117 }
93 assertSuccess(install, "sandbox install + doctor");118 assertSuccess(install, "sandbox install + doctor");
94 119
95 const output = `${install.stdout}\n${install.stderr}`;120 const output = `${install.stdout}\n${install.stderr}`;
96 expect(output).toContain("Executor status: ready");121 expect(output).toContain("Executor status: ready");
97 expect(output).toContain("Functions: bootstrapped");122 expect(output).toContain("Functions: bootstrapped");
98 123
99 const uninstall = await runSandboxBash(124 const uninstallScript = [
100 sandbox,125 "set -euo pipefail",
101 [126 "~/.executor/bin/executor uninstall --yes",
102 "set -euo pipefail",127 "test ! -e ~/.executor/runtime/convex-data/convex_local_backend.sqlite3",
103 "~/.executor/bin/executor uninstall --yes",128 ].join("; ");
104 "test ! -e ~/.executor/runtime/convex-data/convex_local_backend.sqlite3",129
105 ].join("; "),130 const uninstall = await runSandboxBash(sandbox, uninstallScript, 300_000);
106 300_000,
107 );
108 assertSuccess(uninstall, "sandbox uninstall validation");131 assertSuccess(uninstall, "sandbox uninstall validation");
109 } finally {132 } finally {
110 await sandbox.stop();133 await sandbox.stop();
package.json+1−1
@@ -24,7 +24,7 @@ · 23 unmodified lines
24 "dev:assistant": "bun run --cwd assistant/packages/server dev",24 "dev:assistant": "bun run --cwd assistant/packages/server dev",
25 "dev:bot": "bun run --cwd assistant/packages/bot dev",25 "dev:bot": "bun run --cwd assistant/packages/bot dev",
26 "test": "bun run test:executor && bun run test:assistant",26 "test": "bun run test:executor && bun run test:assistant",
27 "test:executor": "bun test --cwd executor",27 "test:executor": "DISABLE_CONVEX_SCHEDULER=1 bun test --cwd executor",
28 "test:assistant": "bun run --cwd assistant test",28 "test:assistant": "bun run --cwd assistant test",
29 "typecheck": "bun run typecheck:executor && bun run typecheck:assistant",29 "typecheck": "bun run typecheck:executor && bun run typecheck:assistant",
30 "typecheck:executor": "bunx tsc --noEmit -p executor/tsconfig.json",30 "typecheck:executor": "bunx tsc --noEmit -p executor/tsconfig.json",