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
3838 enableSystem
3939 enableColorScheme
4040 >
41− <AppConvexProvider>
42− <QueryProvider>
41+ <QueryProvider>
42+ <AppConvexProvider>
4343 <SessionProvider>
4444 {children}
4545 </SessionProvider>
46− </QueryProvider>
47− </AppConvexProvider>
46+ </AppConvexProvider>
47+ </QueryProvider>
4848 <Toaster position="bottom-right" />
4949 </ThemeProvider>
5050 </body>
executor/apps/web/src/components/tasks/code-editor.tsx+56−98
@@ -1,16 +1,12 @@
11 "use client";
22
3−import { useRef, useEffect, useMemo, useState } from "react";
3+import { useEffect, useRef } from "react";
4+import { useQuery as useTanstackQuery } from "@tanstack/react-query";
45 import Editor, { type OnMount, type BeforeMount, type Monaco } from "@monaco-editor/react";
56 import { Loader2 } from "lucide-react";
67 import { useTheme } from "next-themes";
78 import type { ToolDescriptor } from "@/lib/types";
89 import { cn } from "@/lib/utils";
9−import {
10− BASE_ENVIRONMENT_DTS,
11− OPENAPI_HELPER_TYPES,
12− generateToolsDts,
13−} from "./code/editor-types";
1410 import {
1511 CODE_EDITOR_OPTIONS,
1612 configureJavascriptDefaults,
@@ -18,13 +14,14 @@ import { · 1 unmodified line
1814 setDiagnosticsOptions,
1915 } from "./code/editor-monaco";
2016
17+const PLACEHOLDER_TYPES_DTS = "declare const tools: any;\n";
18+
2119 interface CodeEditorProps {
2220 value: string;
2321 onChange: (value: string) => void;
2422 tools: ToolDescriptor[];
25− /** Per-source .d.ts download URLs for OpenAPI IntelliSense. Keyed by source key. */
26− dtsUrls?: Record<string, string>;
27− typesLoading?: boolean;
23+ /** Workspace-wide Monaco `.d.ts` bundle URL. */
24+ typesUrl?: string;
2825 className?: string;
2926 height?: string;
3027 }
@@ -33,152 +30,113 @@ export function CodeEditor({ · 2 unmodified lines
3330 value,
3431 onChange,
3532 tools,
36− dtsUrls,
37− typesLoading = false,
33+ typesUrl,
3834 className,
3935 height = "400px",
4036 }: CodeEditorProps) {
4137 const { resolvedTheme } = useTheme();
42− const [dtsHydrating, setDtsHydrating] = useState(false);
4338 const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
4439 const monacoRef = useRef<Monaco | null>(null);
4540 const envLibDisposable = useRef<{ dispose: () => void } | null>(null);
4641 const toolsLibDisposable = useRef<{ dispose: () => void } | null>(null);
47− const dtsLibDisposables = useRef<{ dispose: () => void }[]>([]);
4842 const toolsLibVersion = useRef(0);
49− const fetchedDtsUrls = useRef<string>("");
50− const dtsSources = useMemo(() => new Set(Object.keys(dtsUrls ?? {})), [dtsUrls]);
51− const toolTypesLoading = typesLoading || dtsHydrating;
5243
53− // Fetch and register .d.ts blobs from OpenAPI sources
44+ 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;
5464 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− }
6465 const m = monacoRef.current;
6566 if (!m) return;
6667
6768 const jsDefaults = m.languages.typescript.javascriptDefaults;
6869
69− // Skip if URLs haven't changed
70− const urlsKey = JSON.stringify(dtsUrls);
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− });
70+ // Dispose previous tool type declarations
71+ toolsLibDisposable.current?.dispose();
12072
121− return () => {
122− cancelled = true;
123− };
124− }, [dtsUrls]);
73+ // Placeholder types until we fetch the workspace bundle.
74+ const version = ++toolsLibVersion.current;
75+ toolsLibDisposable.current = jsDefaults.addExtraLib(
76+ PLACEHOLDER_TYPES_DTS,
77+ `file:///node_modules/@types/executor-tools/v${version}.d.ts`,
78+ );
79+ }, [tools]);
12580
126− // Update types when tools change (or on first mount)
81+ // Register fetched workspace type bundle.
12782 useEffect(() => {
83+ if (!typesBundleQuery.data) {
84+ return;
85+ }
86+
12887 const m = monacoRef.current;
12988 if (!m) return;
13089
13190 const jsDefaults = m.languages.typescript.javascriptDefaults;
13291
133− // Dispose previous tool type declarations
13492 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.
14093 const version = ++toolsLibVersion.current;
14194 toolsLibDisposable.current = jsDefaults.addExtraLib(
142− dts,
95+ typesBundleQuery.data,
14396 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,
14497 );
145− }, [tools, dtsSources]);
98+ }, [typesBundleQuery.data]);
14699
147100 // Avoid transient semantic errors while tool metadata is still loading.
148101 useEffect(() => {
149102 const m = monacoRef.current;
150103 if (!m) return;
151− setDiagnosticsOptions(m, toolTypesLoading);
152− }, [toolTypesLoading]);
104+ setDiagnosticsOptions(m, typesHydrating);
105+ }, [typesHydrating]);
153106
154107 // Clean up on unmount
155108 useEffect(() => {
156109 return () => {
157110 envLibDisposable.current?.dispose();
158111 toolsLibDisposable.current?.dispose();
159− for (const d of dtsLibDisposables.current) d.dispose();
160112 };
161113 }, []);
162114
163115 const handleBeforeMount: BeforeMount = (monaco) => {
164116 monacoRef.current = monaco;
165117
166− const ts = configureJavascriptDefaults(monaco, toolTypesLoading);
118+ const ts = configureJavascriptDefaults(monaco, typesHydrating);
167119
168120 // Add stable environment declarations (once)
121+ // Keep a stable environment declaration (console/timers).
169122 envLibDisposable.current?.dispose();
170123 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",
172131 "file:///node_modules/@types/executor-env/index.d.ts",
173132 );
174133
175134 // Add initial tool type declarations
176135 // (will be replaced by useEffect when tools load from the API)
177136 toolsLibDisposable.current?.dispose();
178− const dts = generateToolsDts(tools, dtsSources);
179137 const version = ++toolsLibVersion.current;
180138 toolsLibDisposable.current = ts.javascriptDefaults.addExtraLib(
181− dts,
139+ PLACEHOLDER_TYPES_DTS,
182140 `file:///node_modules/@types/executor-tools/v${version}.d.ts`,
183141 );
184142
@@ -203,7 +161,7 @@ export function CodeEditor({ · 18 unmodified lines
203161
204162 return (
205163 <div className={cn("relative", className)}>
206− {toolTypesLoading ? (
164+ {typesHydrating ? (
207165 <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">
208166 <Loader2 className="h-3 w-3 animate-spin" />
209167 {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 @@
1−import type { ToolDescriptor } from "@/lib/types";
2−export { OPENAPI_HELPER_TYPES } from "@executor/core/openapi/helper-types";
3−import { toolDisplaySegment } from "@/lib/tool/explorer-grouping";
4−
5−interface NamespaceNode {
6− children: Map<string, NamespaceNode>;
7− tools: ToolDescriptor[];
8−}
9−
10−function 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−
30−const memberNameRegex = /^[$A-Z_][0-9A-Z_$]*$/i;
31−
32−function emitMemberName(name: string): string {
33− if (memberNameRegex.test(name)) {
34− return name;
35− }
36−
37− return JSON.stringify(name);
38−}
39−
40−function 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−
48−function 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−
84−function 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−
109−export 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−
140−export const BASE_ENVIRONMENT_DTS = `
141−interface 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−}
151−declare var console: Console;
152−
153−declare function setTimeout(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
154−declare function clearTimeout(id: number): void;
155−declare function setInterval(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
156−declare 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
5757
5858 const runtimes = useQuery(convexApi.workspace.listRuntimeTargets, {});
5959 const createTask = useAction(convexApi.executor.createTask);
60− const { tools, dtsUrls, loadingTools, loadingTypes } = useWorkspaceTools(context ?? null);
60+ const { tools, typesUrl, loadingTools } = useWorkspaceTools(context ?? null);
6161 const runtimeTargets = useMemo(() => runtimes ?? [], [runtimes]);
6262 const effectiveRuntimeId = runtimeTargets.some((runtime: RuntimeTargetDescriptor) => runtime.id === runtimeId)
6363 ? runtimeId
@@ -141,35 +141,15 @@ export function TaskComposer() { · 77 unmodified lines
141141 <span className="text-[10px] font-mono text-muted-foreground">
142142 {loadingTools
143143 ? "Loading tool inventory..."
144− : loadingTypes
145− ? `${tools.length} tool${tools.length === 1 ? "" : "s"} loaded, type defs warming...`
146− : `${tools.length} tool${tools.length === 1 ? "" : "s"} ready`}
144+ : `${tools.length} tool${tools.length === 1 ? "" : "s"} loaded${typesUrl ? ", type defs ready" : ""}`}
147145 </span>
148146 </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− )}
166147 <div className="rounded-md border border-border">
167148 <CodeEditor
168149 value={code}
169150 onChange={setCode}
170151 tools={tools}
171− dtsUrls={dtsUrls}
172− typesLoading={loadingTypes}
152+ typesUrl={typesUrl}
173153 height="400px"
174154 />
175155 </div>
executor/apps/web/src/components/tools/add/source-dialog.tsx+47−2
@@ -1,7 +1,7 @@
11 "use client";
22
33 import { useState, type ReactNode } from "react";
4−import { AlertTriangle, Plus } from "lucide-react";
4+import { AlertTriangle, Plus, Trash2 } from "lucide-react";
55 import { useAction, useMutation, useQuery } from "convex/react";
66 import { Result } from "better-result";
77 import { toast } from "sonner";
@@ -82,6 +82,7 @@ function normalizeEndpoint(value: string): string { · 74 unmodified lines
8282 export function AddSourceDialog({
8383 existingSourceNames,
8484 onSourceAdded,
85+ onSourceDeleted,
8586 sourceToEdit,
8687 sourceDialogMeta,
8788 sourceAuthProfiles,
@@ -89,6 +90,7 @@ export function AddSourceDialog({ · 1 unmodified line
8990 }: {
9091 existingSourceNames: Set<string>;
9192 onSourceAdded?: (source: ToolSourceRecord, options?: { connected?: boolean }) => void;
93+ onSourceDeleted?: (sourceName: string) => void;
9294 sourceToEdit?: ToolSourceRecord;
9395 sourceDialogMeta?: SourceDialogMeta;
9496 sourceAuthProfiles?: Record<string, SourceAuthProfile>;
@@ -96,6 +98,7 @@ export function AddSourceDialog({ · 1 unmodified line
9698 }) {
9799 const { context } = useSession();
98100 const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource);
101+ const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource);
99102 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
100103 const credentials = useQuery(
101104 convexApi.workspace.listCredentials,
@@ -106,6 +109,7 @@ export function AddSourceDialog({ · 4 unmodified lines
106109 const [open, setOpen] = useState(false);
107110 const [submitting, setSubmitting] = useState(false);
108111 const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false);
112+ const [deleting, setDeleting] = useState(false);
109113
110114 const sourceDialogHeader = sourceToEdit ? "Edit Tool Source" : "Add Tool Source";
111115 const sourceDialogDescription = sourceToEdit
@@ -256,6 +260,33 @@ export function AddSourceDialog({ · 144 unmodified lines
256260 ? "Add Source + Save Credentials"
257261 : "Add Source";
258262
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+
259290 return (
260291 <Dialog open={open} onOpenChange={handleOpenChange}>
261292 <DialogTrigger asChild>
@@ -338,13 +369,27 @@ export function AddSourceDialog({ · 76 unmodified lines
338369
339370 {editSourceWarnings.length > 0 ? (
340371 <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) => (
342373 <p key={`${sourceToEdit.name}-warning-${index}`} className="text-[10px] text-terminal-amber/90">
343374 {warning}
344375 </p>
345376 ))}
346377 </div>
347378 ) : 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>
348393 </div>
349394 </div>
350395 ) : null}
executor/apps/web/src/components/tools/explorer-groups.tsx+3−4
@@ -42,7 +42,6 @@ export function GroupNode({ · 41 unmodified lines
4242 onSelectTool,
4343 onExpandedChange,
4444 detailLoadingPaths,
45− sourceSchemasBySource,
4645 search,
4746 source,
4847 }: {
@@ -55,7 +54,6 @@ export function GroupNode({ · 6 unmodified lines
5554 onSelectTool: (path: string, e: React.MouseEvent) => void;
5655 onExpandedChange?: (tool: ToolDescriptor, expanded: boolean) => void;
5756 detailLoadingPaths?: Set<string>;
58− sourceSchemasBySource?: Record<string, Record<string, string>>;
5957 search: string;
6058 source?: ToolSourceRecord;
6159 }) {
@@ -190,7 +188,6 @@ export function GroupNode({ · 128 unmodified lines
190188 onSelectTool={onSelectTool}
191189 onExpandedChange={onExpandedChange}
192190 detailLoadingPaths={detailLoadingPaths}
193− sourceSchemasBySource={sourceSchemasBySource}
194191 search={search}
195192 />
196193 );
@@ -206,7 +203,6 @@ export function GroupNode({ · 9 unmodified lines
206203 onSelectTool={onSelectTool}
207204 onExpandedChange={onExpandedChange}
208205 detailLoading={detailLoadingPaths?.has(child.path)}
209− sourceSchemas={child.source ? sourceSchemasBySource?.[child.source] : undefined}
210206 />
211207 );
212208 })
@@ -234,6 +230,7 @@ export function SourceSidebar({ · 21 unmodified lines
234230 sourceDialogMeta,
235231 existingSourceNames,
236232 sourceAuthProfiles,
233+ onSourceDeleted,
237234 }: {
238235 sources: ToolSourceRecord[];
239236 sourceCounts: Record<string, number>;
@@ -244,6 +241,7 @@ export function SourceSidebar({ · 4 unmodified lines
244241 sourceDialogMeta?: Record<string, SourceDialogMeta>;
245242 existingSourceNames: Set<string>;
246243 sourceAuthProfiles?: Record<string, SourceAuthProfile>;
244+ onSourceDeleted?: (sourceName: string) => void;
247245 }) {
248246 const warningCountsBySource = useMemo(() => {
249247 const counts: Record<string, number> = {};
@@ -353,6 +351,7 @@ export function SourceSidebar({ · 103 unmodified lines
353351 sourceToEdit={g.source}
354352 sourceDialogMeta={editMeta}
355353 sourceAuthProfiles={sourceAuthProfiles}
354+ onSourceDeleted={onSourceDeleted}
356355 trigger={
357356 <Button
358357 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
2626 onSelect,
2727 onExpandedChange,
2828 detailLoading,
29− sourceSchemas,
3029 }: {
3130 tool: ToolDescriptor;
3231 label: string;
@@ -35,7 +34,6 @@ const ToolRow = memo(function ToolRow({ · 2 unmodified lines
3534 onSelect: (e: React.MouseEvent) => void;
3635 onExpandedChange?: (tool: ToolDescriptor, expanded: boolean) => void;
3736 detailLoading?: boolean;
38− sourceSchemas?: Record<string, string>;
3937 }) {
4038 const [expanded, setExpanded] = useState(false);
4139
@@ -101,7 +99,7 @@ const ToolRow = memo(function ToolRow({ · 59 unmodified lines
10199 </CollapsibleTrigger>
102100
103101 <CollapsibleContent>
104− <ToolDetail tool={tool} depth={depth} loading={detailLoading} sourceSchemas={sourceSchemas} />
102+ <ToolDetail tool={tool} depth={depth} loading={detailLoading} />
105103 </CollapsibleContent>
106104 </Collapsible>
107105 );
@@ -115,7 +113,6 @@ export const SelectableToolRow = memo(function SelectableToolRow({ · 7 unmodified lines
115113 onSelectTool,
116114 onExpandedChange,
117115 detailLoading,
118− sourceSchemas,
119116 }: {
120117 tool: ToolDescriptor;
121118 label: string;
@@ -124,7 +121,6 @@ export const SelectableToolRow = memo(function SelectableToolRow({ · 2 unmodified lines
124121 onSelectTool: (path: string, e: React.MouseEvent) => void;
125122 onExpandedChange?: (tool: ToolDescriptor, expanded: boolean) => void;
126123 detailLoading?: boolean;
127− sourceSchemas?: Record<string, string>;
128124 }) {
129125 const selected = selectedKeys.has(tool.path);
130126 const handleSelect = useCallback(
@@ -141,7 +137,6 @@ export const SelectableToolRow = memo(function SelectableToolRow({ · 10 unmodified lines
141137 onSelect={handleSelect}
142138 onExpandedChange={onExpandedChange}
143139 detailLoading={detailLoading}
144− sourceSchemas={sourceSchemas}
145140 />
146141 );
147142 },
@@ -150,7 +145,6 @@ export const SelectableToolRow = memo(function SelectableToolRow({ · 2 unmodified lines
150145 prev.label === next.label &&
151146 prev.depth === next.depth &&
152147 prev.detailLoading === next.detailLoading &&
153− prev.sourceSchemas === next.sourceSchemas &&
154148 prev.selectedKeys.has(prev.tool.path) === next.selectedKeys.has(next.tool.path),
155149 );
156150
@@ -161,7 +155,6 @@ export function VirtualFlatList({ · 4 unmodified lines
161155 onExpandedChange,
162156 detailLoadingPaths,
163157 loadingRows,
164− sourceSchemasBySource,
165158 scrollContainerRef,
166159 }: {
167160 tools: ToolDescriptor[];
@@ -170,7 +163,6 @@ export function VirtualFlatList({ · 2 unmodified lines
170163 onExpandedChange?: (tool: ToolDescriptor, expanded: boolean) => void;
171164 detailLoadingPaths?: Set<string>;
172165 loadingRows?: { source: string; count: number }[];
173− sourceSchemasBySource?: Record<string, Record<string, string>>;
174166 scrollContainerRef: React.RefObject<HTMLDivElement | null>;
175167 }) {
176168 return (
@@ -189,7 +181,6 @@ export function VirtualFlatList({ · 12 unmodified lines
189181 onSelectTool={onSelectTool}
190182 onExpandedChange={onExpandedChange}
191183 detailLoading={detailLoadingPaths?.has(tool.path)}
192− sourceSchemas={tool.source ? sourceSchemasBySource?.[tool.source] : undefined}
193184 />
194185 ))}
195186
executor/apps/web/src/components/tools/explorer.tsx+8−24
@@ -50,17 +50,7 @@ interface ToolExplorerProps { · 49 unmodified lines
5050 sourceDialogMeta?: Record<string, SourceDialogMeta>;
5151 sourceAuthProfiles?: Record<string, SourceAuthProfile>;
5252 existingSourceNames?: Set<string>;
53− sourceSchemas?: Record<string, Record<string, string>>;
54−}
55−
56−function 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"]['));
53+ onSourceDeleted?: (sourceName: string) => void;
6454 }
6555
6656 export function ToolExplorer({
@@ -78,7 +68,7 @@ export function ToolExplorer({ · 11 unmodified lines
7868 sourceDialogMeta,
7969 sourceAuthProfiles,
8070 existingSourceNames,
81− sourceSchemas = {},
71+ onSourceDeleted,
8272 }: ToolExplorerProps) {
8373 const [searchInput, setSearchInput] = useState("");
8474 const search = useDeferredValue(searchInput);
@@ -308,19 +298,14 @@ export function ToolExplorer({ · 223 unmodified lines
308298 return;
309299 }
310300
311− const sourceSchemasForTool = tool.source ? sourceSchemas[tool.source] : undefined;
312− const needsSchemaHydration = hasSchemaReference(tool)
313− && (!sourceSchemasForTool || Object.keys(sourceSchemasForTool).length === 0);
314−
315301 const hasDetails = Boolean(
316302 tool.description
317− || tool.strictArgsType
318− || tool.strictReturnsType
319− || tool.argsType
320− || tool.returnsType,
303+ || tool.display?.input
304+ || tool.display?.output
305+ || (tool.typing?.requiredInputKeys?.length ?? 0) > 0,
321306 );
322307
323− if ((hasDetails || toolDetailsByPath[tool.path]) && !needsSchemaHydration) {
308+ if (hasDetails || toolDetailsByPath[tool.path]) {
324309 return;
325310 }
326311
@@ -347,7 +332,7 @@ export function ToolExplorer({ · 20 unmodified lines
347332 return next;
348333 });
349334 }
350− }, [loadingDetailPaths, onLoadToolDetails, sourceSchemas, toolDetailsByPath]);
335+ }, [loadingDetailPaths, onLoadToolDetails, toolDetailsByPath]);
351336
352337 const flatLoadingRows = useMemo(() => {
353338 if (search.length > 0 || viewMode !== "flat") {
@@ -407,6 +392,7 @@ export function ToolExplorer({ · 53 unmodified lines
407392 sourceDialogMeta={sourceDialogMeta}
408393 sourceAuthProfiles={sourceAuthProfiles}
409394 existingSourceNames={sidebarExistingSourceNames}
395+ onSourceDeleted={onSourceDeleted}
410396 />
411397 ) : null}
412398
@@ -461,7 +447,6 @@ export function ToolExplorer({ · 48 unmodified lines
461447 onSelectTool={toggleSelectTool}
462448 onExpandedChange={maybeLoadToolDetails}
463449 detailLoadingPaths={loadingDetailPaths}
464− sourceSchemasBySource={sourceSchemas}
465450 scrollContainerRef={flatListRef}
466451 loadingRows={flatLoadingRows}
467452 />
@@ -491,7 +476,6 @@ export function ToolExplorer({ · 23 unmodified lines
491476 onSelectTool={toggleSelectTool}
492477 onExpandedChange={maybeLoadToolDetails}
493478 detailLoadingPaths={loadingDetailPaths}
494− sourceSchemasBySource={sourceSchemas}
495479 source={group.type === "source" ? sourceByName.get(group.label) : undefined}
496480 search={search}
497481 />
executor/apps/web/src/components/tools/explorer/tool-detail.tsx+14−91
@@ -10,89 +10,18 @@ const codePlugin = createCodePlugin({ · 9 unmodified lines
1010 themes: ["github-light", "github-dark"],
1111 });
1212
13−function 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−
34−function shouldUseStrictType(displayType?: string): boolean {
35− if (!displayType) return false;
36− return displayType.includes("...");
37−}
38−
39−function escapeRegExp(value: string): string {
40− return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
41−}
42−
43−function 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−
5913 export function ToolDetail({
6014 tool,
6115 depth,
62− loading: _loading = false,
63− sourceSchemas,
6416 }: {
6517 tool: ToolDescriptor;
6618 depth: number;
67− loading?: boolean;
68− sourceSchemas?: Record<string, string>;
6919 }) {
7020 const insetLeft = depth * 20 + 8 + 16 + 8;
71− const displayArgsType = tool.argsType?.trim();
72− const displayReturnsType = tool.returnsType?.trim();
73− const strictArgsType = tool.strictArgsType?.trim();
74− const strictReturnsType = tool.strictReturnsType?.trim();
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);
21+ const inputHint = tool.display?.input?.trim();
22+ const outputHint = tool.display?.output?.trim();
23+ const required = tool.typing?.requiredInputKeys ?? [];
24+ const hasDetails = Boolean(tool.description || inputHint || outputHint || required.length > 0);
9625
9726 return (
9827 <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
12251 </div>
12352 )}
12453
125− {resolvedArgsType && <TypeSignature raw={resolvedArgsType} label="Arguments" />}
126− {resolvedReturnsType && <TypeSignature raw={resolvedReturnsType} label="Returns" />}
127− {schemaEntries.length > 0 && (
128− <div className="space-y-2">
54+ {inputHint && <TypeSignature raw={inputHint} label="Arguments" />}
55+ {outputHint && <TypeSignature raw={outputHint} label="Returns" />}
56+
57+ {required.length > 0 ? (
58+ <div className="space-y-1">
12959 <p className="text-[9px] font-mono uppercase tracking-wider text-muted-foreground/50">
130− Schemas
60+ Required Keys
61+ </p>
62+ <p className="text-[11px] font-mono text-muted-foreground break-words">
63+ {required.join(", ")}
13164 </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− ))}
14265 </div>
143− )}
66+ ) : null}
14467 </div>
14568 );
14669 }
executor/apps/web/src/components/tools/explorer/type-signature.tsx+11−18
@@ -1,6 +1,7 @@
11 "use client";
22
3−import { useEffect, useMemo, useState } from "react";
3+import { useMemo } from "react";
4+import { useQuery as useTanstackQuery } from "@tanstack/react-query";
45
56 function prettyType(raw: string): string {
67 const trimmed = raw.trim();
@@ -48,26 +49,18 @@ function prettyType(raw: string): string { · 41 unmodified lines
4849
4950 export function TypeSignature({ raw, label }: { raw: string; label: string }) {
5051 const formatted = useMemo(() => prettyType(raw), [raw]);
51− const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null);
5252
53− useEffect(() => {
54− let cancelled = false;
55−
56− import("shiki").then(({ codeToHtml }) =>
57− codeToHtml(formatted, {
53+ const { data: highlightedHtml } = useTanstackQuery<string>({
54+ queryKey: ["type-signature", formatted],
55+ queryFn: async () => {
56+ const { codeToHtml } = await import("shiki");
57+ return codeToHtml(formatted, {
5858 lang: "typescript",
5959 themes: { light: "github-light", dark: "github-dark" },
60− }).then((html) => {
61− if (!cancelled) {
62− setHighlightedHtml(html);
63− }
64− }),
65− );
66−
67− return () => {
68− cancelled = true;
69− };
70− }, [formatted]);
60+ });
61+ },
62+ staleTime: Infinity,
63+ });
7164
7265 return (
7366 <div>
executor/apps/web/src/components/tools/setup-card.tsx+18−33
@@ -1,8 +1,9 @@
11 "use client";
22
3−import { useEffect, useMemo, useState } from "react";
3+import { useMemo, useState } from "react";
44 import { Check, ChevronRight, Copy } from "lucide-react";
55 import Image from "next/image";
6+import { useQuery as useTanstackQuery } from "@tanstack/react-query";
67 import { Button } from "@/components/ui/button";
78 import {
89 Collapsible,
@@ -64,8 +65,6 @@ export function McpSetupCard({ · 55 unmodified lines
6465 const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code");
6566 const [copiedKey, setCopiedKey] = useState<string | null>(null);
6667 const [headersOpen, setHeadersOpen] = useState(false);
67− const [anonymousAccessToken, setAnonymousAccessToken] = useState<string | null>(null);
68− const [anonymousTokenError, setAnonymousTokenError] = useState<string | null>(null);
6968
7069 const origin = useMemo(() => {
7170 if (typeof window === "undefined") return "";
@@ -86,36 +85,22 @@ export function McpSetupCard({ · 14 unmodified lines
8685
8786 const isAnonymousSession = isAnonymousSessionId(sessionId);
8887
89− useEffect(() => {
90− let cancelled = false;
91−
92− if (!isAnonymousSession) {
93− setAnonymousAccessToken(null);
94− setAnonymousTokenError(null);
95− return;
96− }
97−
98− const hydrateToken = async () => {
99− try {
100− const token = await getAnonymousAuthToken();
101− if (!cancelled) {
102− setAnonymousAccessToken(token.accessToken);
103− setAnonymousTokenError(null);
104− }
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]);
88+ const anonymousTokenQuery = useTanstackQuery<{ accessToken: string; actorId: string; expiresAtMs: number } | null>({
89+ queryKey: ["mcp-anonymous-token", sessionId],
90+ queryFn: async () => {
91+ const token = await getAnonymousAuthToken();
92+ return token;
93+ },
94+ enabled: isAnonymousSession,
95+ retry: false,
96+ });
97+
98+ const anonymousAccessToken = anonymousTokenQuery.data?.accessToken ?? null;
99+ const anonymousTokenError = anonymousTokenQuery.isError
100+ ? anonymousTokenQuery.error instanceof Error
101+ ? anonymousTokenQuery.error.message
102+ : "Failed to get anonymous token"
103+ : null;
119104
120105 const headerLines = useMemo(() => {
121106 return [
executor/apps/web/src/components/tools/source-favicon.tsx+6−8
@@ -1,6 +1,6 @@
11 "use client";
22
3−import { useEffect, useState } from "react";
3+import { useState } from "react";
44 import { Layers, Globe, Server } from "lucide-react";
55 import Image from "next/image";
66 import type { ToolSourceRecord } from "@/lib/types";
@@ -38,19 +38,17 @@ export function SourceFavicon({ · 31 unmodified lines
3838 : source
3939 ? getSourceFavicon(source)
4040 : null;
41− const [failed, setFailed] = useState(false);
41+ const [failedSrc, setFailedSrc] = useState<string | null>(null);
42+ const isFailed = Boolean(sourceFavicon && failedSrc === sourceFavicon);
4243
43− useEffect(() => {
44− setFailed(false);
45− }, [sourceFavicon]);
46−
47− if (!sourceFavicon || failed) {
44+ if (!sourceFavicon || isFailed) {
4845 const sourceType = fallbackType ?? source?.type ?? "openapi";
4946 return <DefaultSourceIcon type={sourceType} className={iconClassName} />;
5047 }
5148
5249 return (
5350 <Image
51+ key={sourceFavicon}
5452 src={sourceFavicon}
5553 alt=""
5654 width={imageSize}
@@ -58,7 +56,7 @@ export function SourceFavicon({ · 1 unmodified line
5856 className={imageClassName ?? "w-full h-full object-contain"}
5957 loading="lazy"
6058 unoptimized
61− onError={() => setFailed(true)}
59+ onError={() => setFailedSrc(sourceFavicon)}
6260 />
6361 );
6462 }
executor/apps/web/src/components/tools/view.tsx+7−4
@@ -1,6 +1,6 @@
11 "use client";
22
3−import { useMemo, useState } from "react";
3+import { useCallback, useMemo, useState } from "react";
44 import {
55 Plus,
66 } from "lucide-react";
@@ -76,12 +76,11 @@ export function ToolsView({ · 69 unmodified lines
7676 warnings,
7777 sourceQuality,
7878 sourceAuthProfiles,
79− sourceSchemas,
8079 loadingSources,
8180 loadingTools,
8281 refreshingTools,
8382 loadToolDetails,
84− } = useWorkspaceTools(context ?? null, { includeDetails: false, includeDtsUrls: false });
83+ } = useWorkspaceTools(context ?? null, { includeDetails: false });
8584 const existingSourceNames = useMemo(() => new Set(sourceItems.map((source) => source.name)), [sourceItems]);
8685 const toolSourceNames = useMemo(
8786 () => new Set(tools.map((tool) => sourceLabel(tool.source))),
@@ -104,6 +103,10 @@ export function ToolsView({ · 16 unmodified lines
104103 && (sourceItems.some((source) => source.name === selectedSource) || toolSourceNames.has(selectedSource))
105104 ? selectedSource
106105 : null;
106+
107+ const handleSourceDeleted = useCallback((sourceName: string) => {
108+ setSelectedSource((current) => (current === sourceName ? null : current));
109+ }, []);
107110 const openConnectionCreate = (sourceKey?: string) => {
108111 setConnectionDialogEditing(null);
109112 setConnectionDialogSourceKey(sourceKey ?? null);
@@ -196,8 +199,8 @@ export function ToolsView({ · 86 unmodified lines
196199 loading={loadingTools}
197200 sourceDialogMeta={sourceDialogMeta}
198201 sourceAuthProfiles={sourceAuthProfiles}
199− sourceSchemas={sourceSchemas}
200202 existingSourceNames={existingSourceNames}
203+ onSourceDeleted={handleSourceDeleted}
201204 onLoadToolDetails={loadToolDetails}
202205 warnings={warnings}
203206 initialSource={initialSource}
executor/apps/web/src/hooks/use/workspace-tools.ts+7−105
@@ -1,10 +1,10 @@
11 "use client";
22
3−import { useCallback, useEffect, useRef, useState } from "react";
3+import { useCallback, useEffect, useRef } from "react";
44 import { useQuery as useTanstackQuery } from "@tanstack/react-query";
55 import { useAction, useQuery as useConvexQuery } from "convex/react";
66 import { convexApi } from "@/lib/convex-api";
7−import type { OpenApiSourceQuality, SourceAuthProfile, ToolDescriptor, ToolSourceRecord } from "@/lib/types";
7+import type { OpenApiSourceQuality, SourceAuthProfile, ToolDescriptor } from "@/lib/types";
88 import type { Id } from "@executor/convex/_generated/dataModel";
99
1010 interface WorkspaceContext {
@@ -19,7 +19,8 @@ interface WorkspaceToolsQueryResult { · 8 unmodified lines
1919 warnings: string[];
2020 sourceQuality: Record<string, OpenApiSourceQuality>;
2121 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;
2324 debug?: {
2425 mode: "cache-fresh" | "cache-stale" | "rebuild";
2526 includeDts: boolean;
@@ -35,13 +36,8 @@ interface WorkspaceToolsQueryResult { · 9 unmodified lines
3536 };
3637 }
3738
38−interface WorkspaceToolDtsResult {
39− dtsUrls: Record<string, string>;
40−}
41−
4239 interface UseWorkspaceToolsOptions {
4340 includeDetails?: boolean;
44− includeDtsUrls?: boolean;
4541 }
4642
4743 /**
@@ -55,12 +51,9 @@ export function useWorkspaceTools( · 7 unmodified lines
5551 options: UseWorkspaceToolsOptions = {},
5652 ) {
5753 const includeDetails = options.includeDetails ?? true;
58− const includeDtsUrls = options.includeDtsUrls ?? true;
5954 const listToolsWithWarningsRaw = useAction(convexApi.executorNode.listToolsWithWarnings);
60− const listToolDtsUrls = useAction(convexApi.executorNode.listToolDtsUrls);
6155 const listToolsWithWarnings = listToolsWithWarningsRaw as unknown as (args: Record<string, unknown>) => Promise<WorkspaceToolsQueryResult>;
6256 const detailsCacheRef = useRef<Map<string, ToolDescriptor>>(new Map());
63− const [sourceSchemasBySource, setSourceSchemasBySource] = useState<Record<string, Record<string, string>>>({});
6457
6558 // Watch tool sources reactively so we invalidate when sources change
6659 const toolSources = useConvexQuery(
@@ -88,7 +81,7 @@ export function useWorkspaceTools( · 21 unmodified lines
8881 warnings: [],
8982 sourceQuality: {},
9083 sourceAuthProfiles: {},
91− sourceSchemas: {},
84+ typesUrl: undefined,
9285 };
9386 }
9487 return await listToolsWithWarnings({
@@ -97,7 +90,6 @@ export function useWorkspaceTools( · 2 unmodified lines
9790 ...(context.clientId && { clientId: context.clientId }),
9891 ...(context.sessionId && { sessionId: context.sessionId }),
9992 includeDetails,
100− includeSchemaRegistry: false,
10193 });
10294 },
10395 enabled: !!context,
@@ -133,39 +125,12 @@ export function useWorkspaceTools( · 29 unmodified lines
133125 ...(context.sessionId && { sessionId: context.sessionId }),
134126 includeDetails: true,
135127 includeSourceMeta: false,
136− includeSchemaRegistry: true,
137128 toolPaths: missing,
138129 });
139130
140131 for (const tool of detailedInventory.tools) {
141132 cache.set(tool.path, tool);
142133 }
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− }
169134 }
170135
171136 const result: Record<string, ToolDescriptor> = {};
@@ -182,10 +147,6 @@ export function useWorkspaceTools( · 10 unmodified lines
182147 detailsCacheRef.current.clear();
183148 }, [context?.workspaceId, context?.actorId, context?.clientId, context?.sessionId]);
184149
185− useEffect(() => {
186− setSourceSchemasBySource({});
187− }, [context?.workspaceId, context?.actorId, context?.clientId, context?.sessionId]);
188−
189150 useEffect(() => {
190151 if (!inventoryData || !includeDetails) {
191152 return;
@@ -196,77 +157,18 @@ export function useWorkspaceTools( · 4 unmodified lines
196157 }
197158 }, [inventoryData, includeDetails]);
198159
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−
256160 return {
257161 tools: inventoryData?.tools ?? [],
258162 warnings: inventoryData?.warnings ?? [],
259− /** Per-source .d.ts download URLs for Monaco IntelliSense. Keyed by source key (e.g. "openapi:cloudflare"). */
260− dtsUrls: dtsData?.dtsUrls ?? {},
163+ /** Workspace-wide Monaco `.d.ts` bundle URL (may be undefined). */
164+ typesUrl: inventoryData?.typesUrl,
261165 /** Per-source OpenAPI quality metrics (unknown/fallback type rates). */
262166 sourceQuality: inventoryData?.sourceQuality ?? {},
263167 sourceAuthProfiles: inventoryData?.sourceAuthProfiles ?? {},
264− sourceSchemas: sourceSchemasBySource,
265168 debug: inventoryData?.debug,
266169 loadingSources: inventoryData?.debug?.timedOutSources ?? [],
267170 loadingTools: !!context && toolsLoading,
268171 refreshingTools: !!context && toolsFetching,
269− loadingTypes: !!context && includeDtsUrls && hasOpenApiSource && !!inventoryData && dtsLoading,
270172 // Backward compatibility for callers that still use a single loading state.
271173 loading: !!context && toolsLoading,
272174 loadToolDetails,
executor/apps/web/src/lib/convex-provider.tsx+22−39
@@ -1,6 +1,6 @@
11 "use client";
22
3−import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
3+import { createContext, useCallback, useContext, useMemo } from "react";
44 import { ConvexProviderWithAuth } from "convex/react";
55 import {
66 AuthKitProvider,
@@ -9,6 +9,7 @@ import { · 2 unmodified lines
99 } from "@workos-inc/authkit-nextjs/components";
1010 import { ConvexReactClient } from "convex/react";
1111 import type { ReactNode } from "react";
12+import { useQueryClient, useQuery as useTanstackQuery } from "@tanstack/react-query";
1213 import {
1314 getAnonymousAuthToken,
1415 readStoredAnonymousAuthToken,
@@ -34,58 +35,40 @@ export function useWorkosAuthState() { · 19 unmodified lines
3435 }
3536
3637 function useConvexAuthFromAnonymous() {
37− const [loading, setLoading] = useState(() => readStoredAnonymousAuthToken() === null);
38− const [token, setToken] = useState<string | null>(() => readStoredAnonymousAuthToken()?.accessToken ?? null);
39−
40− useEffect(() => {
41− let cancelled = false;
42−
43− const bootstrap = async () => {
44− try {
45− const auth = await getAnonymousAuthToken();
46− if (!cancelled) {
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− }, []);
38+ const queryClient = useQueryClient();
39+ const tokenQuery = useTanstackQuery<string | null>({
40+ queryKey: ["anonymous-auth-token"],
41+ queryFn: async () => {
42+ const auth = await getAnonymousAuthToken();
43+ return auth.accessToken;
44+ },
45+ initialData: () => readStoredAnonymousAuthToken()?.accessToken ?? null,
46+ retry: false,
47+ });
6648
6749 const fetchAccessToken = useCallback(async () => {
6850 const stored = readStoredAnonymousAuthToken();
6951 if (stored) {
70− if (stored.accessToken !== token) {
71− setToken(stored.accessToken);
52+ if (stored.accessToken !== tokenQuery.data) {
53+ queryClient.setQueryData(["anonymous-auth-token"], stored.accessToken);
54+ return stored.accessToken;
7255 }
73− return stored.accessToken;
56+ queryClient.setQueryData(["anonymous-auth-token"], tokenQuery.data);
57+ return tokenQuery.data;
7458 }
7559
7660 const refreshed = await getAnonymousAuthToken(true);
77− setToken(refreshed.accessToken);
78− setLoading(false);
61+ queryClient.setQueryData(["anonymous-auth-token"], refreshed.accessToken);
7962 return refreshed.accessToken;
80− }, [token]);
63+ }, [queryClient, tokenQuery.data]);
8164
8265 return useMemo(
8366 () => ({
84− isLoading: loading,
85− isAuthenticated: Boolean(token),
67+ isLoading: tokenQuery.isPending,
68+ isAuthenticated: Boolean(tokenQuery.data),
8669 fetchAccessToken,
8770 }),
88− [fetchAccessToken, loading, token],
71+ [fetchAccessToken, tokenQuery.isPending, tokenQuery.data],
8972 );
9073 }
9174
executor/apps/web/src/lib/types.ts+13−6
@@ -106,12 +106,19 @@ export interface ToolDescriptor { · 105 unmodified lines
106106 description: string;
107107 approval: ToolApprovalMode;
108108 source?: string;
109− argsType?: string;
110− returnsType?: string;
111− strictArgsType?: string;
112− strictReturnsType?: string;
113− argPreviewKeys?: string[];
114− operationId?: string;
109+ typing?: {
110+ requiredInputKeys?: string[];
111+ previewInputKeys?: string[];
112+ typedRef?: {
113+ kind: "openapi_operation";
114+ sourceKey: string;
115+ operationId: string;
116+ };
117+ };
118+ display?: {
119+ input?: string;
120+ output?: string;
121+ };
115122 }
116123
117124 export interface OpenApiSourceQuality {
executor/packages/convex/_generated/api.d.ts+114−12
@@ -130,12 +130,6 @@ export declare const api: { · 129 unmodified lines
130130 >;
131131 };
132132 executorNode: {
133− listToolDtsUrls: FunctionReference<
134− "action",
135− "public",
136− { actorId?: string; sessionId?: string; workspaceId: Id<"workspaces"> },
137− any
138− >;
139133 listToolsWithWarnings: FunctionReference<
140134 "action",
141135 "public",
@@ -143,7 +137,6 @@ export declare const api: { · 1 unmodified line
143137 actorId?: string;
144138 clientId?: string;
145139 includeDetails?: boolean;
146− includeSchemaRegistry?: boolean;
147140 includeSourceMeta?: boolean;
148141 sessionId?: string;
149142 toolPaths?: Array<string>;
@@ -287,7 +280,7 @@ export declare const api: { · 137 unmodified lines
287280 bootstrapAnonymousSession: FunctionReference<
288281 "mutation",
289282 "public",
290− { sessionId?: string },
283+ { actorId?: string; sessionId?: string },
291284 any
292285 >;
293286 deleteToolSource: FunctionReference<
@@ -1098,7 +1091,7 @@ export declare const internal: { · 804 unmodified lines
10981091 >;
10991092 };
11001093 migrations: {
1101− backfillDtsStorageIds: FunctionReference<
1094+ cleanupAccessPolicyEmptyStringSentinels: FunctionReference<
11021095 "mutation",
11031096 "internal",
11041097 {
@@ -1110,7 +1103,7 @@ export declare const internal: { · 5 unmodified lines
11101103 },
11111104 any
11121105 >;
1113− cleanupAccessPolicyEmptyStringSentinels: FunctionReference<
1106+ cleanupTaskEmptyStringSentinels: FunctionReference<
11141107 "mutation",
11151108 "internal",
11161109 {
@@ -1122,7 +1115,7 @@ export declare const internal: { · 5 unmodified lines
11221115 },
11231116 any
11241117 >;
1125− cleanupTaskEmptyStringSentinels: FunctionReference<
1118+ cleanupWorkspaceToolCacheLegacyDtsStorageIds: FunctionReference<
11261119 "mutation",
11271120 "internal",
11281121 {
@@ -1226,6 +1219,115 @@ export declare const internal: { · 97 unmodified lines
12261219 any
12271220 >;
12281221 };
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+ };
12291331 workspaceAuthInternal: {
12301332 getWorkspaceAccessForAnonymousSubject: FunctionReference<
12311333 "query",
@@ -1257,11 +1359,11 @@ export declare const internal: { · 25 unmodified lines
12571359 "mutation",
12581360 "internal",
12591361 {
1260− dtsStorageIds: Array<{ sourceKey: string; storageId: Id<"_storage"> }>;
12611362 signature: string;
12621363 sizeBytes: number;
12631364 storageId: Id<"_storage">;
12641365 toolCount: number;
1366+ typesStorageId?: Id<"_storage">;
12651367 workspaceId: Id<"workspaces">;
12661368 },
12671369 any
executor/packages/convex/access-controls.test.ts+1−0
@@ -21,6 +21,7 @@ function setup() { · 20 unmodified lines
2121 "./app.ts": () => import("./app"),
2222 "./billingInternal.ts": () => import("./billingInternal"),
2323 "./billingSync.ts": () => import("./billingSync"),
24+ "./toolRegistry.ts": () => import("./toolRegistry"),
2425 "./_generated/api.js": () => import("./_generated/api.js"),
2526 });
2627 }
executor/packages/convex/accounts.ts+2−4
@@ -64,10 +64,8 @@ async function deleteWorkspaceData( · 63 unmodified lines
6464 .collect();
6565 for (const cacheEntry of cachedToolsets) {
6666 await ctx.storage.delete(cacheEntry.storageId).catch(() => {});
67− if (cacheEntry.dtsStorageIds) {
68− for (const dtsEntry of cacheEntry.dtsStorageIds) {
69− await ctx.storage.delete(dtsEntry.storageId).catch(() => {});
70− }
67+ if (cacheEntry.typesStorageId) {
68+ await ctx.storage.delete(cacheEntry.typesStorageId).catch(() => {});
7169 }
7270 await ctx.db.delete(cacheEntry._id);
7371 }
executor/packages/convex/database.test.ts+1−0
@@ -7,6 +7,7 @@ import schema from "./schema"; · 6 unmodified lines
77 function setup() {
88 return convexTest(schema, {
99 "./database.ts": () => import("./database"),
10+ "./toolRegistry.ts": () => import("./toolRegistry"),
1011 "./_generated/api.js": () => import("./_generated/api.js"),
1112 });
1213 }
executor/packages/convex/executor-mcp.e2e.test.ts+39−70
@@ -2,7 +2,6 @@ import { expect, test } from "bun:test"; · 1 unmodified line
22 import { convexTest } from "convex-test";
33 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
44 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5−import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
65 import { internal } from "./_generated/api";
76 import type { Id } from "./_generated/dataModel.d.ts";
87 import schema from "./schema";
@@ -16,12 +15,36 @@ function setup() { · 7 unmodified lines
1615 "./auth.ts": () => import("./auth"),
1716 "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),
1817 "./workspaceToolCache.ts": () => import("./workspaceToolCache"),
18+ "./toolRegistry.ts": () => import("./toolRegistry"),
1919 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),
2020 "./_generated/api.js": () => import("./_generated/api.js"),
2121 });
2222 }
2323
24−function createMcpTransport(
24+async 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+
47+async function createMcpTransport(
2548 t: ReturnType<typeof setup>,
2649 workspaceId: string,
2750 actorId: string,
@@ -32,19 +55,27 @@ function createMcpTransport( · 4 unmodified lines
3255 const mcpPath = isAnonymousSession ? "/mcp/anonymous" : "/mcp";
3356 const url = new URL(`https://executor.test${mcpPath}`);
3457 url.searchParams.set("workspaceId", workspaceId);
35− if (isAnonymousSession) {
36− url.searchParams.set("actorId", actorId);
37− } else {
58+ if (!isAnonymousSession) {
3859 url.searchParams.set("sessionId", sessionId);
3960 }
4061 url.searchParams.set("clientId", clientId);
4162
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+
4269 return new StreamableHTTPClientTransport(url, {
4370 fetch: async (input, init) => {
4471 const raw = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;
4572 const parsed = new URL(raw);
4673 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 });
4879 },
4980 });
5081 }
@@ -86,7 +117,7 @@ test("MCP run_code survives delayed approval and completes", async () => { · 35 unmodified lines
86117 const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
87118
88119 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");
90121
91122 try {
92123 await client.connect(transport);
@@ -132,74 +163,12 @@ test("MCP run_code survives delayed approval and completes", async () => { · 39 unmodified lines
132163 }
133164 }, 60_000);
134165
135−test("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−
197166 test("MCP run_code returns denied after approval denial", async () => {
198167 const t = setup();
199168 const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
200169
201170 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");
203172
204173 try {
205174 await client.connect(transport);
executor/packages/convex/executor.ts+6−5
@@ -11,6 +11,7 @@ import { isTerminalTaskStatus, taskTerminalEventType } from "./task/status"; · 10 unmodified lines
1111 import { DEFAULT_TASK_TIMEOUT_MS } from "./task/constants";
1212 import { createTaskEvent } from "./task/events";
1313 import { markTaskFinished } from "./task/finish";
14+import { safeRunAfter } from "./lib/scheduler";
1415
1516 type TaskCreateContext = Pick<MutationCtx, "runMutation"> & {
1617 scheduler?: Pick<MutationCtx, "scheduler">["scheduler"];
@@ -84,9 +85,7 @@ async function createTaskRecord( · 67 unmodified lines
8485 throw new Error("Task scheduling is unavailable in this execution context");
8586 }
8687
87− await ctx.scheduler.runAfter(1, internal.executorNode.runTask, {
88− taskId,
89− });
88+ await safeRunAfter(ctx.scheduler, 1, internal.executorNode.runTask, { taskId });
9089 }
9190
9291 return { task };
@@ -174,7 +173,9 @@ export const createTask = action({ · 81 unmodified lines
174173 }
175174
176175 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, {
178179 code: args.code,
179180 timeoutMs: args.timeoutMs,
180181 runtimeId: args.runtimeId,
@@ -186,7 +187,7 @@ export const createTask = action({ · 5 unmodified lines
186187 });
187188
188189 if (!waitForResult) {
189− return { task: created.task };
190+ return { task: created.task as TaskRecord };
190191 }
191192
192193 const runOutcome = await ctx.runAction(internal.executorNode.runTask, {
executor/packages/convex/executorNode.github-profile.e2e.test.ts+7−18
@@ -1,6 +1,5 @@
11 import { expect, test } from "bun:test";
22 import { convexTest } from "convex-test";
3−import { api } from "./_generated/api";
43 import { internal } from "./_generated/api";
54 import schema from "./schema";
65
@@ -13,6 +12,7 @@ function setup() { · 6 unmodified lines
1312 "./executorNode.ts": () => import("./executorNode"),
1413 "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),
1514 "./workspaceToolCache.ts": () => import("./workspaceToolCache"),
15+ "./toolRegistry.ts": () => import("./toolRegistry"),
1616 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),
1717 "./_generated/api.js": () => import("./_generated/api.js"),
1818 });
@@ -47,11 +47,6 @@ test("convex-test keeps GitHub inventory build warm-cache fast", async () => { · 28 unmodified lines
4747 });
4848 const warmMs = performance.now() - warmStart;
4949
50− const publicWarm = await t.action(api.executorNode.listToolsWithWarnings, {
51− workspaceId: session.workspaceId,
52− sessionId: session.sessionId,
53− });
54−
5550 console.log(
5651 `github openapi convex-test profiling: cold=${coldMs.toFixed(0)}ms warm=${warmMs.toFixed(0)}ms tools=${cold.tools.length}`,
5752 );
@@ -60,18 +55,12 @@ test("convex-test keeps GitHub inventory build warm-cache fast", async () => { · 2 unmodified lines
6055 expect(cold.tools.length).toBe(warm.tools.length);
6156 expect(cold.warnings.some((warning: string) => warning.includes("skipped bundle"))).toBe(false);
6257 expect(warm.warnings.some((warning: string) => warning.includes("skipped bundle"))).toBe(false);
63− expect(Object.keys(cold.dtsUrls).length).toBe(0);
64− expect(Object.keys(warm.dtsUrls).length).toBe(0);
65− expect(publicWarm.tools.length).toBe(warm.tools.length);
66− expect(publicWarm.debug.skipCacheRead).toBe(false);
67− expect(publicWarm.debug.cacheHit).toBe(true);
68− expect(publicWarm.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);
58+ expect(typeof cold.typesUrl).toBe("string");
59+ expect(typeof warm.typesUrl).toBe("string");
60+ expect(cold.typesUrl).toBe(warm.typesUrl);
61+ expect(warm.debug.skipCacheRead).toBe(false);
62+ expect(warm.debug.cacheHit).toBe(true);
63+ expect(warm.debug.mode).toBe("cache-fresh");
7564
7665 expect(coldMs).toBeLessThan(12_000);
7766 expect(coldMs).toBeGreaterThan(warmMs * 3);
executor/packages/convex/executorNode.ts+6−35
@@ -10,11 +10,10 @@ import type { · 9 unmodified lines
1010 SourceAuthProfile,
1111 } from "../core/src/types";
1212 import { requireCanonicalActor } from "./runtime/actor_auth";
13+import { safeRunAfter } from "./lib/scheduler";
1314 import {
1415 listToolsForContext,
1516 listToolsWithWarningsForContext,
16− loadDtsUrls,
17− loadWorkspaceDtsStorageIds,
1817 type WorkspaceToolsDebug,
1918 } from "./runtime/workspace_tools";
2019 import { runQueuedTask } from "./runtime/task_runner";
@@ -28,7 +27,6 @@ export const listToolsWithWarnings = action({ · 7 unmodified lines
2827 sessionId: v.optional(v.string()),
2928 includeDetails: v.optional(v.boolean()),
3029 includeSourceMeta: v.optional(v.boolean()),
31− includeSchemaRegistry: v.optional(v.boolean()),
3230 toolPaths: v.optional(v.array(v.string())),
3331 },
3432 handler: async (
@@ -37,10 +35,9 @@ export const listToolsWithWarnings = action({ · 2 unmodified lines
3735 ): Promise<{
3836 tools: ToolDescriptor[];
3937 warnings: string[];
40− dtsUrls: Record<string, string>;
38+ typesUrl?: string;
4139 sourceQuality: Record<string, OpenApiSourceQuality>;
4240 sourceAuthProfiles: Record<string, SourceAuthProfile>;
43− sourceSchemas: Record<string, Record<string, string>>;
4441 debug: WorkspaceToolsDebug;
4542 }> => {
4643 const canonicalActorId = await requireCanonicalActor(ctx, {
@@ -54,10 +51,8 @@ export const listToolsWithWarnings = action({ · 7 unmodified lines
5451 actorId: canonicalActorId,
5552 clientId: args.clientId,
5653 }, {
57− includeDts: false,
5854 includeDetails: args.includeDetails ?? true,
5955 includeSourceMeta: args.includeSourceMeta ?? (args.toolPaths ? false : true),
60− includeSchemaRegistry: args.includeSchemaRegistry ?? false,
6156 toolPaths: args.toolPaths,
6257 sourceTimeoutMs: 2_500,
6358 allowStaleOnMismatch: true,
@@ -65,7 +60,7 @@ export const listToolsWithWarnings = action({ · 1 unmodified line
6560
6661 if (inventory.warnings.some((warning) => warning.includes("showing previous results while refreshing"))) {
6762 try {
68− await ctx.scheduler.runAfter(0, internal.executorNode.listToolsWithWarningsInternal, {
63+ await safeRunAfter(ctx.scheduler, 0, internal.executorNode.listToolsWithWarningsInternal, {
6964 workspaceId: args.workspaceId,
7065 actorId: canonicalActorId,
7166 clientId: args.clientId,
@@ -79,29 +74,6 @@ export const listToolsWithWarnings = action({ · 7 unmodified lines
7974 },
8075 });
8176
82−export 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−
10577 export const listToolsInternal = internalAction({
10678 args: {
10779 workspaceId: v.id("workspaces"),
@@ -109,7 +81,7 @@ export const listToolsInternal = internalAction({ · 1 unmodified line
10981 clientId: v.optional(v.string()),
11082 },
11183 handler: async (ctx, args): Promise<ToolDescriptor[]> => {
112− return await listToolsForContext(ctx, args, { includeDts: false });
84+ return await listToolsForContext(ctx, args);
11385 },
11486 });
11587
@@ -125,13 +97,12 @@ export const listToolsWithWarningsInternal = internalAction({ · 9 unmodified lines
12597 ): Promise<{
12698 tools: ToolDescriptor[];
12799 warnings: string[];
128− dtsUrls: Record<string, string>;
100+ typesUrl?: string;
129101 sourceQuality: Record<string, OpenApiSourceQuality>;
130102 sourceAuthProfiles: Record<string, SourceAuthProfile>;
131− sourceSchemas: Record<string, Record<string, string>>;
132103 debug: WorkspaceToolsDebug;
133104 }> => {
134− return await listToolsWithWarningsForContext(ctx, args, { includeDts: false });
105+ return await listToolsWithWarningsForContext(ctx, args);
135106 },
136107 });
137108
executor/packages/convex/http/mcp_handler.ts+55−35
@@ -2,6 +2,7 @@ import { httpAction } from "../_generated/server"; · 1 unmodified line
22 import { internal } from "../_generated/api";
33 import { handleMcpRequest, type McpWorkspaceContext } from "../../core/src/mcp-server";
44 import { isAnonymousIdentity } from "../auth/anonymous";
5+import { getAnonymousAuthIssuer } from "../auth/anonymous";
56 import {
67 getMcpAuthConfig,
78 isAnonymousSessionId,
@@ -13,6 +14,12 @@ import { createMcpExecutorService } from "./mcp_service"; · 5 unmodified lines
1314
1415 type McpEndpointMode = "default" | "anonymous";
1516
17+function 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+
1623 function createMcpHandler(mode: McpEndpointMode) {
1724 return httpAction(async (ctx, request) => {
1825 const url = new URL(request.url);
@@ -32,48 +39,61 @@ function createMcpHandler(mode: McpEndpointMode) { · 13 unmodified lines
3239
3340 let context: McpWorkspaceContext | undefined;
3441
35− if (mode === "anonymous") {
36− try {
37− const workspaceId = requestedContext?.workspaceId;
38− if (!workspaceId) {
42+ if (mode === "anonymous") {
43+ try {
44+ const workspaceId = requestedContext?.workspaceId;
45+ if (!workspaceId) {
3946 return Response.json(
4047 { error: "workspaceId query parameter is required for /mcp/anonymous" },
4148 { status: 400 },
4249 );
43− }
50+ }
4451
45− if (requestedContext?.sessionId || requestedContext?.actorId) {
46− return Response.json(
47− {
48− error:
49− "Legacy anonymous context query params are disabled. Use Authorization: Bearer <anonymous token>.",
50− },
51− { status: 400 },
52− );
53− }
52+ const anonymousAuthConfigured = isAnonymousAuthConfigured();
53+ if (anonymousAuthConfigured && (requestedContext?.sessionId || requestedContext?.actorId)) {
54+ return Response.json(
55+ {
56+ error:
57+ "Legacy anonymous context query params are disabled. Use Authorization: Bearer <anonymous token>.",
58+ },
59+ { status: 400 },
60+ );
61+ }
5462
55− const identity = await ctx.auth.getUserIdentity().catch(() => null);
56− if (!identity || !isAnonymousIdentity(identity)) {
57− return Response.json(
58− { error: "Anonymous bearer token is required for /mcp/anonymous" },
59− { status: 401 },
60− );
61− }
63+ const identity = await ctx.auth.getUserIdentity().catch(() => null);
64+ if (identity && isAnonymousIdentity(identity)) {
65+ const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForAnonymousSubject, {
66+ workspaceId,
67+ actorId: identity.subject,
68+ });
6269
63− const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForAnonymousSubject, {
64− workspaceId,
65− actorId: identity.subject,
66− });
67−
68− context = {
69− workspaceId,
70− actorId: access.actorId,
71− clientId: requestedContext?.clientId,
72− };
73− } catch (error) {
74− return Response.json(
75− { error: error instanceof Error ? error.message : "Workspace authorization failed" },
76− { status: 403 },
70+ context = {
71+ workspaceId,
72+ actorId: access.actorId,
73+ clientId: requestedContext?.clientId,
74+ };
75+ } else if (!anonymousAuthConfigured && requestedContext?.actorId) {
76+ // Local/test fallback: allow query-param actorId when anonymous auth isn't configured.
77+ const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForAnonymousSubject, {
78+ workspaceId,
79+ actorId: requestedContext.actorId,
80+ });
81+
82+ context = {
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 },
7797 );
7898 }
7999 } else {
executor/packages/convex/lib/scheduler.ts+26−0
@@ -0,0 +1,26 @@
1+type SchedulerLike = {
2+ runAfter: (delayMs: number, functionReference: any, args: any) => Promise<any>;
3+};
4+
5+export function isSchedulerDisabled(): boolean {
6+ return process.env.DISABLE_CONVEX_SCHEDULER === "1";
7+}
8+
9+export 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
1616 "../../auth.ts": () => import("../../auth"),
1717 "../../workspaceAuthInternal.ts": () => import("../../workspaceAuthInternal"),
1818 "../../workspaceToolCache.ts": () => import("../../workspaceToolCache"),
19+ "../../toolRegistry.ts": () => import("../../toolRegistry"),
1920 "../../openApiSpecCache.ts": () => import("../../openApiSpecCache"),
2021 "../../_generated/api.js": () => import("../../_generated/api.js"),
2122 });
2223 }
2324
24−function createMcpTransport(
25+async 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+
48+async function createMcpTransport(
2549 t: ReturnType<typeof setup>,
2650 workspaceId: string,
2751 actorId: string,
@@ -32,19 +56,27 @@ function createMcpTransport( · 4 unmodified lines
3256 const mcpPath = isAnonymousSession ? "/mcp/anonymous" : "/mcp";
3357 const url = new URL(`https://executor.test${mcpPath}`);
3458 url.searchParams.set("workspaceId", workspaceId);
35− if (isAnonymousSession) {
36− url.searchParams.set("actorId", actorId);
37− } else {
59+ if (!isAnonymousSession) {
3860 url.searchParams.set("sessionId", sessionId);
3961 }
4062 url.searchParams.set("clientId", clientId);
4163
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+
4270 return new StreamableHTTPClientTransport(url, {
4371 fetch: async (input, init) => {
4472 const raw = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;
4573 const parsed = new URL(raw);
4674 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 });
4880 },
4981 });
5082 }
@@ -54,7 +86,7 @@ test("MCP run_code no longer hits typecheck OOM path", async () => { · 3 unmodified lines
5486 const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
5587
5688 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);
5890
5991 try {
6092 await client.connect(transport);
executor/packages/convex/migrations.ts+21−9
@@ -92,15 +92,6 @@ export const deleteAnonymousSessionsMissingUserId = migrations.define({ · 91 unmodified lines
9292 },
9393 });
9494
95−export const backfillDtsStorageIds = migrations.define({
96− table: "workspaceToolCache",
97− migrateOne: async (_ctx, entry) => {
98− if (entry.dtsStorageIds === undefined) {
99− return { dtsStorageIds: [] };
100− }
101− },
102−});
103−
10495 export const cleanupTaskEmptyStringSentinels = migrations.define({
10596 table: "tasks",
10697 migrateOne: async (_ctx, task) => {
@@ -121,4 +112,25 @@ export const cleanupAccessPolicyEmptyStringSentinels = migrations.define({ · 14 unmodified lines
121112 },
122113 });
123114
115+export 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+
124136 export const run = migrations.runner();
executor/packages/convex/openApiSpecCache.test.ts+1−0
@@ -6,6 +6,7 @@ import schema from "./schema"; · 5 unmodified lines
66 function setup() {
77 return convexTest(schema, {
88 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),
9+ "./toolRegistry.ts": () => import("./toolRegistry"),
910 "./_generated/api.js": () => import("./_generated/api.js"),
1011 });
1112 }
executor/packages/convex/runtime/tool_call_resolution.ts+84−53
@@ -1,17 +1,20 @@
11 "use node";
22
33 import type { ActionCtx } from "../_generated/server";
4+import { internal } from "../_generated/api";
45 import { parseGraphqlOperationPaths } from "../../core/src/graphql/operation-paths";
56 import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../core/src/types";
7+import { rehydrateTools, type SerializedTool } from "../../core/src/tool/source-serialization";
68 import { getDecisionForContext, getToolDecision } from "./policy";
7−import { resolveAliasedToolPath, resolveClosestToolPath, suggestToolPaths, toPreferredToolPath } from "./tool_paths";
8−import { baseTools, getWorkspaceTools } from "./workspace_tools";
9+import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";
10+import { baseTools } from "./workspace_tools";
11+import { sourceSignature } from "./tool_source_loading";
912
1013 export function getGraphqlDecision(
1114 task: TaskRecord,
1215 tool: ToolDefinition,
1316 input: unknown,
14− workspaceTools: Map<string, ToolDefinition>,
17+ workspaceTools: Map<string, ToolDefinition> | undefined,
1518 policies: AccessPolicyRecord[],
1619 ): { decision: PolicyDecision; effectivePaths: string[] } {
1720 const sourceName = tool._graphqlSource!;
@@ -30,7 +33,7 @@ export function getGraphqlDecision( · 12 unmodified lines
3033 let worstDecision: PolicyDecision = "allow";
3134
3235 for (const fieldPath of fieldPaths) {
33− const pseudoTool = workspaceTools.get(fieldPath);
36+ const pseudoTool = workspaceTools?.get(fieldPath);
3437 const fieldDecision = pseudoTool
3538 ? getDecisionForContext(
3639 pseudoTool,
@@ -63,31 +66,52 @@ export function getGraphqlDecision( · 26 unmodified lines
6366 return { decision: worstDecision, effectivePaths: fieldPaths };
6467 }
6568
66−function unknownToolErrorMessage(toolPath: string, availableTools: Map<string, ToolDefinition>): string {
67− const suggestions = suggestToolPaths(toolPath, availableTools);
68− const queryHint = toolPath
69− .split(".")
70− .filter(Boolean)
71− .join(" ");
72− const suggestionText = suggestions.length > 0
73− ? `\nDid you mean: ${suggestions.map((path) => `tools.${toPreferredToolPath(path)}`).join(", ")}`
74− : "";
75− const discoverHint = `\nTry: const found = await tools.discover({ query: "${queryHint}", compact: false, depth: 2, limit: 12 });`;
76− return `Unknown tool: ${toolPath}${suggestionText}${discoverHint}`;
69+async function resolveRegistryBuildId(
70+ ctx: ActionCtx,
71+ workspaceId: TaskRecord["workspaceId"],
72+): Promise<string> {
73+ const [state, sources] = await Promise.all([
74+ ctx.runQuery(internal.toolRegistry.getState, { workspaceId }) as Promise<null | { signature: string; readyBuildId?: string }>,
75+ ctx.runQuery(internal.database.listToolSources, { workspaceId }) as Promise<Array<{ id: string; updatedAt: number; enabled: boolean }>>,
76+ ]);
77+
78+ const enabledSources = sources.filter((source) => source.enabled);
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;
7790 }
7891
79−export async function ensureWorkspaceTools(
92+async function suggestFromRegistry(
8093 ctx: ActionCtx,
81− task: TaskRecord,
82− workspaceTools?: Map<string, ToolDefinition>,
83−): Promise<Map<string, ToolDefinition>> {
84− if (workspaceTools) {
85− return workspaceTools;
86− }
87− const result = await getWorkspaceTools(ctx, task.workspaceId, {
88− actorId: task.actorId,
89− });
90− return result.tools;
94+ workspaceId: TaskRecord["workspaceId"],
95+ buildId: string,
96+ toolPath: string,
97+): Promise<string[]> {
98+ const term = toolPath.split(".").filter(Boolean).join(" ");
99+ const hits = await ctx.runQuery(internal.toolRegistry.searchTools, {
100+ workspaceId,
101+ buildId,
102+ query: term,
103+ limit: 3,
104+ }) as Array<{ preferredPath: string }>;
105+ return hits.map((hit) => hit.preferredPath);
106+}
107+
108+function 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}`;
91115 }
92116
93117 export async function resolveToolForCall(
@@ -97,41 +121,48 @@ export async function resolveToolForCall( · 3 unmodified lines
97121 ): Promise<{
98122 tool: ToolDefinition;
99123 resolvedToolPath: string;
100− workspaceTools?: Map<string, ToolDefinition>;
101124 }> {
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+
103132 let resolvedToolPath = toolPath;
104− let tool = baseTools.get(toolPath);
105− if (!tool) {
106− workspaceTools = await ensureWorkspaceTools(ctx, task, workspaceTools);
107− tool = workspaceTools.get(toolPath);
108−
109− if (!tool) {
110− const aliasedPath = resolveAliasedToolPath(toolPath, workspaceTools);
111− if (aliasedPath) {
112− resolvedToolPath = aliasedPath;
113− tool = workspaceTools.get(aliasedPath);
114− }
133+ let entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, {
134+ workspaceId: task.workspaceId,
135+ buildId,
136+ path: toolPath,
137+ }) as null | { path: string; serializedToolJson: string };
138+
139+ if (!entry) {
140+ const normalized = normalizeToolPathForLookup(toolPath);
141+ const hits = await ctx.runQuery(internal.toolRegistry.getToolsByNormalizedPath, {
142+ workspaceId: task.workspaceId,
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;
115153 }
116154 }
117155
118− if (!tool) {
119− const availableTools = workspaceTools ?? baseTools;
120− const healedPath = resolveClosestToolPath(toolPath, availableTools);
121− if (healedPath) {
122− resolvedToolPath = healedPath;
123− tool = availableTools.get(healedPath);
124− }
156+ if (!entry) {
157+ const suggestions = await suggestFromRegistry(ctx, task.workspaceId, buildId, toolPath);
158+ throw new Error(unknownToolErrorMessage(toolPath, suggestions));
125159 }
126160
161+ const serialized = JSON.parse(entry.serializedToolJson) as SerializedTool;
162+ const [tool] = rehydrateTools([serialized], baseTools);
127163 if (!tool) {
128− const availableTools = workspaceTools ?? baseTools;
129− throw new Error(unknownToolErrorMessage(toolPath, availableTools));
164+ throw new Error(`Failed to rehydrate tool: ${resolvedToolPath}`);
130165 }
131166
132− return {
133− tool,
134− resolvedToolPath,
135− workspaceTools,
136− };
167+ return { tool, resolvedToolPath };
137168 }
executor/packages/convex/runtime/tool_descriptors.test.ts+26−10
@@ -2,17 +2,32 @@ import { expect, test } from "bun:test"; · 1 unmodified line
22 import type { ToolDefinition } from "../../core/src/types";
33 import { listVisibleToolDescriptors } from "./tool_descriptors";
44
5−test("listVisibleToolDescriptors formats type hints for client responses", () => {
5+test("listVisibleToolDescriptors derives display hints from schemas", () => {
66 const tool: ToolDefinition = {
77 path: "github.actions.add_custom_labels_to_self_hosted_runner_for_org",
88 description: "Add custom labels",
99 approval: "required",
1010 source: "openapi:github",
11− metadata: {
12− argsType: '{org:string;runner_id:number;labels:string[]}',
13− returnsType: '{total_count:number;labels:components["schemas"]["runner-label"][]}',
14− displayArgsType: '{ org: string; runner_id: number; labels: string[] }',
15− displayReturnsType: '{ total_count: number; labels: components["schemas"]["runner-label"][] }',
11+ typing: {
12+ inputSchema: {
13+ type: "object",
14+ properties: {
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+ },
1631 },
1732 run: async () => ({ total_count: 0, labels: [] }),
1833 };
@@ -27,8 +42,9 @@ test("listVisibleToolDescriptors formats type hints for client responses", () => · 8 unmodified lines
2742
2843 expect(descriptors).toHaveLength(1);
2944 const descriptor = descriptors[0]!;
30− expect(descriptor.argsType).toContain("org: string");
31− expect(descriptor.strictArgsType).toContain("runner_id: number");
32− expect(descriptor.returnsType).toContain('components["schemas"]["runner-label"][]');
33− expect(descriptor.strictReturnsType).toContain("total_count: number");
45+ expect(descriptor.display?.input).toContain("org");
46+ expect(descriptor.display?.input).toContain("runner_id");
47+ expect(descriptor.display?.output).toContain("total_count");
48+ expect(descriptor.typing?.requiredInputKeys).toEqual(expect.arrayContaining(["org", "runner_id", "labels"]));
49+ expect(descriptor.typing?.previewInputKeys).toEqual(expect.arrayContaining(["org", "runner_id"]));
3450 });
executor/packages/convex/runtime/tool_descriptors.ts+30−22
@@ -6,7 +6,8 @@ import type { · 5 unmodified lines
66 ToolDefinition,
77 ToolDescriptor,
88 } from "../../core/src/types";
9−import { formatTypeExpressionForClient } from "../../core/src/type-format";
9+import { jsonSchemaTypeHintFallback } from "../../core/src/openapi/schema-hints";
10+import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../../core/src/tool-typing/schema-utils";
1011 import { getDecisionForContext } from "./policy";
1112
1213 function toToolDescriptor(
@@ -23,12 +24,23 @@ function toToolDescriptor( · 10 unmodified lines
2324 source: tool.source,
2425 ...(includeDetails
2526 ? {
26− argsType: formatTypeExpressionForClient(tool.metadata?.displayArgsType ?? tool.metadata?.argsType),
27− returnsType: formatTypeExpressionForClient(tool.metadata?.displayReturnsType ?? tool.metadata?.returnsType),
28− strictArgsType: formatTypeExpressionForClient(tool.metadata?.argsType),
29− strictReturnsType: formatTypeExpressionForClient(tool.metadata?.returnsType),
30− argPreviewKeys: tool.metadata?.argPreviewKeys,
31− operationId: tool.metadata?.operationId,
27+ typing: tool.typing
28+ ? {
29+ requiredInputKeys: tool.typing.requiredInputKeys
30+ ?? extractTopLevelRequiredKeys(tool.typing.inputSchema),
31+ previewInputKeys: tool.typing.previewInputKeys
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+ },
3244 }
3345 : {}),
3446 };
@@ -58,21 +70,17 @@ export function computeOpenApiSourceQuality( · 23 unmodified lines
5870 let partialUnknownReturnsCount = 0;
5971
6072 for (const tool of tools) {
61− const argsType = tool.metadata?.argsType?.trim() ?? "";
62− const returnsType = tool.metadata?.returnsType?.trim() ?? "";
63−
64− if (!argsType || argsType === "Record<string, unknown>") {
65− unknownArgsCount += 1;
66− }
67− if (!returnsType || returnsType === "unknown") {
68− unknownReturnsCount += 1;
69− }
70− if (argsType.includes("unknown")) {
71− partialUnknownArgsCount += 1;
72− }
73− if (returnsType.includes("unknown")) {
74− partialUnknownReturnsCount += 1;
75− }
73+ const inputSchema = tool.typing?.inputSchema ?? {};
74+ const outputSchema = tool.typing?.outputSchema ?? {};
75+ const hasInput = Object.keys(inputSchema).length > 0;
76+ const hasOutput = Object.keys(outputSchema).length > 0;
77+ if (!hasInput) unknownArgsCount += 1;
78+ if (!hasOutput) unknownReturnsCount += 1;
79+ // Best-effort: count schema nodes that still include unknown-ish placeholders.
80+ const inputHint = hasInput ? jsonSchemaTypeHintFallback(inputSchema) : "unknown";
81+ const outputHint = hasOutput ? jsonSchemaTypeHintFallback(outputSchema) : "unknown";
82+ if (inputHint.includes("unknown")) partialUnknownArgsCount += 1;
83+ if (outputHint.includes("unknown")) partialUnknownReturnsCount += 1;
7684 }
7785
7886 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
88 PolicyDecision,
99 ResolvedToolCredential,
1010 TaskRecord,
11+ ToolDefinition,
1112 ToolCallRecord,
1213 ToolCallRequest,
1314 ToolRunContext,
1415 } from "../../core/src/types";
1516 import { describeError } from "../../core/src/utils";
1617 import { asPayload } from "../lib/object";
17−import { getToolDecision, isToolAllowedForTask } from "./policy";
18+import { getToolDecision, getDecisionForContext } from "./policy";
1819 import { baseTools } from "./workspace_tools";
1920 import { publishTaskEvent } from "./events";
2021 import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";
2122 import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials";
22−import { ensureWorkspaceTools, getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";
23+import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";
24+import { sourceSignature } from "./tool_source_loading";
2325
2426 function createApprovalId(): string {
2527 return `approval_${crypto.randomUUID()}`;
@@ -57,13 +59,132 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 31 unmodified lines
5759 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });
5860 const typedPolicies = policies as AccessPolicyRecord[];
5961
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);
61183
62184 let decision: PolicyDecision;
63185 let effectiveToolPath = resolvedToolPath;
64186 if (tool._graphqlSource) {
65− workspaceTools = await ensureWorkspaceTools(ctx, task, workspaceTools);
66− const result = getGraphqlDecision(task, tool, input, workspaceTools, typedPolicies);
187+ const result = getGraphqlDecision(task, tool, input, undefined, typedPolicies);
67188 decision = result.decision;
68189 if (result.effectivePaths.length > 0) {
69190 effectiveToolPath = result.effectivePaths.join(", ");
@@ -179,7 +300,8 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 109 unmodified lines
179300 actorId: task.actorId,
180301 clientId: task.clientId,
181302 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,
183305 };
184306 const value = await tool.run(input, context);
185307 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
4646 return collapsed.join("");
4747 }
4848
49−function normalizeToolPath(path: string): string {
49+export function normalizeToolPathForLookup(path: string): string {
5050 const segments = path
5151 .split(".")
5252 .filter(Boolean);
@@ -110,12 +110,12 @@ export function resolveAliasedToolPath( · 57 unmodified lines
110110 ): string | null {
111111 if (toolMap.has(requestedPath)) return requestedPath;
112112
113− const normalizedRequested = normalizeToolPath(requestedPath);
113+ const normalizedRequested = normalizeToolPathForLookup(requestedPath);
114114 if (!normalizedRequested) return null;
115115
116116 const matches: string[] = [];
117117 for (const path of toolMap.keys()) {
118− if (normalizeToolPath(path) === normalizedRequested) {
118+ if (normalizeToolPathForLookup(path) === normalizedRequested) {
119119 matches.push(path);
120120 }
121121 }
@@ -134,13 +134,13 @@ export function suggestToolPaths( · 12 unmodified lines
134134 toolMap: Map<string, ToolDefinition>,
135135 limit = 3,
136136 ): string[] {
137− const normalizedRequested = normalizeToolPath(requestedPath);
137+ const normalizedRequested = normalizeToolPathForLookup(requestedPath);
138138 const requestedSegments = normalizedRequested.split(".").filter(Boolean);
139139 const requestedNamespace = requestedSegments[0] ?? "";
140140
141141 return [...toolMap.keys()]
142142 .map((path) => {
143− const normalizedCandidate = normalizeToolPath(path);
143+ const normalizedCandidate = normalizeToolPathForLookup(path);
144144 const candidateSegments = normalizedCandidate.split(".").filter(Boolean);
145145 const candidateNamespace = candidateSegments[0] ?? "";
146146
@@ -173,7 +173,7 @@ export function resolveClosestToolPath( · 26 unmodified lines
173173 requestedPath: string,
174174 toolMap: Map<string, ToolDefinition>,
175175 ): string | null {
176− const normalizedRequested = normalizeToolPath(requestedPath);
176+ const normalizedRequested = normalizeToolPathForLookup(requestedPath);
177177 if (!normalizedRequested) return null;
178178
179179 const requestedNamespace = normalizedRequested.split(".").filter(Boolean)[0] ?? "";
@@ -182,7 +182,7 @@ export function resolveClosestToolPath( · 2 unmodified lines
182182
183183 const ranked = [...toolMap.keys()]
184184 .map((path) => {
185− const normalizedCandidate = normalizeToolPath(path);
185+ const normalizedCandidate = normalizeToolPathForLookup(path);
186186 const candidateNamespace = normalizedCandidate.split(".").filter(Boolean)[0] ?? "";
187187 const distance = levenshteinDistance(normalizedRequested, normalizedCandidate);
188188 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
2424
2525 const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000;
2626
27−/** Cache version - bump when PreparedOpenApiSpec shape changes. */
28−const TOOL_SOURCE_CACHE_VERSION = "v18";
27+/** Cache version - bump when tool snapshot/registry/type-hint semantics change. */
28+const TOOL_SOURCE_CACHE_VERSION = "v24";
2929
3030 export function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
3131 const parts = sources
@@ -279,7 +279,7 @@ export async function loadSourceArtifact( · 247 unmodified lines
279279 ctx: ActionCtx,
280280 source: ExternalToolSourceConfig,
281281 options: { includeDts?: boolean; workspaceId: Id<"workspaces">; actorId?: string },
282−): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[] }> {
282+): Promise<{ artifact?: CompiledToolSourceArtifact; warnings: string[]; openApiDts?: string }> {
283283 const includeDts = options.includeDts ?? true;
284284
285285 if (source.type === "openapi" && typeof source.spec === "string") {
@@ -289,7 +289,7 @@ export async function loadSourceArtifact( · 3 unmodified lines
289289 const warnings = (prepared.warnings ?? []).map(
290290 (warning) => `Source '${source.name}': ${warning}`,
291291 );
292− return { artifact, warnings };
292+ return { artifact, warnings, openApiDts: prepared.dts };
293293 } catch (error) {
294294 const message = error instanceof Error ? error.message : String(error);
295295 return {
executor/packages/convex/runtime/workspace_tools.ts+396−225
@@ -3,13 +3,16 @@ · 2 unmodified lines
33 import type { ActionCtx } from "../_generated/server";
44 import { internal } from "../_generated/api";
55 import type { Id } from "../_generated/dataModel.d.ts";
6−import { createCatalogTools, createDiscoverTool } from "../../core/src/tool-discovery";
6+import { buildWorkspaceTypeBundle } from "../../core/src/tool-typing/typebundle";
7+import { jsonSchemaTypeHintFallback } from "../../core/src/openapi/schema-hints";
8+import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../../core/src/tool-typing/schema-utils";
79 import {
810 materializeCompiledToolSource,
911 materializeWorkspaceSnapshot,
1012 type CompiledToolSourceArtifact,
1113 type WorkspaceToolSnapshot,
1214 } from "../../core/src/tool-sources";
15+import type { SerializedTool } from "../../core/src/tool/source-serialization";
1316 import type { ExternalToolSourceConfig } from "../../core/src/tool/source-types";
1417 import type {
1518 AccessPolicyRecord,
@@ -18,24 +21,165 @@ import type { · 2 unmodified lines
1821 ToolDefinition,
1922 ToolDescriptor,
2023 } from "../../core/src/types";
21−import {
22− extractSchemaRefKeys,
23− extractSourceSchemaTypesFromDts,
24−} from "../../core/src/tool-discovery/schema-registry";
2524 import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors";
2625 import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";
26+import { normalizeToolPathForLookup } from "./tool_paths";
2727
2828 const baseTools = new Map<string, ToolDefinition>();
2929
30−interface DtsStorageEntry {
31− sourceKey: string;
32− storageId: Id<"_storage">;
33−}
30+// Minimal built-in tools used by tests/demos.
31+// These are intentionally simple and are always approval-gated.
32+baseTools.set("admin.send_announcement", {
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+
66+baseTools.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.
96+baseTools.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+
128+baseTools.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+
149+baseTools.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+});
34178
35179 interface WorkspaceToolsResult {
36180 tools: Map<string, ToolDefinition>;
37181 warnings: string[];
38− dtsStorageIds: DtsStorageEntry[];
182+ typesStorageId?: Id<"_storage">;
39183 debug: WorkspaceToolsDebug;
40184 }
41185
@@ -54,7 +198,6 @@ export interface WorkspaceToolsDebug { · 12 unmodified lines
54198 }
55199
56200 interface GetWorkspaceToolsOptions {
57− includeDts?: boolean;
58201 sourceTimeoutMs?: number;
59202 allowStaleOnMismatch?: boolean;
60203 skipCacheRead?: boolean;
@@ -64,10 +207,9 @@ interface GetWorkspaceToolsOptions { · 3 unmodified lines
64207 interface WorkspaceToolInventory {
65208 tools: ToolDescriptor[];
66209 warnings: string[];
67− dtsStorageIds: DtsStorageEntry[];
210+ typesUrl?: string;
68211 sourceQuality: Record<string, OpenApiSourceQuality>;
69212 sourceAuthProfiles: Record<string, SourceAuthProfile>;
70− sourceSchemas: Record<string, Record<string, string>>;
71213 debug: WorkspaceToolsDebug;
72214 }
73215
@@ -122,125 +264,228 @@ function computeSourceAuthProfiles(tools: Map<string, ToolDefinition>): Record<s · 48 unmodified lines
122264 return profiles;
123265 }
124266
125−function formatSchemaRefToken(schemaKey: string): string {
126− return `components["schemas"][${JSON.stringify(schemaKey)}]`;
127−}
267+function mergeTools(externalTools: Iterable<ToolDefinition>): Map<string, ToolDefinition> {
268+ const merged = new Map<string, ToolDefinition>();
128269
129−function collectSchemaKeysBySource(tools: ToolDescriptor[]): Map<string, Set<string>> {
130− const refsBySource = new Map<string, Set<string>>();
131−
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− }
270+ for (const tool of baseTools.values()) {
271+ merged.set(tool.path, tool);
272+ }
151273
152− for (const key of keys) {
153− sourceRefs.add(key);
154− }
155− }
274+ for (const tool of externalTools) {
275+ merged.set(tool.path, tool);
156276 }
277+ return merged;
278+}
157279
158− return refsBySource;
280+function 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);
159290 }
160291
161−async function loadSourceSchemasForTools(
162− ctx: ActionCtx,
163− tools: ToolDescriptor[],
164− dtsStorageIds: DtsStorageEntry[],
165−): Promise<Record<string, Record<string, string>>> {
166− const refsBySource = collectSchemaKeysBySource(tools);
167− if (refsBySource.size === 0 || dtsStorageIds.length === 0) {
168− return {};
292+const GENERIC_NAMESPACE_SUFFIXES = new Set([
293+ "api",
294+ "apis",
295+ "openapi",
296+ "sdk",
297+ "service",
298+ "services",
299+]);
300+
301+function 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);
169309 }
170310
171− const storageBySource = new Map<string, Id<"_storage">>();
172− for (const entry of dtsStorageIds) {
173− storageBySource.set(entry.sourceKey, entry.storageId);
311+ while (collapsed.length > 1) {
312+ const last = collapsed[collapsed.length - 1];
313+ if (!last || !GENERIC_NAMESPACE_SUFFIXES.has(last)) break;
314+ collapsed.pop();
174315 }
175316
176− const entries = await Promise.all([...refsBySource.entries()].map(async ([source, keys]) => {
177− const storageId = storageBySource.get(source);
178− if (!storageId) {
179− return null;
180− }
317+ return collapsed.join("_");
318+}
181319
182− try {
183− const blob = await ctx.storage.get(storageId);
184− if (!blob) {
185− return null;
186− }
320+function preferredToolPath(path: string): string {
321+ const segments = path.split(".").filter(Boolean);
322+ if (segments.length === 0) return path;
187323
188− const dtsText = await blob.text();
189− const schemaTypes = extractSourceSchemaTypesFromDts(dtsText);
190− if (Object.keys(schemaTypes).length === 0) {
191− return null;
192− }
324+ const simplifiedNamespace = simplifyNamespaceSegment(segments[0]!);
325+ if (!simplifiedNamespace || simplifiedNamespace === segments[0]) {
326+ return path;
327+ }
193328
194− const sourceSchemas: Record<string, string> = {};
195− for (const key of keys) {
196− const schemaType = schemaTypes[key];
197− if (!schemaType) continue;
198− sourceSchemas[formatSchemaRefToken(key)] = schemaType;
199− }
329+ return [simplifiedNamespace, ...segments.slice(1)].join(".");
330+}
200331
201− if (Object.keys(sourceSchemas).length === 0) {
202− return null;
203− }
332+function toCamelSegment(segment: string): string {
333+ return segment.replace(/_+([a-z0-9])/g, (_m, char: string) => char.toUpperCase());
334+}
204335
205− return [source, sourceSchemas] as const;
206− } catch {
207− return null;
208− }
209− }));
336+function getPathAliases(path: string): string[] {
337+ const segments = path.split(".").filter(Boolean);
338+ if (segments.length === 0) return [];
210339
211− const sourceSchemas: Record<string, Record<string, string>> = {};
212− for (const entry of entries) {
213− if (!entry) continue;
214− const [source, schemas] = entry;
215− sourceSchemas[source] = schemas;
216− }
340+ const canonicalPath = path;
341+ const publicPath = preferredToolPath(path);
217342
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);
219355 }
220356
221−function mergeToolsWithCatalog(externalTools: Iterable<ToolDefinition>): Map<string, ToolDefinition> {
222− const merged = new Map<string, ToolDefinition>();
223357
224− for (const tool of baseTools.values()) {
225− if (tool.path === "discover") continue;
226− merged.set(tool.path, tool);
358+function normalizeHint(type?: string): string {
359+ return type && type.trim().length > 0 ? type : "unknown";
360+}
361+
362+async 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);
227450 }
228451
229− for (const tool of externalTools) {
230− if (tool.path === "discover") continue;
231− merged.set(tool.path, tool);
452+ const namespaces = [...namespaceMap.entries()]
453+ .map(([namespace, meta]) => ({
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+ });
232467 }
233468
234− const catalogTools = createCatalogTools([...merged.values()]);
235− for (const tool of catalogTools) {
236− merged.set(tool.path, tool);
469+ const NS_BATCH = 100;
470+ for (let i = 0; i < namespaces.length; i += NS_BATCH) {
471+ await ctx.runMutation(internal.toolRegistry.putNamespacesBatch, {
472+ workspaceId: args.workspaceId,
473+ buildId,
474+ namespaces: namespaces.slice(i, i + NS_BATCH),
475+ });
237476 }
238477
239− const discover = createDiscoverTool([...merged.values()]);
240− merged.set(discover.path, discover);
241− return merged;
478+ await ctx.runMutation(internal.toolRegistry.finishBuild, {
479+ workspaceId: args.workspaceId,
480+ buildId,
481+ });
482+
483+ return { buildId };
242484 }
243485
486+// No implicit "ensure"/backfill on reads: the registry is built alongside the
487+// workspace tool snapshot during rebuilds.
488+
244489 export async function getWorkspaceTools(
245490 ctx: ActionCtx,
246491 workspaceId: Id<"workspaces">,
@@ -253,7 +498,7 @@ export async function getWorkspaceTools( · 6 unmodified lines
253498 };
254499
255500 const listSourcesStartedAt = Date.now();
256− const includeDts = options.includeDts ?? false;
501+ const includeDts = true;
257502 const sourceTimeoutMs = options.sourceTimeoutMs;
258503 const allowStaleOnMismatch = options.allowStaleOnMismatch ?? false;
259504 const actorId = options.actorId;
@@ -271,6 +516,7 @@ export async function getWorkspaceTools( · 11 unmodified lines
271516 traceStep("listToolSources", listSourcesStartedAt);
272517 const hasOpenApiSource = sources.some((source: { type: string }) => source.type === "openapi");
273518 const signature = sourceSignature(workspaceId, sources);
519+ const registrySignature = `toolreg_v2|${signature}`;
274520 const debugBase: Omit<WorkspaceToolsDebug, "mode" | "normalizedSourceCount" | "cacheHit" | "cacheFresh" | "timedOutSources" | "durationMs" | "trace"> = {
275521 includeDts,
276522 sourceTimeoutMs: sourceTimeoutMs ?? null,
@@ -293,19 +539,16 @@ export async function getWorkspaceTools( · 16 unmodified lines
293539 if (blob) {
294540 const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot;
295541 const restored = materializeWorkspaceSnapshot(snapshot);
296− const merged = mergeToolsWithCatalog(restored);
542+ const merged = mergeTools(restored);
297543 traceStep("cacheHydrate", cacheHydrateStartedAt);
298544
299− const dtsStorageIds = (cacheEntry.dtsStorageIds ?? []) as DtsStorageEntry[];
545+ const typesStorageId = cacheEntry.typesStorageId as Id<"_storage"> | undefined;
300546 if (cacheEntry.isFresh) {
301− const hasCachedDts = dtsStorageIds.length > 0;
302− if (includeDts && hasOpenApiSource && !hasCachedDts) {
303− // Continue into rebuild path to generate missing DTS.
304− } else {
547+ if (typesStorageId) {
305548 return {
306549 tools: merged,
307550 warnings: snapshot.warnings,
308− dtsStorageIds,
551+ typesStorageId,
309552 debug: {
310553 ...debugBase,
311554 mode: "cache-fresh",
@@ -318,11 +561,12 @@ export async function getWorkspaceTools( · 6 unmodified lines
318561 },
319562 };
320563 }
321− } else if (allowStaleOnMismatch && !includeDts) {
564+ // Continue into rebuild path to generate missing type bundle.
565+ } else if (allowStaleOnMismatch) {
322566 return {
323567 tools: merged,
324568 warnings: [...snapshot.warnings, "Tool sources changed; showing previous results while refreshing."],
325− dtsStorageIds,
569+ typesStorageId,
326570 debug: {
327571 ...debugBase,
328572 mode: "cache-stale",
@@ -374,6 +618,7 @@ export async function getWorkspaceTools( · 45 unmodified lines
374618 warnings: string[];
375619 timedOut: boolean;
376620 sourceName: string;
621+ openApiDts?: string;
377622 }>((resolve) => {
378623 timer = setTimeout(() => {
379624 resolve({
@@ -381,6 +626,7 @@ export async function getWorkspaceTools( · 1 unmodified line
381626 warnings: [`Source '${config.name}' is still loading; showing partial results.`],
382627 timedOut: true,
383628 sourceName: config.name,
629+ openApiDts: undefined,
384630 });
385631 }, sourceTimeoutMs);
386632 });
@@ -404,15 +650,15 @@ export async function getWorkspaceTools( · 17 unmodified lines
404650 const timedOutSources = loadedSources
405651 .filter((loaded) => loaded.timedOut)
406652 .map((loaded) => loaded.sourceName);
407− const merged = mergeToolsWithCatalog(externalTools);
653+ const merged = mergeTools(externalTools);
408654
409− let dtsStorageIds: DtsStorageEntry[] = [];
655+ let typesStorageId: Id<"_storage"> | undefined;
410656 try {
411657 if (hasTimedOutSource) {
412658 return {
413659 tools: merged,
414660 warnings,
415− dtsStorageIds,
661+ typesStorageId,
416662 debug: {
417663 ...debugBase,
418664 mode: "rebuild",
@@ -429,39 +675,32 @@ export async function getWorkspaceTools( · 10 unmodified lines
429675 const snapshotWriteStartedAt = Date.now();
430676 const allTools = [...merged.values()];
431677
432− const seenDtsSources = new Set<string>();
433− const dtsEntries: { sourceKey: string; content: string }[] = [];
434− for (const artifact of externalArtifacts) {
435− for (const tool of artifact.tools) {
436− if (tool.metadata?.sourceDts && tool.source && !seenDtsSources.has(tool.source)) {
437− seenDtsSources.add(tool.source);
438− dtsEntries.push({ sourceKey: tool.source, content: tool.metadata.sourceDts });
439− }
678+ // Build a per-tool registry for fast discover + invocation.
679+ const registryStartedAt = Date.now();
680+ await buildWorkspaceToolRegistry(ctx, {
681+ workspaceId,
682+ registrySignature,
683+ serializedTools: externalArtifacts.flatMap((artifact) => artifact.tools),
684+ });
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;
440692 }
441693 }
442−
443− const storedDts = await Promise.all(
444− dtsEntries.map(async (entry) => {
445− const dtsBlob = new Blob([entry.content], { type: "text/plain" });
446− const sid = await ctx.storage.store(dtsBlob);
447− return { sourceKey: entry.sourceKey, storageId: sid };
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− }));
694+ const typeBundle = buildWorkspaceTypeBundle({
695+ tools: allTools,
696+ openApiDtsBySource,
697+ });
698+ const typesBlob = new Blob([typeBundle], { type: "text/plain" });
699+ typesStorageId = await ctx.storage.store(typesBlob);
461700
462701 const snapshot: WorkspaceToolSnapshot = {
463702 version: "v2",
464− externalArtifacts: sanitizedArtifacts,
703+ externalArtifacts,
465704 warnings,
466705 };
467706
@@ -473,7 +712,7 @@ export async function getWorkspaceTools( · 5 unmodified lines
473712 workspaceId,
474713 signature,
475714 storageId,
476− dtsStorageIds: storedDts.map((e) => ({ sourceKey: e.sourceKey, storageId: e.storageId })),
715+ typesStorageId,
477716 toolCount: allTools.length,
478717 sizeBytes: json.length,
479718 });
@@ -489,7 +728,7 @@ export async function getWorkspaceTools( · 9 unmodified lines
489728 return {
490729 tools: merged,
491730 warnings,
492− dtsStorageIds,
731+ typesStorageId,
493732 debug: {
494733 ...debugBase,
495734 mode: "rebuild",
@@ -507,26 +746,21 @@ async function loadWorkspaceToolInventoryForContext( · 11 unmodified lines
507746 ctx: ActionCtx,
508747 context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },
509748 options: {
510− includeDts?: boolean;
511749 includeDetails?: boolean;
512750 includeSourceMeta?: boolean;
513− includeSchemaRegistry?: boolean;
514751 toolPaths?: string[];
515752 sourceTimeoutMs?: number;
516753 allowStaleOnMismatch?: boolean;
517754 skipCacheRead?: boolean;
518755 } = {},
519756 ): Promise<WorkspaceToolInventory> {
520− const includeDts = options.includeDts ?? false;
521757 const includeDetails = options.includeDetails ?? true;
522758 const includeSourceMeta = options.includeSourceMeta ?? true;
523− const includeSchemaRegistry = options.includeSchemaRegistry ?? false;
524759 const sourceTimeoutMs = options.sourceTimeoutMs;
525760 const allowStaleOnMismatch = options.allowStaleOnMismatch;
526761 const skipCacheRead = options.skipCacheRead;
527762 const [result, policies] = await Promise.all([
528763 getWorkspaceTools(ctx, context.workspaceId, {
529− includeDts,
530764 sourceTimeoutMs,
531765 allowStaleOnMismatch,
532766 skipCacheRead,
@@ -543,10 +777,8 @@ async function loadWorkspaceToolInventoryForContext( · 10 unmodified lines
543777 const descriptorsMs = Date.now() - descriptorsStartedAt;
544778 let sourceQuality: Record<string, OpenApiSourceQuality> = {};
545779 let sourceAuthProfiles: Record<string, SourceAuthProfile> = {};
546− let sourceSchemas: Record<string, Record<string, string>> = {};
547780 let qualityMs = 0;
548781 let authProfilesMs = 0;
549− let schemaRegistryMs = 0;
550782
551783 if (includeSourceMeta) {
552784 const qualityStartedAt = Date.now();
@@ -564,16 +796,15 @@ async function loadWorkspaceToolInventoryForContext( · 11 unmodified lines
564796 ]
565797 : ["sourceMeta=skipped"];
566798
567− if (includeSchemaRegistry) {
568− const schemaRegistryStartedAt = Date.now();
569− sourceSchemas = await loadSourceSchemasForTools(ctx, tools, result.dtsStorageIds);
570− schemaRegistryMs = Date.now() - schemaRegistryStartedAt;
799+ let typesUrl: string | undefined;
800+ if (result.typesStorageId) {
801+ try {
802+ typesUrl = await ctx.storage.getUrl(result.typesStorageId) ?? undefined;
803+ } catch {
804+ typesUrl = undefined;
805+ }
571806 }
572807
573− const schemaRegistryTrace = includeSchemaRegistry
574− ? [`buildSourceSchemas=${schemaRegistryMs}ms`]
575− : ["sourceSchemas=skipped"];
576−
577808 const { tools: boundedTools, warnings: boundedWarnings } = truncateToolsForActionResult(
578809 tools,
579810 result.warnings,
@@ -582,17 +813,15 @@ async function loadWorkspaceToolInventoryForContext( · 2 unmodified lines
582813 return {
583814 tools: boundedTools,
584815 warnings: boundedWarnings,
585− dtsStorageIds: result.dtsStorageIds,
816+ typesUrl,
586817 sourceQuality,
587818 sourceAuthProfiles,
588− sourceSchemas,
589819 debug: {
590820 ...result.debug,
591821 trace: [
592822 ...result.debug.trace,
593823 `listVisibleToolDescriptors=${descriptorsMs}ms`,
594824 ...sourceMetaTrace,
595− ...schemaRegistryTrace,
596825 ],
597826 },
598827 };
@@ -602,10 +831,8 @@ export async function listToolsForContext( · 3 unmodified lines
602831 ctx: ActionCtx,
603832 context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },
604833 options: {
605− includeDts?: boolean;
606834 includeDetails?: boolean;
607835 includeSourceMeta?: boolean;
608− includeSchemaRegistry?: boolean;
609836 toolPaths?: string[];
610837 sourceTimeoutMs?: number;
611838 allowStaleOnMismatch?: boolean;
@@ -620,10 +847,8 @@ export async function listToolsWithWarningsForContext( · 8 unmodified lines
620847 ctx: ActionCtx,
621848 context: { workspaceId: Id<"workspaces">; actorId?: string; clientId?: string },
622849 options: {
623− includeDts?: boolean;
624850 includeDetails?: boolean;
625851 includeSourceMeta?: boolean;
626− includeSchemaRegistry?: boolean;
627852 toolPaths?: string[];
628853 sourceTimeoutMs?: number;
629854 allowStaleOnMismatch?: boolean;
@@ -632,74 +857,20 @@ export async function listToolsWithWarningsForContext( · 2 unmodified lines
632857 ): Promise<{
633858 tools: ToolDescriptor[];
634859 warnings: string[];
635− dtsUrls: Record<string, string>;
860+ typesUrl?: string;
636861 sourceQuality: Record<string, OpenApiSourceQuality>;
637862 sourceAuthProfiles: Record<string, SourceAuthProfile>;
638− sourceSchemas: Record<string, Record<string, string>>;
639863 debug: WorkspaceToolsDebug;
640864 }> {
641865 const inventory = await loadWorkspaceToolInventoryForContext(ctx, context, options);
642− const dtsUrls = await loadDtsUrls(ctx, inventory.dtsStorageIds);
643−
644866 return {
645867 tools: inventory.tools,
646868 warnings: inventory.warnings,
647− dtsUrls,
869+ typesUrl: inventory.typesUrl,
648870 sourceQuality: inventory.sourceQuality,
649871 sourceAuthProfiles: inventory.sourceAuthProfiles,
650− sourceSchemas: inventory.sourceSchemas,
651872 debug: inventory.debug,
652873 };
653874 }
654875
655−export 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−
677−export 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−
705876 export { baseTools };
executor/packages/convex/schema.ts+182−3
@@ -1,6 +1,20 @@
11 import { defineSchema, defineTable } from "convex/server";
22 import { v } from "convex/values";
33
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+
418 const accountProvider = v.union(v.literal("workos"), v.literal("anonymous"));
519 const accountStatus = v.union(v.literal("active"), v.literal("deleted"));
620 const organizationStatus = v.union(v.literal("active"), v.literal("deleted"));
@@ -41,6 +55,7 @@ const toolCallStatus = v.union( · 34 unmodified lines
4155 v.literal("failed"),
4256 v.literal("denied"),
4357 );
58+const toolApprovalMode = v.union(v.literal("auto"), v.literal("required"));
4459 const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
4560 const credentialScope = v.union(v.literal("workspace"), v.literal("actor"));
4661 const credentialProvider = v.union(
@@ -50,6 +65,10 @@ const credentialProvider = v.union( · 3 unmodified lines
5065 const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
5166
5267 export default defineSchema({
68+ // User identities (WorkOS-backed or anonymous).
69+ //
70+ // Primary access patterns:
71+ // - Lookup by provider + providerAccountId (WorkOS user id / anon id).
5372 accounts: defineTable({
5473 provider: accountProvider,
5574 providerAccountId: v.string(), // WorkOS user ID or anon_* UUID
@@ -65,6 +84,12 @@ export default defineSchema({ · 9 unmodified lines
6584 })
6685 .index("by_provider", ["provider", "providerAccountId"]),
6786
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.
6893 workspaces: defineTable({
6994 workosOrgId: v.optional(v.string()), // external WorkOS org ID
7095 organizationId: v.id("organizations"),
@@ -80,6 +105,12 @@ export default defineSchema({ · 9 unmodified lines
80105 .index("by_organization_slug", ["organizationId", "slug"])
81106 .index("by_slug", ["slug"]),
82107
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.
83114 organizations: defineTable({
84115 workosOrgId: v.optional(v.string()), // external WorkOS org ID
85116 slug: v.string(),
@@ -93,6 +124,13 @@ export default defineSchema({ · 7 unmodified lines
93124 .index("by_slug", ["slug"])
94125 .index("by_status_created", ["status", "createdAt"]),
95126
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).
96134 organizationMembers: defineTable({
97135 organizationId: v.id("organizations"),
98136 accountId: v.id("accounts"),
@@ -110,6 +148,12 @@ export default defineSchema({ · 11 unmodified lines
110148 .index("by_account", ["accountId"])
111149 .index("by_org_billable_status", ["organizationId", "billable", "status"]),
112150
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.
113157 workspaceMembers: defineTable({
114158 workspaceId: v.id("workspaces"),
115159 accountId: v.id("accounts"),
@@ -124,6 +168,12 @@ export default defineSchema({ · 8 unmodified lines
124168 .index("by_account", ["accountId"])
125169 .index("by_workos_membership_id", ["workosOrgMembershipId"]),
126170
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.
127177 invites: defineTable({
128178 organizationId: v.id("organizations"),
129179 workspaceId: v.optional(v.id("workspaces")),
@@ -140,6 +190,11 @@ export default defineSchema({ · 10 unmodified lines
140190 .index("by_org", ["organizationId"])
141191 .index("by_org_email_status", ["organizationId", "email", "status"]),
142192
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.
143198 billingCustomers: defineTable({
144199 organizationId: v.id("organizations"),
145200 stripeCustomerId: v.string(), // external Stripe customer ID
@@ -149,6 +204,11 @@ export default defineSchema({ · 3 unmodified lines
149204 .index("by_org", ["organizationId"])
150205 .index("by_stripe_customer_id", ["stripeCustomerId"]),
151206
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.
152212 billingSubscriptions: defineTable({
153213 organizationId: v.id("organizations"),
154214 stripeSubscriptionId: v.string(), // external Stripe subscription ID
@@ -165,6 +225,8 @@ export default defineSchema({ · 10 unmodified lines
165225 .index("by_org_status", ["organizationId", "status"])
166226 .index("by_stripe_subscription_id", ["stripeSubscriptionId"]),
167227
228+ // Seat syncing bookkeeping (eg Stripe per-seat quantity).
229+ // Stored separately from subscription records so sync logic can be retried/idempotent.
168230 billingSeatState: defineTable({
169231 organizationId: v.id("organizations"),
170232 desiredSeats: v.number(),
@@ -175,6 +237,13 @@ export default defineSchema({ · 4 unmodified lines
175237 updatedAt: v.number(),
176238 }).index("by_org", ["organizationId"]),
177239
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.
178247 tasks: defineTable({
179248 taskId: v.string(), // domain ID: task_<uuid>
180249 code: v.string(),
@@ -198,6 +267,12 @@ export default defineSchema({ · 17 unmodified lines
198267 .index("by_workspace_created", ["workspaceId", "createdAt"])
199268 .index("by_status_created", ["status", "createdAt"]),
200269
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.
201276 approvals: defineTable({
202277 approvalId: v.string(), // domain ID: approval_<uuid>
203278 taskId: v.string(), // references tasks.taskId (not tasks._id)
@@ -214,6 +289,12 @@ export default defineSchema({ · 10 unmodified lines
214289 .index("by_workspace_created", ["workspaceId", "createdAt"])
215290 .index("by_workspace_status_created", ["workspaceId", "status", "createdAt"]),
216291
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.
217298 toolCalls: defineTable({
218299 taskId: v.string(),
219300 callId: v.string(),
@@ -231,6 +312,8 @@ export default defineSchema({ · 11 unmodified lines
231312 .index("by_workspace_created", ["workspaceId", "createdAt"])
232313 .index("by_approval_id", ["approvalId"]),
233314
315+ // Append-only event log for a task.
316+ // `sequence` is monotonically increasing per task (used for ordered replay).
234317 taskEvents: defineTable({
235318 sequence: v.number(),
236319 taskId: v.string(), // references tasks.taskId (not tasks._id)
@@ -241,6 +324,9 @@ export default defineSchema({ · 4 unmodified lines
241324 })
242325 .index("by_task_sequence", ["taskId", "sequence"]),
243326
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.
244330 accessPolicies: defineTable({
245331 policyId: v.string(), // domain ID: policy_<uuid>
246332 workspaceId: v.id("workspaces"),
@@ -255,6 +341,15 @@ export default defineSchema({ · 8 unmodified lines
255341 .index("by_policy_id", ["policyId"])
256342 .index("by_workspace_created", ["workspaceId", "createdAt"]),
257343
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.
258353 sourceCredentials: defineTable({
259354 bindingId: v.string(), // domain ID: bind_<uuid>
260355 credentialId: v.string(), // domain ID: conn_<uuid>
@@ -274,6 +369,14 @@ export default defineSchema({ · 13 unmodified lines
274369 .index("by_workspace_credential", ["workspaceId", "credentialId"])
275370 .index("by_binding_id", ["bindingId"]),
276371
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.
277380 toolSources: defineTable({
278381 sourceId: v.string(), // domain ID: src_<uuid>
279382 workspaceId: v.id("workspaces"),
@@ -290,6 +393,8 @@ export default defineSchema({ · 10 unmodified lines
290393 .index("by_workspace_updated", ["workspaceId", "updatedAt"])
291394 .index("by_workspace_name", ["workspaceId", "name"]),
292395
396+ // Cached OpenAPI spec blobs stored in Convex storage.
397+ // (specUrl, version) uniquely identifies a stored spec payload.
293398 openApiSpecCache: defineTable({
294399 specUrl: v.string(),
295400 storageId: v.id("_storage"),
@@ -299,21 +404,95 @@ export default defineSchema({ · 3 unmodified lines
299404 })
300405 .index("by_spec_url_version", ["specUrl", "version"]),
301406
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.
302410 workspaceToolCache: defineTable({
303411 workspaceId: v.id("workspaces"),
304412 signature: v.string(),
305413 storageId: v.id("_storage"),
306− /** Per-source .d.ts blobs stored separately (too large for action responses). */
307− dtsStorageIds: v.array(v.object({
414+ /** Legacy per-source OpenAPI .d.ts blobs. No longer used; retained for safe schema upgrades. */
415+ dtsStorageIds: v.optional(v.array(v.object({
308416 sourceKey: v.string(),
309417 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")),
311422 toolCount: v.number(),
312423 sizeBytes: v.number(),
313424 createdAt: v.number(),
314425 })
315426 .index("by_workspace", ["workspaceId"]),
316427
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.
317496 anonymousSessions: defineTable({
318497 sessionId: v.string(), // domain ID: anon_session_<uuid> or mcp_<uuid>
319498 workspaceId: v.id("workspaces"),
executor/packages/convex/test-fixtures/executorNode.oomFixture.ts+13−3
@@ -8,9 +8,19 @@ const TOOL = { · 7 unmodified lines
88 description: "Get the authenticated GitHub user",
99 approval: "auto" as const,
1010 source: "openapi:github",
11− argsType: "{}",
12− returnsType: "{ login: string; name: string | null; email: string | null }",
13− operationId: "users/get-authenticated",
11+ typing: {
12+ typedRef: {
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+ },
1424 };
1525
1626 export const listToolsInternal = internalAction({
executor/packages/convex/toolRegistry.ts+331−0
@@ -0,0 +1,331 @@
1+import { v } from "convex/values";
2+import { internalMutation, internalQuery } from "./_generated/server";
3+
4+export 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+
25+export 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+
58+export 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+
115+export 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+
142+export 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+
179+export 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+
212+export 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+
246+export 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+
274+export 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+
310+export 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
33 import { mutation, query } from "./_generated/server";
44 import { workspaceMutation, workspaceQuery } from "../core/src/function-builders";
55 import { isAnonymousIdentity } from "./auth/anonymous";
6+import { safeRunAfter } from "./lib/scheduler";
67
78 const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
89 const 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
1718 }
1819
1920 export const bootstrapAnonymousSession = mutation({
20− args: { sessionId: v.optional(v.string()) },
21− handler: async (ctx) => {
22− const identity = await ctx.auth.getUserIdentity();
23− if (!identity) {
24− throw new Error("Anonymous auth token is required");
25− }
21+ args: {
22+ sessionId: v.optional(v.string()),
23+ actorId: v.optional(v.string()),
24+ },
25+ handler: async (ctx, args) => {
26+ const identity = await ctx.auth.getUserIdentity().catch(() => null);
27+ const requestedSessionId = args.sessionId?.trim() || undefined;
28+ const requestedActorId = args.actorId?.trim() || undefined;
2629
27− if (!isAnonymousIdentity(identity)) {
28− throw new Error("Anonymous auth token is required");
30+ if (identity && !isAnonymousIdentity(identity)) {
31+ throw new Error("Cannot bootstrap an anonymous session while authenticated");
2932 }
3033
31− const actorId = identity.subject;
32− if (!actorId.startsWith("anon_")) {
33− throw new Error("Anonymous token subject must use anon_* actor ids");
34+ const actorId = identity ? identity.subject : requestedActorId;
35+ if (actorId && !actorId.startsWith("anon_")) {
36+ throw new Error("actorId must be an anonymous actor (anon_*)");
3437 }
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+
3645 return await ctx.runMutation(internal.database.bootstrapAnonymousSession, {
37− sessionId: stableSessionId,
38− actorId,
46+ ...(sessionId ? { sessionId } : {}),
47+ ...(actorId ? { actorId } : {}),
3948 clientId: "web",
4049 });
4150 },
@@ -192,7 +201,7 @@ export const upsertToolSource = workspaceMutation({ · 150 unmodified lines
192201 });
193202
194203 try {
195− await ctx.scheduler.runAfter(0, internal.executorNode.listToolsWithWarningsInternal, {
204+ await safeRunAfter(ctx.scheduler, 0, internal.executorNode.listToolsWithWarningsInternal, {
196205 workspaceId: ctx.workspaceId,
197206 });
198207 } catch {
@@ -222,7 +231,7 @@ export const deleteToolSource = workspaceMutation({ · 23 unmodified lines
222231 });
223232
224233 try {
225− await ctx.scheduler.runAfter(0, internal.executorNode.listToolsWithWarningsInternal, {
234+ await safeRunAfter(ctx.scheduler, 0, internal.executorNode.listToolsWithWarningsInternal, {
226235 workspaceId: ctx.workspaceId,
227236 });
228237 } catch {
executor/packages/convex/workspaceToolCache.ts+13−9
@@ -21,7 +21,7 @@ export const getEntry = internalQuery({ · 20 unmodified lines
2121 return {
2222 isFresh: entry.signature === args.signature,
2323 storageId: entry.storageId,
24− dtsStorageIds: entry.dtsStorageIds,
24+ typesStorageId: entry.typesStorageId,
2525 toolCount: entry.toolCount,
2626 sizeBytes: entry.sizeBytes,
2727 createdAt: entry.createdAt,
@@ -38,10 +38,7 @@ export const putEntry = internalMutation({ · 10 unmodified lines
3838 workspaceId: v.id("workspaces"),
3939 signature: v.string(),
4040 storageId: v.id("_storage"),
41− dtsStorageIds: v.array(v.object({
42− sourceKey: v.string(),
43− storageId: v.id("_storage"),
44− })),
41+ typesStorageId: v.optional(v.id("_storage")),
4542 toolCount: v.number(),
4643 sizeBytes: v.number(),
4744 },
@@ -54,9 +51,16 @@ export const putEntry = internalMutation({ · 6 unmodified lines
5451 if (existing) {
5552 // Delete old main snapshot blob
5653 await ctx.storage.delete(existing.storageId).catch(() => {});
57− // Delete old .d.ts blobs
58− for (const entry of existing.dtsStorageIds) {
59− await ctx.storage.delete(entry.storageId).catch(() => {});
54+ const legacy = (existing as any).dtsStorageIds as Array<{ storageId?: string }> | undefined;
55+ if (Array.isArray(legacy)) {
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(() => {});
6064 }
6165 await ctx.db.delete(existing._id);
6266 }
@@ -65,7 +69,7 @@ export const putEntry = internalMutation({ · 2 unmodified lines
6569 workspaceId: args.workspaceId,
6670 signature: args.signature,
6771 storageId: args.storageId,
68− dtsStorageIds: args.dtsStorageIds,
72+ typesStorageId: args.typesStorageId,
6973 toolCount: args.toolCount,
7074 sizeBytes: args.sizeBytes,
7175 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
3030 ` ${namespaces.join(", ")}`,
3131 "",
3232 "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).",
34− "Prefer `bestPath` when present, otherwise copy a `results[i].exampleCall` for invocation shape.",
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; each result includes `{ path, input, output }` to help you form a correct call.",
3535 "For migration/ETL tasks: discover once, then execute in small batches and return compact summaries (counts, IDs, top-N samples).",
3636 "Never shadow the global `tools` object (do NOT write `const tools = ...`).",
3737 "Then call tools directly using the returned path.",
executor/packages/core/src/dts-loader.test.ts+0−64
@@ -1,64 +0,0 @@
1−import { expect, test } from "bun:test";
2−import { loadSourceDtsByUrl } from "./dts-loader";
3−
4−test("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−
36−test("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 @@
1−async 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−
17−export 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
191191 ): Promise<McpServer> {
192192 const mcp = new McpServer(
193193 { name: "executor", version: "0.1.0" },
194− { capabilities: { tools: {} } },
194+ { capabilities: { tools: {}, elicitation: { form: {} } } as any },
195195 );
196196 const onApprovalPrompt = createMcpApprovalPrompt(mcp);
197− const registerTool = mcp.registerTool as (
197+ const registerTool = (mcp.registerTool as (
198198 name: string,
199199 config: { description: string; inputSchema: AnySchema },
200200 cb: ReturnType<typeof createRunCodeTool>,
201− ) => void;
201+ ) => void).bind(mcp);
202202
203203 registerTool(
204204 "run_code",
@@ -241,8 +241,7 @@ export async function handleMcpRequest( · 36 unmodified lines
241241 request: Request,
242242 context?: McpWorkspaceContext,
243243 ): Promise<Response> {
244− const headers = new Headers(request.headers);
245− headers.delete("mcp-session-id");
246− const requestWithoutSession = new Request(request, { headers });
247− return await handleStatelessMcpRequest(service, requestWithoutSession, context);
244+ // Preserve MCP session headers so the SDK can negotiate capabilities
245+ // (elicitation/sampling) across requests.
246+ return await handleStatelessMcpRequest(service, request, context);
248247 }
executor/packages/core/src/mcp/server-approval.ts+26−1
@@ -111,6 +111,7 @@ export function waitForTerminalTask( · 110 unmodified lines
111111 let loggedElicitationFallback = false;
112112 const seenApprovalIds = new Set<string>();
113113 let unsubscribe: (() => void) | undefined;
114+ let interval: ReturnType<typeof setInterval> | undefined;
114115
115116 const logElicitationFallback = (reason: string) => {
116117 if (loggedElicitationFallback) return;
@@ -122,12 +123,36 @@ export function waitForTerminalTask( · 5 unmodified lines
122123 if (settled) return;
123124 settled = true;
124125 clearTimeout(timeout);
126+ if (interval) {
127+ clearInterval(interval);
128+ }
125129 unsubscribe?.();
126130 resolve(await service.getTask(taskId, workspaceId));
127131 };
128132
129133 const timeout = setTimeout(done, waitTimeoutMs);
130134
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+
131156 const maybeHandleApprovals = async () => {
132157 if (!elicitationEnabled || !service.listPendingApprovals || !service.resolveApproval || !onApprovalPrompt || !approvalContext) {
133158 return;
@@ -184,7 +209,7 @@ export function waitForTerminalTask( · 50 unmodified lines
184209 }
185210 });
186211
187− void maybeHandleApprovals().catch(() => {});
212+ void poll().catch(() => {});
188213
189214 void service.getTask(taskId, workspaceId).then((task) => {
190215 if (task && getTaskTerminalState(task.status)) {
executor/packages/core/src/openapi-cache.test.ts+0−1335
@@ -1,1335 +0,0 @@
1−import { expect, test, describe } from "bun:test";
2−import {
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− */
15−function 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−
145−describe("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−
226−describe("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−
1201−describe("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−
1262−describe("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
103103 let requestBodySchema: Record<string, unknown> = {};
104104 let responseSchema: Record<string, unknown> = {};
105105 let responseStatus = "";
106+ // Always attempt to compute minimal input/output schemas. This keeps the
107+ // prepared spec compact while enabling schema-first tool signatures.
106108 if (includeSchemas || hasGeneratedTypes) {
107109 const requestBody = resolveRequestBodyRef(asRecord(operation.requestBody), compRequestBodies);
108110 const requestBodyContent = asRecord(requestBody.content);
@@ -122,43 +124,32 @@ export function compactOpenApiPaths( · 13 unmodified lines
122124 }
123125 }
124126
125− if (hasGeneratedTypes) {
126− const mergedParameters = normalizeParameters(operation.parameters).concat(sharedParameters);
127− const hasInputSchema =
128− mergedParameters.length > 0 || Object.keys(requestBodySchema).length > 0;
129− const combinedSchema = buildOpenApiInputSchema(mergedParameters, requestBodySchema);
130− compactOperation._argsTypeHint = hasInputSchema
131− ? jsonSchemaTypeHintFallback(combinedSchema, 0, compSchemas)
132− : "{}";
133− compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas);
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− };
127+ const mergedParameters = normalizeParameters(operation.parameters).concat(sharedParameters);
128+ const hasInputSchema = mergedParameters.length > 0 || Object.keys(requestBodySchema).length > 0;
129+ if (hasInputSchema) {
130+ compactOperation._inputSchema = buildOpenApiInputSchema(mergedParameters, requestBodySchema);
131+ }
132+ if (Object.keys(responseSchema).length > 0 || responseStatus) {
133+ compactOperation._outputSchema = responseSchema;
134+ if (responseStatus) {
135+ compactOperation._successStatus = responseStatus;
147136 }
137+ }
148138
149− if (responseStatus) {
150− compactOperation.responses = {
151− [responseStatus]: Object.keys(responseSchema).length > 0
152− ? {
153− content: {
154− "application/json": {
155− schema: responseSchema,
156− },
157− },
158− }
159− : {},
160− };
139+ const previewKeys = buildOpenApiArgPreviewKeys(mergedParameters, requestBodySchema, compSchemas);
140+ if (previewKeys.length > 0) {
141+ compactOperation._previewInputKeys = [...new Set(previewKeys)];
142+ }
143+
144+ if (hasGeneratedTypes) {
145+ // Keep a low-cost type hint string for optional UI usage.
146+ // NOTE: Not required for the schema-first agent signature.
147+ if (compactOperation._inputSchema) {
148+ compactOperation._argsTypeHint = jsonSchemaTypeHintFallback(compactOperation._inputSchema, 0, compSchemas);
149+ } else {
150+ compactOperation._argsTypeHint = "{}";
161151 }
152+ compactOperation._returnsTypeHint = responseTypeHintFromSchema(responseSchema, responseStatus, compSchemas);
162153 }
163154
164155 compactPathObject[method] = compactOperation;
executor/packages/core/src/openapi-typing.test.ts+130−0
@@ -0,0 +1,130 @@
1+import { describe, expect, test } from "bun:test";
2+import { buildOpenApiToolsFromPrepared, prepareOpenApiSpec } from "./tool-sources";
3+
4+function 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+
85+describe("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
128128
129129 expect(tools.length).toBeGreaterThan(0);
130130
131− // Spot-check: every tool has a path and type metadata
131+ // Spot-check: every tool has a path and schema-first typing
132132 for (const tool of tools) {
133133 expect(tool.path).toContain(`${fixture.name}.`);
134134 expect(typeof tool.description).toBe("string");
135− expect(tool.metadata).toBeDefined();
136− expect(tool.metadata!.argsType).toBeDefined();
137− expect(tool.metadata!.returnsType).toBeDefined();
135+ expect(tool.typing).toBeDefined();
138136 }
139137
140− // If we have .d.ts, tools should carry operationId + sourceDts for typechecking
138+ // If we have .d.ts, tools should carry typed refs for high-fidelity typing
141139 if (fixture.expectDts) {
142− // At least some tools should have operationId set
143− const withOperationId = tools.filter(
144− (t) => t.metadata!.operationId != null,
140+ const withTypedRef = tools.filter(
141+ (t) => t.typing?.typedRef?.kind === "openapi_operation",
145142 );
146− expect(withOperationId.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);
143+ expect(withTypedRef.length).toBeGreaterThan(0);
167144 }
168145
169146 if (prepared.warnings.length > 0) {
@@ -193,15 +170,13 @@ describe("real-world OpenAPI specs", () => { · 23 unmodified lines
193170 );
194171
195172 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",
197174 );
198175
199176 expect(tool).toBeDefined();
200177 expect(tool!.path).toBe("github.activity.delete_repo_subscription");
201− expect(tool!.metadata!.argsType).toContain("owner");
202− expect(tool!.metadata!.argsType).toContain("repo");
203− expect(tool!.metadata!.argsType).not.toBe("Record<string, unknown>");
204− expect(tool!.metadata!.returnsType).toBe("void");
178+ expect(tool!.typing?.requiredInputKeys ?? []).toContain("owner");
179+ expect(tool!.typing?.requiredInputKeys ?? []).toContain("repo");
205180 },
206181 300_000,
207182 );
@@ -224,16 +199,15 @@ describe("real-world OpenAPI specs", () => { · 16 unmodified lines
224199 );
225200
226201 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",
228203 );
229204
230205 expect(tool).toBeDefined();
231206 expect(tool!.path).toBe("github.actions.add_custom_labels_to_self_hosted_runner_for_repo");
232− expect(tool!.metadata!.displayArgsType).toContain("owner: string");
233− expect(tool!.metadata!.displayArgsType).toContain("repo: string");
234− expect(tool!.metadata!.displayArgsType).toContain("runner_id: number");
235− expect(tool!.metadata!.displayArgsType).toContain("labels: string[]");
236− expect(tool!.metadata!.displayArgsType).not.toContain("owner: ...");
207+ expect(tool!.typing?.requiredInputKeys ?? []).toContain("owner");
208+ expect(tool!.typing?.requiredInputKeys ?? []).toContain("repo");
209+ expect(tool!.typing?.requiredInputKeys ?? []).toContain("runner_id");
210+ expect(tool!.typing?.requiredInputKeys ?? []).toContain("labels");
237211 },
238212 300_000,
239213 );
@@ -256,137 +230,24 @@ describe("real-world OpenAPI specs", () => { · 16 unmodified lines
256230 );
257231
258232 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",
260234 );
261235
262236 expect(tool).toBeDefined();
263237 expect(tool!.path).toBe("github.actions.create_hosted_runner_for_org");
264− expect(tool!.metadata!.argsType).toContain("org: string");
265− expect(tool!.metadata!.returnsType).toContain("id");
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");
238+ expect(tool!.typing?.requiredInputKeys ?? []).toContain("org");
239+ expect(tool!.typing?.outputSchema).toBeDefined();
380240 },
381241 300_000,
382242 );
383243
384244 test(
385− "cloudflare inventory mode: access apps keeps typed args and non-unknown returns",
245+ "OpenAPI inventory mode still yields usable schemas",
386246 async () => {
387247 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();
388250
389− const prepared = await prepareOpenApiSpec(cloudflareUrl, "cloudflare", { includeDts: false });
390251 const tools = buildOpenApiToolsFromPrepared(
391252 {
392253 type: "openapi",
@@ -397,108 +258,11 @@ describe("real-world OpenAPI specs", () => { · 4 unmodified lines
397258 prepared,
398259 );
399260
400− const tool = tools.find(
401− (t) => t.metadata?.operationId === "access-applications-list-access-applications",
402− );
403−
404− expect(tool).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");
261+ expect(tools.length).toBeGreaterThan(0);
262+ const anyToolWithSchema = tools.find((t) => t.typing?.inputSchema && Object.keys(t.typing.inputSchema).length > 0);
263+ expect(anyToolWithSchema).toBeDefined();
264+ const anyToolWithTypedRef = tools.find((t) => t.typing?.typedRef);
265+ expect(anyToolWithTypedRef).toBeDefined();
502266 },
503267 300_000,
504268 );
executor/packages/core/src/openapi/schema-hints.test.ts+305−0
@@ -0,0 +1,305 @@
1+import { expect, test } from "bun:test";
2+import { jsonSchemaTypeHintFallback } from "./schema-hints";
3+
4+test("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+
28+test("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+
59+test("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+
81+test("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+
103+test("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+
129+test("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+
146+test("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+
163+test("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+
213+test("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+
274+test("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
33 type JsonSchema = Record<string, unknown>;
44 const COMPONENT_REF_INLINE_DEPTH = 2;
55
6+function 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+
618 export type OpenApiParameterHint = {
719 name: string;
820 required: boolean;
@@ -224,11 +236,454 @@ function joinUnion(parts: string[]): string { · 215 unmodified lines
224236 .join(" | ");
225237 }
226238
239+function 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+
245+function 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+
251+function 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+
276+function 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+
297+function 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+
311+function 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+
418+function 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+
426+function 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+
484+function 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+
498+function 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+
563+function 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+
227681 function joinIntersection(parts: string[]): string {
228682 const expanded = parts.flatMap((part) => splitTopLevelBy(part, "&"));
229683 const unique = dedupeTypeParts(expanded).filter((part) => part !== "unknown");
230684 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(" & ");
232687 }
233688
234689 function maybeParenthesizeArrayElement(typeHint: string): string {
@@ -252,19 +707,18 @@ export function jsonSchemaTypeHintFallback( · 17 unmodified lines
252707 const prefix = "#/components/schemas/";
253708 if (ref.startsWith(prefix)) {
254709 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− }
261710 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) {
263716 const nextSeen = new Set(seenRefs);
264717 nextSeen.add(ref);
265718 return jsonSchemaTypeHintFallback(resolved, depth + 1, componentSchemas, nextSeen);
266719 }
267720
721+ // Fall back to a stable named reference.
268722 return formatComponentSchemaRefType(key);
269723 }
270724 }
@@ -276,12 +730,88 @@ export function jsonSchemaTypeHintFallback( · 5 unmodified lines
276730
277731 const oneOf = Array.isArray(shape.oneOf) ? shape.oneOf : undefined;
278732 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)));
280772 }
281773
282774 const anyOf = Array.isArray(shape.anyOf) ? shape.anyOf : undefined;
283775 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)));
285815 }
286816
287817 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
99 import { buildOpenApiToolPath } from "./tool-path";
1010 import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
1111 import { executeOpenApiRequest } from "../tool/source-execution";
12−import { compactArgDisplayHint, compactReturnTypeHint } from "../type-hints";
1312 import type { OpenApiToolSourceConfig, PreparedOpenApiSpec } from "../tool/source-types";
1413 import type { ToolDefinition } from "../types";
14+import type { ToolTypedRef } from "../types";
15+import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
1516 import { asRecord } from "../utils";
1617 import type { SerializedTool } from "../tool/source-serialization";
1718
@@ -22,12 +23,6 @@ type OpenApiOperationParameter = { · 4 unmodified lines
2223 schema: Record<string, unknown>;
2324 };
2425
25−type OpenApiOperationTypeHints = {
26− argsType: string;
27− returnsType: string;
28− argPreviewKeys: string[];
29−};
30−
3126 function buildOpenApiOperationParameters(
3227 sharedParameters: Array<Record<string, unknown>>,
3328 operation: Record<string, unknown>,
@@ -45,51 +40,6 @@ function buildOpenApiOperationParameters( · 11 unmodified lines
4540 }));
4641 }
4742
48−function 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−
9343 export function buildOpenApiToolsFromPrepared(
9444 config: OpenApiToolSourceConfig,
9545 prepared: PreparedOpenApiSpec,
@@ -107,13 +57,6 @@ export function buildOpenApiToolsFromPrepared( · 11 unmodified lines
10757 const paths = asRecord(prepared.paths);
10858 const tools: ToolDefinition[] = [];
10959
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−
11760 const methods = ["get", "post", "put", "delete", "patch", "head", "options"] as const;
11861 const readMethods = new Set(["get", "head", "options"]);
11962 const usedToolPaths = new Set<string>();
@@ -133,10 +76,24 @@ export function buildOpenApiToolsFromPrepared( · 13 unmodified lines
13376 const operationIdRaw = String(operation.operationId ?? `${method}_${pathTemplate}`);
13477 const parameters = buildOpenApiOperationParameters(sharedParameters, operation);
13578
136− const { argsType, returnsType, argPreviewKeys } = buildOpenApiOperationTypeHints(operation, parameters);
137−
138− const displayArgsType = compactArgDisplayHint(argsType, argPreviewKeys);
139− const displayReturnsType = compactReturnTypeHint(returnsType);
79+ const inputSchema = asRecord(operation._inputSchema);
80+ const outputSchema = asRecord(operation._outputSchema);
81+ const inputHint = typeof operation._argsTypeHint === "string" && operation._argsTypeHint.trim().length > 0
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+ };
14097
14198 const approval = config.overrides?.[operationIdRaw]?.approval
14299 ?? (readMethods.has(method)
@@ -157,14 +114,14 @@ export function buildOpenApiToolsFromPrepared( · 14 unmodified lines
157114 source: sourceLabel,
158115 approval,
159116 description: String(operation.summary ?? operation.description ?? `${method.toUpperCase()} ${pathTemplate}`),
160− metadata: {
161− argsType,
162− returnsType,
163− displayArgsType,
164− displayReturnsType,
165− ...(argPreviewKeys.length > 0 ? { argPreviewKeys } : {}),
166− operationId: operationIdRaw,
167− ...(sourceDts && !sourceDtsEmitted ? { sourceDts } : {}),
117+ typing: {
118+ ...(Object.keys(inputSchema).length > 0 ? { inputSchema } : {}),
119+ ...(Object.keys(outputSchema).length > 0 ? { outputSchema } : {}),
120+ ...(inputHint ? { inputHint } : {}),
121+ ...(outputHint ? { outputHint } : {}),
122+ ...(requiredInputKeys.length > 0 ? { requiredInputKeys } : {}),
123+ ...(previewInputKeys.length > 0 ? { previewInputKeys } : {}),
124+ typedRef,
168125 },
169126 credential: credentialSpec,
170127 _runSpec: runSpec,
@@ -173,10 +130,6 @@ export function buildOpenApiToolsFromPrepared( · 2 unmodified lines
173130 },
174131 };
175132 tools.push(tool);
176−
177− if (sourceDts && !sourceDtsEmitted) {
178− sourceDtsEmitted = true;
179− }
180133 }
181134 }
182135
executor/packages/core/src/tool-discovery.test.ts+120−122
@@ -1,7 +1,6 @@
11 import { expect, test } from "bun:test";
22 import type { Id } from "../../convex/_generated/dataModel.d.ts";
33 import { createCatalogTools, createDiscoverTool } from "./tool-discovery";
4−import { extractSourceSchemaTypesFromDts } from "./tool-discovery/schema-registry";
54 import type { ToolDefinition } from "./types";
65
76 const TEST_WORKSPACE_ID = "w" as Id<"workspaces">;
@@ -13,9 +12,24 @@ test("discover returns aliases and example calls", async () => { · 5 unmodified lines
1312 description: "Add numbers",
1413 approval: "auto",
1514 source: "openapi:calc",
16− metadata: {
17− argsType: "{ a: number; b: number }",
18− returnsType: "{ sum: number }",
15+ typing: {
16+ inputSchema: {
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"],
1933 },
2034 run: async () => ({ sum: 0 }),
2135 } satisfies ToolDefinition,
@@ -32,7 +46,10 @@ test("discover returns aliases and example calls", async () => { · 10 unmodified lines
3246 exampleCall: string;
3347 signature: string;
3448 canonicalSignature: string;
35− expandedShape: { input: string; output: string };
49+ signatureInfo: {
50+ requiredKeys: string[];
51+ previewKeys: string[];
52+ };
3653 }>;
3754 total: number;
3855 };
@@ -43,10 +60,10 @@ test("discover returns aliases and example calls", async () => { · 4 unmodified lines
4360 expect(result.results[0]?.aliases).toContain("calc.math.addNumbers");
4461 expect(result.results[0]?.aliases).toContain("calc.math.addnumbers");
4562 expect(result.results[0]?.exampleCall).toBe("await tools.calc.math.add_numbers({ a: ..., b: ... });");
46− expect(result.results[0]?.signature).toContain("Promise<{ sum: number }>");
47− expect(result.results[0]?.canonicalSignature).toContain("Promise<{ sum: number }>");
48− expect(result.results[0]?.expandedShape.input).toContain("a:");
49− expect(result.results[0]?.expandedShape.output).toContain("sum");
63+ expect(result.results[0]?.signature).toContain("Promise<");
64+ expect(result.results[0]?.signature).toContain("sum");
65+ expect(result.results[0]?.canonicalSignature).toContain("sum");
66+ expect(result.results[0]?.signatureInfo.requiredKeys).toEqual(["a", "b"]);
5067 });
5168
5269 test("discover example call handles input-shaped args", async () => {
@@ -56,9 +73,23 @@ test("discover example call handles input-shaped args", async () => { · 3 unmodified lines
5673 description: "Create issue",
5774 approval: "required",
5875 source: "graphql:linear",
59− metadata: {
60− argsType: "{ input: { teamId: string; title: string } }",
61− returnsType: "{ data: { id: string }; errors: unknown[] }",
76+ typing: {
77+ inputSchema: {
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"],
6293 },
6394 run: async () => ({ data: { id: "x" }, errors: [] }),
6495 } satisfies ToolDefinition,
@@ -85,9 +116,28 @@ test("discover uses compact signatures by default and allows full mode", async ( · 20 unmodified lines
85116 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",
86117 approval: "auto",
87118 source: "graphql:linear",
88− metadata: {
89− argsType: "{ filter?: TeamFilter; before?: string; after?: string; first?: number; last?: number; includeArchived?: boolean; orderBy?: PaginationOrderBy }",
90− returnsType: "{ data: TeamConnection; errors: unknown[] }",
119+ typing: {
120+ inputSchema: {
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"],
91141 },
92142 run: async () => ({ data: {}, errors: [] }),
93143 } satisfies ToolDefinition,
@@ -102,7 +152,7 @@ test("discover uses compact signatures by default and allows full mode", async ( · 8 unmodified lines
102152 description: string;
103153 signature: string;
104154 canonicalSignature: string;
105− expandedShape: { input: string; output: string };
155+ signatureInfo: { previewKeys: string[] };
106156 }>;
107157 };
108158
@@ -115,94 +165,21 @@ test("discover uses compact signatures by default and allows full mode", async ( · 6 unmodified lines
115165 description: string;
116166 signature: string;
117167 canonicalSignature: string;
118− expandedShape: { input: string; output: string };
168+ signatureInfo: { previewKeys: string[] };
119169 }>;
120170 };
121171
122172 expect(compactResult.bestPath).toBe("linear.query.teams");
123173 expect(fullResult.bestPath).toBe("linear.query.teams");
124− expect(compactResult.results[0]?.signature).toContain("Promise<{ data: ...; errors: unknown[] }>");
125− expect(compactResult.results[0]?.canonicalSignature).toContain("TeamConnection");
126− expect(compactResult.results[0]?.expandedShape.input).toContain("filter");
174+ expect(compactResult.results[0]?.signature).toContain("Promise<");
175+ expect(compactResult.results[0]?.signature).toContain("errors");
176+ expect(compactResult.results[0]?.signatureInfo.previewKeys).toContain("filter");
127177 expect(compactResult.results[0]?.description).not.toContain("TRAILING_MARKER_TEXT");
128178
129− expect(fullResult.results[0]?.signature).toContain("TeamFilter");
130− expect(fullResult.results[0]?.signature).toContain("TeamConnection");
179+ expect(fullResult.results[0]?.signature).toContain("Promise<");
131180 expect(fullResult.results[0]?.description).toContain("TRAILING_MARKER_TEXT");
132181 });
133182
134−test("discover includes deduped schema registry for component refs", async () => {
135− const sourceDts = `
136−interface components {
137− schemas: {
138− "runner-label": { id?: number; name?: string };
139− runner: { labels?: components["schemas"]["runner-label"][] };
140− };
141−}
142−interface 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−
186−test("schema parser handles semicolons inside schema comments", () => {
187− const dts = `
188−interface 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−
206183 test("discover returns null bestPath when there are no matches", async () => {
207184 const tool = createDiscoverTool([
208185 {
@@ -210,9 +187,15 @@ test("discover returns null bestPath when there are no matches", async () => { · 1 unmodified line
210187 description: "Add numbers",
211188 approval: "auto",
212189 source: "openapi:calc",
213− metadata: {
214− argsType: "{ a: number; b: number }",
215− returnsType: "{ sum: number }",
190+ typing: {
191+ inputSchema: {
192+ type: "object",
193+ properties: {
194+ a: { type: "number" },
195+ b: { type: "number" },
196+ },
197+ required: ["a", "b"],
198+ },
216199 },
217200 run: async () => ({ sum: 0 }),
218201 } satisfies ToolDefinition,
@@ -235,9 +218,14 @@ test("discover bestPath prefers simpler exact intent operation", async () => { · 16 unmodified lines
235218 description: "Create issue-to-release join",
236219 approval: "required",
237220 source: "graphql:linear",
238− metadata: {
239− argsType: "{ input: { issueId: string; releaseId: string } }",
240− returnsType: "{ data: IssueToReleasePayload; errors: unknown[] }",
221+ typing: {
222+ inputSchema: {
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"],
241229 },
242230 run: async () => ({ data: {}, errors: [] }),
243231 } satisfies ToolDefinition,
@@ -246,9 +234,14 @@ test("discover bestPath prefers simpler exact intent operation", async () => { · 2 unmodified lines
246234 description: "Create issue",
247235 approval: "required",
248236 source: "graphql:linear",
249− metadata: {
250− argsType: "{ input: { teamId: string; title: string } }",
251− returnsType: "{ data: IssuePayload; errors: unknown[] }",
237+ typing: {
238+ inputSchema: {
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"],
252245 },
253246 run: async () => ({ data: {}, errors: [] }),
254247 } satisfies ToolDefinition,
@@ -270,9 +263,10 @@ test("discover namespace hint suppresses cross-namespace bestPath", async () => · 15 unmodified lines
270263 description: "List teams",
271264 approval: "auto",
272265 source: "openapi:github",
273− metadata: {
274− argsType: "{ org: string }",
275− returnsType: "Array<Team>",
266+ typing: {
267+ inputSchema: { type: "object", properties: { org: { type: "string" } }, required: ["org"] },
268+ requiredInputKeys: ["org"],
269+ previewInputKeys: ["org"],
276270 },
277271 run: async () => ([]),
278272 } satisfies ToolDefinition,
@@ -281,9 +275,9 @@ test("discover namespace hint suppresses cross-namespace bestPath", async () => · 2 unmodified lines
281275 description: "List teams in Linear",
282276 approval: "auto",
283277 source: "graphql:linear",
284− metadata: {
285− argsType: "{}",
286− returnsType: "{ data: TeamConnection; errors: unknown[] }",
278+ typing: {
279+ inputSchema: { type: "object", properties: {} },
280+ outputSchema: { type: "object", properties: { data: {}, errors: { type: "array", items: {} } }, required: ["data"] },
287281 },
288282 run: async () => ({ data: {}, errors: [] }),
289283 } satisfies ToolDefinition,
@@ -305,9 +299,11 @@ test("discover prefers simplified alias path for ugly namespaces", async () => { · 15 unmodified lines
305299 description: "Get Vercel domain",
306300 approval: "auto",
307301 source: "openapi:vercel",
308− metadata: {
309− argsType: "{ domain: string; teamId?: string }",
310− returnsType: "{ domain: string }",
302+ typing: {
303+ inputSchema: { type: "object", properties: { domain: { type: "string" }, teamId: { type: "string" } }, required: ["domain"] },
304+ outputSchema: { type: "object", properties: { domain: { type: "string" } }, required: ["domain"] },
305+ requiredInputKeys: ["domain"],
306+ previewInputKeys: ["domain", "teamId"],
311307 },
312308 run: async () => ({ domain: "executor.sh" }),
313309 } satisfies ToolDefinition,
@@ -334,9 +330,11 @@ test("catalog tools list namespaces and typed signatures", async () => { · 20 unmodified lines
334330 description: "Get domain",
335331 approval: "auto",
336332 source: "openapi:vercel",
337− metadata: {
338− argsType: "{ domain: string; teamId?: string }",
339− returnsType: "{ domain: string }",
333+ typing: {
334+ inputSchema: { type: "object", properties: { domain: { type: "string" }, teamId: { type: "string" } }, required: ["domain"] },
335+ outputSchema: { type: "object", properties: { domain: { type: "string" } }, required: ["domain"] },
336+ requiredInputKeys: ["domain"],
337+ previewInputKeys: ["domain", "teamId"],
340338 },
341339 run: async () => ({ domain: "executor.sh" }),
342340 } satisfies ToolDefinition,
@@ -345,9 +343,9 @@ test("catalog tools list namespaces and typed signatures", async () => { · 2 unmodified lines
345343 description: "Get time",
346344 approval: "auto",
347345 source: "local",
348− metadata: {
349− argsType: "{}",
350− returnsType: "{ iso: string; unix: number }",
346+ typing: {
347+ inputSchema: { type: "object", properties: {} },
348+ outputSchema: { type: "object", properties: { iso: { type: "string" }, unix: { type: "number" } }, required: ["iso", "unix"] },
351349 },
352350 run: async () => ({ iso: "", unix: 0 }),
353351 } satisfies ToolDefinition,
@@ -365,14 +363,14 @@ test("catalog tools list namespaces and typed signatures", async () => { · 11 unmodified lines
365363 { namespace: "vercel", compact: false, depth: 2, limit: 10 },
366364 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
367365 ) 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 } }>;
369367 total: number;
370368 };
371369
372370 expect(listed.total).toBe(1);
373371 expect(listed.results[0]?.path).toBe("vercel.domains.get_domain");
374372 expect(listed.results[0]?.aliases).toContain("vercel.domains.get_domain");
375− expect(listed.results[0]?.signature).toContain("domain: string");
376− expect(listed.results[0]?.argsType).toContain("domain: string");
377− expect(listed.results[0]?.returnsType).toContain("domain: string");
373+ expect(listed.results[0]?.signatureText).toContain("domain");
374+ expect(listed.results[0]?.signatureInfo.input).toContain("domain");
375+ expect(listed.results[0]?.signatureInfo.output).toContain("domain");
378376 });
executor/packages/core/src/tool-discovery.ts+55−28
@@ -4,12 +4,10 @@ import { · 3 unmodified lines
44 } from "./type-hints";
55 import {
66 buildExampleCall,
7− buildExpandedShape,
87 formatCanonicalSignature,
98 formatSignature,
109 } from "./tool-discovery/formatting";
1110 import { buildIndex, getTopLevelNamespace, listIndexForContext } from "./tool-discovery/indexing";
12−import { buildSchemaRegistryForEntries, buildSourceSchemaTypeMap } from "./tool-discovery/schema-registry";
1311 import { chooseBestPath, deriveIntentPhrase, extractNamespaceHints, scoreEntry } from "./tool-discovery/ranking";
1412
1513 export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] {
@@ -20,11 +18,9 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 4 unmodified lines
2018 source: "system",
2119 approval: "auto",
2220 description: "List available tool namespaces with counts and sample callable paths.",
23− metadata: {
24− argsType: "{}",
25− returnsType: "{ namespaces: Array<{ namespace: string; toolCount: number; samplePaths: string[] }>; total: number }",
26− displayArgsType: "{}",
27− displayReturnsType: "{ namespaces: ...; total: number }",
21+ typing: {
22+ inputSchema: { type: "object", properties: {} },
23+ outputSchema: { type: "object", properties: { namespaces: { type: "array" }, total: { type: "number" } }, required: ["namespaces", "total"] },
2824 },
2925 run: async (_input: unknown, context) => {
3026 const visible = listIndexForContext(index, context.isToolAllowed);
@@ -57,12 +53,19 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 26 unmodified lines
5753 source: "system",
5854 approval: "auto",
5955 description: "List tools with typed signatures. Supports namespace and query filters in one call.",
60− metadata: {
61− argsType: "{ namespace?: string; query?: string; depth?: number; limit?: number; compact?: boolean }",
62− returnsType:
63− "{ results: Array<{ path: string; aliases: string[]; source: string; approval: 'auto' | 'required'; description: string; argsType: string; returnsType: string; signature: string; exampleCall: string }>; total: number }",
64− displayArgsType: "{ namespace?: string; query?: string; depth?: number; limit?: number; compact?: boolean }",
65− displayReturnsType: "{ results: ...; total: number }",
56+ typing: {
57+ inputSchema: {
58+ type: "object",
59+ properties: {
60+ namespace: { type: "string" },
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"] },
6669 },
6770 run: async (input: unknown, context) => {
6871 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
@@ -71,6 +74,7 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 2 unmodified lines
7174 const depth = Math.max(0, Math.min(2, Number(payload.depth ?? 1)));
7275 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));
7376 const compact = payload.compact === false ? false : true;
77+ const includeSchemas = payload.includeSchemas === true;
7478 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];
7579
7680 const visible = listIndexForContext(index, context.isToolAllowed);
@@ -97,9 +101,15 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 20 unmodified lines
97101 source: entry.source,
98102 approval: entry.approval,
99103 description: compact ? compactDescriptionLine(entry.description) : entry.description,
100− argsType: compact ? entry.displayArgsType : entry.argsType,
101− returnsType: compact ? entry.displayReturnsType : entry.returnsType,
102− signature: formatSignature(entry, depth, compact),
104+ signatureInfo: {
105+ input: entry.displayInputHint,
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),
103113 exampleCall: buildExampleCall(entry),
104114 }));
105115
@@ -115,7 +125,6 @@ export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] { · 9 unmodified lines
115125
116126 export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition {
117127 const index = buildIndex(tools);
118− const sourceSchemaTypes = buildSourceSchemaTypeMap(tools);
119128
120129 return {
121130 path: "discover",
@@ -123,10 +132,27 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 1 unmodified line
123132 approval: "auto",
124133 description:
125134 "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: {
127− argsType: "{ query: string; depth?: number; limit?: number; compact?: boolean }",
128− returnsType:
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 }",
135+ typing: {
136+ inputSchema: {
137+ type: "object",
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+ },
130156 },
131157 run: async (input: unknown, context) => {
132158 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
@@ -134,6 +160,7 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 1 unmodified line
134160 const depth = Math.max(0, Math.min(2, Number(payload.depth ?? 1)));
135161 const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));
136162 const compact = payload.compact === false ? false : true;
163+ const includeSchemas = payload.includeSchemas === true;
137164 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];
138165 const namespaces = new Set(index.map((entry) => getTopLevelNamespace(entry.path)).filter(Boolean));
139166 const namespaceHints = extractNamespaceHints(terms, namespaces);
@@ -159,19 +186,19 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 19 unmodified lines
159186 description: compact ? compactDescriptionLine(entry.description) : entry.description,
160187 signature: formatSignature(entry, depth, compact),
161188 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+ },
163196 exampleCall: buildExampleCall(entry),
164197 }));
165198
166− const schemas = buildSchemaRegistryForEntries(
167− ranked.map(({ entry }) => entry),
168− sourceSchemaTypes,
169− );
170−
171199 return {
172200 bestPath: chooseBestPath(ranked, terms.length),
173201 results,
174− ...(Object.keys(schemas).length > 0 ? { schemas } : {}),
175202 total: results.length,
176203 };
177204 },
executor/packages/core/src/tool-discovery/formatting.ts+8−18
@@ -1,6 +1,3 @@
1−import {
2− extractTopLevelTypeKeys,
3−} from "../type-hints";
41 import type { DiscoverIndexEntry } from "./types";
52
63 export function buildExampleCall(entry: DiscoverIndexEntry): string {
@@ -9,11 +6,11 @@ export function buildExampleCall(entry: DiscoverIndexEntry): string { · 2 unmodified lines
96 return `await tools.${callPath}({ query: "query { __typename }", variables: {} });`;
107 }
118
12− if (entry.argsType === "{}") {
9+ if (entry.displayInputHint === "{}") {
1310 return `await tools.${callPath}({});`;
1411 }
1512
16− const keys = entry.argPreviewKeys.length > 0 ? entry.argPreviewKeys : extractTopLevelTypeKeys(entry.argsType);
13+ const keys = entry.previewInputKeys;
1714 if (keys.length > 0) {
1815 const argsSnippet = keys
1916 .slice(0, 5)
@@ -32,8 +29,8 @@ export function formatSignature(entry: DiscoverIndexEntry, depth: number, compac · 12 unmodified lines
3229 return "(input: ...): Promise<...>";
3330 }
3431
35− const args = entry.displayArgsType;
36− const returns = entry.displayReturnsType;
32+ const args = entry.displayInputHint;
33+ const returns = entry.displayOutputHint;
3734
3835 if (depth === 1) {
3936 return `(input: ${args}): Promise<${returns}>`;
@@ -43,23 +40,16 @@ export function formatSignature(entry: DiscoverIndexEntry, depth: number, compac · 3 unmodified lines
4340 }
4441
4542 if (depth <= 0) {
46− return `(input: ${entry.argsType}): Promise<...>`;
43+ return `(input: ${entry.displayInputHint}): Promise<...>`;
4744 }
4845
4946 if (depth === 1) {
50− return `(input: ${entry.argsType}): Promise<${entry.returnsType}>`;
47+ return `(input: ${entry.displayInputHint}): Promise<${entry.displayOutputHint}>`;
5148 }
5249
53− return `(input: ${entry.argsType}): Promise<${entry.returnsType}> [source=${entry.source}]`;
50+ return `(input: ${entry.displayInputHint}): Promise<${entry.displayOutputHint}> [source=${entry.source}]`;
5451 }
5552
5653 export function formatCanonicalSignature(entry: DiscoverIndexEntry): string {
57− return `(input: ${entry.argsType}): Promise<${entry.returnsType}>`;
58−}
59−
60−export function buildExpandedShape(entry: DiscoverIndexEntry): { input: string; output: string } {
61− return {
62− input: entry.expandedArgsShape,
63− output: entry.expandedReturnsShape,
64− };
54+ return `(input: ${entry.displayInputHint}): Promise<${entry.displayOutputHint}>`;
6555 }
executor/packages/core/src/tool-discovery/indexing.ts+39−31
@@ -1,10 +1,7 @@
11 import type { ToolDefinition } from "../types";
2−import {
3− compactArgDisplayHint,
4− compactReturnTypeHint,
5− llmExpandedArgShapeHint,
6− llmExpandedReturnShapeHint,
7−} from "../type-hints";
2+import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";
3+import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
4+import { sanitizeJsonSchemaForConvex } from "../tool-typing/convex-sanitize";
85 import type { DiscoverIndexEntry } from "./types";
96
107 const GENERIC_NAMESPACE_SUFFIXES = new Set([
@@ -16,10 +13,17 @@ const GENERIC_NAMESPACE_SUFFIXES = new Set([ · 5 unmodified lines
1613 "services",
1714 ]);
1815
19−function normalizeType(type?: string): string {
16+function normalizeHint(type?: string): string {
2017 return type && type.trim().length > 0 ? type : "unknown";
2118 }
2219
20+function 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+
2327 export function normalizeSearchToken(value: string): string {
2428 return value.toLowerCase().replace(/[^a-z0-9]/g, "");
2529 }
@@ -99,24 +103,29 @@ export function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] { · 73 unmodified lines
99103 const preferredPath = preferredToolPath(tool.path);
100104 const aliases = getPathAliases(tool.path);
101105 const searchText = `${tool.path} ${preferredPath} ${aliases.join(" ")} ${tool.description} ${tool.source ?? ""}`.toLowerCase();
102− const argsType = normalizeType(tool.metadata?.argsType);
103− const returnsType = normalizeType(tool.metadata?.returnsType);
104− const argPreviewKeys = Array.isArray(tool.metadata?.argPreviewKeys)
105− ? tool.metadata.argPreviewKeys.filter((value): value is string => typeof value === "string")
106− : [];
107− const displayArgsType = normalizeType(
108− tool.metadata?.displayArgsType
109− ?? compactArgDisplayHint(argsType, argPreviewKeys),
110− );
111− const displayReturnsType = normalizeType(
112− tool.metadata?.displayReturnsType
113− ?? compactReturnTypeHint(returnsType),
114− );
115− const expandedArgsShape = normalizeType(
116− llmExpandedArgShapeHint(argsType, argPreviewKeys),
106+
107+ const inputSchema = (tool.typing?.inputSchema && typeof tool.typing.inputSchema === "object")
108+ ? tool.typing.inputSchema
109+ : {};
110+ const outputSchema = (tool.typing?.outputSchema && typeof tool.typing.outputSchema === "object")
111+ ? tool.typing.outputSchema
112+ : {};
113+
114+ const safeInputSchema = sanitizeJsonSchemaForConvex(inputSchema);
115+ const safeOutputSchema = sanitizeJsonSchemaForConvex(outputSchema);
116+
117+ const requiredInputKeys = Array.isArray(tool.typing?.requiredInputKeys)
118+ ? tool.typing!.requiredInputKeys!.filter((value): value is string => typeof value === "string")
119+ : extractTopLevelRequiredKeys(inputSchema);
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),
117126 );
118− const expandedReturnsShape = normalizeType(
119− llmExpandedReturnShapeHint(returnsType),
127+ const displayOutputHint = normalizeHint(
128+ Object.keys(outputSchema).length === 0 ? "unknown" : jsonSchemaTypeHintFallback(outputSchema),
120129 );
121130
122131 return {
@@ -126,13 +135,12 @@ export function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] { · 3 unmodified lines
126135 description: tool.description,
127136 approval: tool.approval,
128137 source: tool.source ?? "local",
129− argsType,
130− returnsType,
131− displayArgsType,
132− displayReturnsType,
133− expandedArgsShape,
134− expandedReturnsShape,
135− argPreviewKeys,
138+ inputSchema: safeInputSchema,
139+ outputSchema: safeOutputSchema,
140+ requiredInputKeys,
141+ previewInputKeys,
142+ displayInputHint,
143+ displayOutputHint,
136144 searchText,
137145 normalizedPath: normalizeSearchToken(tool.path),
138146 normalizedSearchText: normalizeSearchToken(searchText),
executor/packages/core/src/tool-discovery/schema-registry.ts+0−420
@@ -1,420 +0,0 @@
1−import type { ToolDefinition } from "../types";
2−import type { DiscoverIndexEntry } from "./types";
3−
4−function 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−
42−function 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−
110−function 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−
124−function 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−
138−function 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−
154−function 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−
175−function 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−
187−function 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−
276−function 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−
332−export 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−
353−function formatSchemaRefToken(schemaKey: string): string {
354− return `components["schemas"][${JSON.stringify(schemaKey)}]`;
355−}
356−
357−export 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−
367−export 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−
390−export 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 @@
1−import type { ToolDefinition } from "../types";
1+import type { JsonSchema, ToolDefinition } from "../types";
22
33 export interface DiscoverIndexEntry {
44 path: string;
@@ -7,13 +7,12 @@ export interface DiscoverIndexEntry { · 2 unmodified lines
77 description: string;
88 approval: ToolDefinition["approval"];
99 source: string;
10− argsType: string;
11− returnsType: string;
12− displayArgsType: string;
13− displayReturnsType: string;
14− expandedArgsShape: string;
15− expandedReturnsShape: string;
16− argPreviewKeys: string[];
10+ inputSchema: JsonSchema;
11+ outputSchema: JsonSchema;
12+ requiredInputKeys: string[];
13+ previewInputKeys: string[];
14+ displayInputHint: string;
15+ displayOutputHint: string;
1716 searchText: string;
1817 normalizedPath: string;
1918 normalizedSearchText: string;
executor/packages/core/src/tool-source-loaders/graphql-loader.ts+136−18
@@ -2,8 +2,6 @@ · 1 unmodified line
22
33 import {
44 buildFieldQuery,
5− gqlFieldArgsTypeHint,
6− gqlTypeToHint,
75 normalizeGraphqlFieldVariables,
86 selectGraphqlFieldEnvelope,
97 type GqlSchema,
@@ -16,7 +14,7 @@ import { · 6 unmodified lines
1614 import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
1715 import { sanitizeSegment } from "../tool/path-utils";
1816 import type { GraphqlToolSourceConfig } from "../tool/source-types";
19−import { compactArgTypeHint } from "../type-hints";
17+import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
2018 import type { ToolDefinition } from "../types";
2119 import { asRecord } from "../utils";
2220
@@ -56,6 +54,107 @@ const INTROSPECTION_QUERY = ` · 33 unmodified lines
5654 }
5755 `;
5856
57+function 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+
63+function 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+
86+function 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+
135+function 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+
59158 export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDefinition[]> {
60159 const authHeaders = buildStaticAuthHeaders(config.auth);
61160 const sourceKey = `graphql:${config.name}`;
@@ -101,12 +200,25 @@ export async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise · 39 unmodified lines
101200 source: sourceKey,
102201 description: `Execute a GraphQL query or mutation against ${config.name}. Returns { data, errors }. Use ${sourceName}.query.* and ${sourceName}.mutation.* helpers when available.`,
103202 approval: "auto", // Actual approval is determined dynamically per-invocation
104− metadata: {
105− argsType: "{ query: string; variables?: Record<string, unknown> }",
106− returnsType: "{ data: unknown; errors: unknown[] }",
107− displayArgsType: "{ query: string; variables?: ... }",
108− displayReturnsType: "{ data: ...; errors: unknown[] }",
109− argPreviewKeys: ["query", "variables"],
203+ typing: {
204+ inputSchema: {
205+ type: "object",
206+ properties: {
207+ query: { type: "string" },
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"],
110222 },
111223 credential: credentialSpec,
112224 // 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
155267
156268 const fieldPath = `${sourceName}.${operationType}.${sanitizeSegment(field.name)}`;
157269 const approval = config.overrides?.[field.name]?.approval ?? defaultApproval;
158− const argsType = gqlFieldArgsTypeHint(field.args, typeMap);
159− const returnsType = `{ data: ${gqlTypeToHint(field.type, typeMap)}; errors: unknown[] }`;
160− const argPreviewKeys = field.args.map((arg) => arg.name).filter((name) => name.length > 0);
270+ const inputSchema = buildArgsObjectSchema(field.args as any, typeMap);
271+ const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema);
272+ const previewInputKeys = buildPreviewKeys(inputSchema);
161273
162274 // Build the example query for the description
163275 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
173285 : `GraphQL ${operationType}: ${field.name}\n\nPreferred: ${directCallExample}\nReturns: { data, errors }\nRaw GraphQL: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`,
174286 approval,
175287 credential: credentialSpec,
176− metadata: {
177− argsType,
178− returnsType,
179− displayArgsType: compactArgTypeHint(argsType),
180− displayReturnsType: "{ data: ...; errors: unknown[] }",
181− ...(argPreviewKeys.length > 0 ? { argPreviewKeys } : {}),
288+ typing: {
289+ inputSchema,
290+ outputSchema: {
291+ type: "object",
292+ properties: {
293+ data: {},
294+ errors: { type: "array", items: {} },
295+ },
296+ required: ["data"],
297+ },
298+ ...(requiredInputKeys.length > 0 ? { requiredInputKeys } : {}),
299+ ...(previewInputKeys.length > 0 ? { previewInputKeys } : {}),
182300 },
183301 _runSpec: {
184302 kind: "graphql_field" as const,
executor/packages/core/src/tool-source-loaders/mcp-loader.ts+8−13
@@ -1,12 +1,11 @@
11 "use node";
22
33 import { connectMcp, extractMcpResult } from "../mcp-runtime";
4−import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";
54 import { buildCredentialSpec, buildStaticAuthHeaders, getCredentialSourceKey } from "../tool/source-auth";
65 import { callMcpToolWithReconnect } from "../tool/source-execution";
76 import { sanitizeSegment } from "../tool/path-utils";
87 import type { McpToolSourceConfig } from "../tool/source-types";
9−import { compactArgTypeHint, compactReturnTypeHint } from "../type-hints";
8+import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../tool-typing/schema-utils";
109 import type { ToolDefinition } from "../types";
1110 import { asRecord } from "../utils";
1211 import type { SerializedTool } from "../tool/source-serialization";
@@ -59,22 +58,18 @@ export async function loadMcpTools(config: McpToolSourceConfig): Promise<ToolDef · 46 unmodified lines
5958 const toolName = String(tool.name ?? "tool");
6059 const inputSchema = asRecord(tool.inputSchema);
6160 const outputSchema = asRecord(tool.outputSchema);
62− const argPreviewKeys = Object.keys(asRecord(inputSchema.properties)).filter((key) => key.length > 0);
63− const argsType = jsonSchemaTypeHintFallback(inputSchema);
64− const returnsType = Object.keys(outputSchema).length > 0
65− ? jsonSchemaTypeHintFallback(outputSchema)
66− : "unknown";
61+ const previewInputKeys = buildPreviewKeys(inputSchema).filter((key) => key.length > 0);
62+ const requiredInputKeys = extractTopLevelRequiredKeys(inputSchema);
6763 return {
6864 path: `${sanitizeSegment(config.name)}.${sanitizeSegment(toolName)}`,
6965 source: `mcp:${config.name}`,
7066 approval: config.overrides?.[toolName]?.approval ?? config.defaultApproval ?? "auto",
7167 description: String(tool.description ?? `MCP tool ${toolName}`),
72− metadata: {
73− argsType,
74− returnsType,
75− displayArgsType: compactArgTypeHint(argsType),
76− displayReturnsType: compactReturnTypeHint(returnsType),
77− ...(argPreviewKeys.length > 0 ? { argPreviewKeys } : {}),
68+ typing: {
69+ inputSchema,
70+ ...(Object.keys(outputSchema).length > 0 ? { outputSchema } : {}),
71+ ...(requiredInputKeys.length > 0 ? { requiredInputKeys } : {}),
72+ ...(previewInputKeys.length > 0 ? { previewInputKeys } : {}),
7873 },
7974 credential: credentialSpec,
8075 _runSpec: {
executor/packages/core/src/tool-source-loaders/openapi-loader.ts+14−10
@@ -13,7 +13,6 @@ import { · 12 unmodified lines
1313 import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman-runtime";
1414 import { prepareOpenApiSpec } from "../openapi-prepare";
1515 import type { OpenApiToolSourceConfig } from "../tool/source-types";
16−import { compactArgTypeHint, compactReturnTypeHint } from "../type-hints";
1716 import type { ToolDefinition } from "../types";
1817 import { asRecord } from "../utils";
1918 import type { SerializedTool } from "../tool/source-serialization";
@@ -84,8 +83,16 @@ async function loadPostmanCollectionTools( · 64 unmodified lines
8483 const readMethods = new Set(["get", "head", "options"]);
8584 const usedPaths = new Set<string>();
8685 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 }";
88− const returnsType = "unknown";
86+ const inputSchema: Record<string, unknown> = {
87+ type: "object",
88+ properties: {
89+ variables: {},
90+ query: {},
91+ headers: {},
92+ body: {},
93+ },
94+ };
95+ const previewInputKeys = ["variables", "query", "headers", "body"];
8996
9097 const tools: ToolDefinition[] = [];
9198
@@ -130,13 +137,10 @@ async function loadPostmanCollectionTools( · 38 unmodified lines
130137 description: typeof request.description === "string" && request.description.trim().length > 0
131138 ? request.description
132139 : `${method.toUpperCase()} ${url}`,
133− metadata: {
134− argsType,
135− returnsType,
136− displayArgsType: compactArgTypeHint(argsType),
137− displayReturnsType: compactReturnTypeHint(returnsType),
138− argPreviewKeys: ["variables", "query", "headers", "body"],
139− operationId: requestId || requestName,
140+ typing: {
141+ inputSchema,
142+ outputSchema: {},
143+ previewInputKeys,
140144 },
141145 credential: credentialSpec,
142146 _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
124124
125125 const tool = tools[0];
126126 expect(tool?.path).toBe("status.get_status");
127− expect(tool?.metadata?.returnsType).toContain("ok");
128− expect(tool?.metadata?.returnsType).toContain("retries");
129− expect(tool?.metadata?.displayReturnsType).toContain("ok");
127+ const output = tool?.typing?.outputSchema as Record<string, unknown> | undefined;
128+ expect(output).toBeDefined();
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");
130133 } finally {
131134 mcp.server.stop(true);
132135 }
@@ -791,7 +794,10 @@ test("openapi fallback type hints include index signature when truncated", async · 658 unmodified lines
791794 expect(warnings).toHaveLength(0);
792795 const getItems = tools.find((tool) => tool.path === "wide.items.get_items");
793796 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);
795801 });
796802
797803 test("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 @@
1+import { describe, expect, test } from "bun:test";
2+import { OPENAPI_HELPER_TYPES } from "../openapi/helper-types";
3+import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";
4+import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "../tool-sources";
5+import { connectMcp } from "../mcp-runtime";
6+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7+import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
8+
9+type ToolApproval = "auto" | "required";
10+
11+type 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+
25+function requireTypeScript(): typeof import("typescript") {
26+ // eslint-disable-next-line @typescript-eslint/no-var-requires
27+ return require("typescript") as typeof import("typescript");
28+}
29+
30+function safeNamespaceSegment(value: string): string {
31+ const cleaned = value.replace(/[^A-Za-z0-9_]/g, "_");
32+ return cleaned.length > 0 ? cleaned : "source";
33+}
34+
35+function 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+
43+function 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+
48+type NamespaceNode = {
49+ children: Map<string, NamespaceNode>;
50+ tools: AgentToolSignature[];
51+};
52+
53+function 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+
75+const memberNameRegex = /^[$A-Z_][0-9A-Z_$]*$/i;
76+function emitMemberName(name: string): string {
77+ return memberNameRegex.test(name) ? name : JSON.stringify(name);
78+}
79+
80+function 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+
88+function 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+
93+function 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+
116+function 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+
135+function 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+
164+async 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+
221+function 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+
228+function gqlIsNonNull(ref: any): boolean {
229+ return Boolean(ref && typeof ref === "object" && ref.kind === "NON_NULL");
230+}
231+
232+function 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+
250+function 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+
270+async 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+
325+describe("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 @@
1+import type { JsonSchema } from "../types";
2+
3+function 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+ */
27+export 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 @@
1+export const BASE_ENVIRONMENT_DTS = `
2+interface 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+}
12+declare var console: Console;
13+
14+declare function setTimeout(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
15+declare function clearTimeout(id: number): void;
16+declare function setInterval(callback: (...args: any[]) => void, ms?: number, ...args: any[]): number;
17+declare function clearInterval(id: number): void;
18+`;
executor/packages/core/src/tool-typing/schema-utils.ts+55−0
@@ -0,0 +1,55 @@
1+import type { JsonSchema } from "../types";
2+import { asRecord } from "../utils";
3+
4+function uniq(values: string[]): string[] {
5+ return [...new Set(values.filter((v) => v.trim().length > 0))];
6+}
7+
8+function 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+
23+function 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+
36+export 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+
43+export 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+
50+export 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 @@
1+import type { ToolDefinition } from "../types";
2+import { jsonSchemaTypeHintFallback } from "../openapi/schema-hints";
3+import { OPENAPI_HELPER_TYPES } from "../openapi/helper-types";
4+import { BASE_ENVIRONMENT_DTS } from "./env-types";
5+
6+const memberNameRegex = /^[$A-Z_][0-9A-Z_$]*$/i;
7+
8+function emitMemberName(name: string): string {
9+ return memberNameRegex.test(name) ? name : JSON.stringify(name);
10+}
11+
12+function safeNamespaceSegment(value: string): string {
13+ const cleaned = value.replace(/[^A-Za-z0-9_]/g, "_");
14+ return cleaned.length > 0 ? cleaned : "source";
15+}
16+
17+function 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+
25+function 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+
30+function 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+
41+type NamespaceNode = {
42+ children: Map<string, NamespaceNode>;
43+ tools: ToolDefinition[];
44+};
45+
46+function 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+
68+function 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+
76+function 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+
103+function 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+
127+function 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+
156+export 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+ */
170+export 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
22 import { executePostmanRequest, type PostmanSerializedRunSpec } from "../postman-runtime";
33 import { normalizeGraphqlFieldVariables, selectGraphqlFieldEnvelope } from "../graphql/field-tools";
44 import { callMcpToolWithReconnect, executeGraphqlRequest, executeOpenApiRequest } from "./source-execution";
5−import type { ToolApprovalMode, ToolCredentialSpec, ToolDefinition, ToolTypeMetadata } from "../types";
5+import type { ToolApprovalMode, ToolCredentialSpec, ToolDefinition, ToolTyping } from "../types";
66 import { asRecord } from "../utils";
77
88 export interface SerializedTool {
@@ -10,7 +10,7 @@ export interface SerializedTool { · 1 unmodified line
1010 description: string;
1111 approval: ToolApprovalMode;
1212 source?: string;
13− metadata?: ToolTypeMetadata;
13+ typing?: ToolTyping;
1414 credential?: ToolCredentialSpec;
1515 _graphqlSource?: string;
1616 _pseudoTool?: boolean;
@@ -91,7 +91,7 @@ export function serializeTools(tools: ToolDefinition[]): SerializedTool[] { · 74 unmodified lines
9191 description: tool.description,
9292 approval: tool.approval,
9393 source: tool.source,
94− metadata: tool.metadata,
94+ typing: tool.typing,
9595 credential: tool.credential,
9696 _graphqlSource: tool._graphqlSource,
9797 _pseudoTool: tool._pseudoTool,
@@ -111,7 +111,7 @@ export function rehydrateTools( · 13 unmodified lines
111111 description: st.description,
112112 approval: st.approval,
113113 source: st.source,
114− metadata: st.metadata,
114+ typing: st.typing,
115115 credential: st.credential,
116116 _graphqlSource: st._graphqlSource,
117117 _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
1111 export type ToolApprovalMode = "auto" | "required";
1212 export type ToolSourceType = "mcp" | "openapi" | "graphql";
1313
14+export type JsonSchema = Record<string, unknown>;
15+
1416 export type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed";
1517
1618 export interface SourceAuthProfile {
@@ -126,17 +128,13 @@ export interface ToolDescriptor { · 109 unmodified lines
126128 description: string;
127129 approval: ToolApprovalMode;
128130 source?: string;
129− /** Compact display type hint for tool arguments. */
130− argsType?: string;
131− /** Compact display type hint for tool return value. */
132− returnsType?: string;
133− /** Full-fidelity args type hint for strict editor/typecheck usage. */
134− strictArgsType?: string;
135− /** Full-fidelity return type hint for strict editor/typecheck usage. */
136− strictReturnsType?: string;
137− /** Top-level argument key preview for examples/UI. */
138− argPreviewKeys?: string[];
139− operationId?: string;
131+ /** Canonical tool typing/signature info for clients (schema-less Convex-safe subset). */
132+ typing?: ToolDescriptorTyping;
133+ /** Lightweight, human-readable signature hints (derived from schema/typed refs). */
134+ display?: {
135+ input?: string;
136+ output?: string;
137+ };
140138 }
141139
142140 export interface OpenApiSourceQuality {
@@ -246,24 +244,42 @@ export interface ToolRunContext { · 103 unmodified lines
246244 isToolAllowed: (toolPath: string) => boolean;
247245 }
248246
249−export interface ToolTypeMetadata {
250− /** Lightweight TS type hint for args (for LLM prompt / discover tool). */
251− argsType?: string;
252− /** Lightweight TS type hint for return value (for LLM prompt / discover tool). */
253− returnsType?: string;
254− /** Compact args hint intended for UI/discovery display. */
255− displayArgsType?: string;
256− /** Compact return hint intended for UI/discovery display. */
257− displayReturnsType?: string;
258− /** Top-level argument keys for examples/UI without parsing type strings. */
259− argPreviewKeys?: string[];
260− /** Raw operationId from the OpenAPI spec (used to generate typechecker wrapper). */
261− operationId?: string;
247+export type ToolTypedRef =
248+ | {
249+ kind: "openapi_operation";
250+ /** Source key (e.g. "openapi:github") used for namespacing in type bundles. */
251+ sourceKey: string;
252+ /** OperationId in the OpenAPI spec (key for `operations[...]`). */
253+ operationId: string;
254+ };
255+
256+export interface ToolTyping {
257+ /** JSON Schema describing the tool input payload. */
258+ inputSchema?: JsonSchema;
259+ /** JSON Schema describing the tool output payload. */
260+ outputSchema?: JsonSchema;
262261 /**
263− * Raw .d.ts from openapi-typescript for this tool's source.
264− * Only set on the FIRST tool per source to avoid duplication.
262+ * Optional human-readable type hints derived from schema/OpenAPI.
263+ * Used for discover/catalog outputs and UI signatures.
265264 */
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+ */
279+export interface ToolDescriptorTyping {
280+ requiredInputKeys?: string[];
281+ previewInputKeys?: string[];
282+ typedRef?: ToolTypedRef;
267283 }
268284
269285 export interface ToolDefinition {
@@ -271,7 +287,7 @@ export interface ToolDefinition { · 1 unmodified line
271287 description: string;
272288 approval: ToolApprovalMode;
273289 source?: string;
274− metadata?: ToolTypeMetadata;
290+ typing?: ToolTyping;
275291 credential?: ToolCredentialSpec;
276292 /** For GraphQL sources: the source name used for dynamic path extraction */
277293 _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
2929 description: "Echo input",
3030 approval: "auto" as const,
3131 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+ },
3338 run: async (input: unknown) => {
3439 const payload = input as Record<string, unknown>;
3540 return payload.message;
@@ -190,9 +195,8 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 154 unmodified lines
190195 for (const tool of rehydrated) {
191196 expect(typeof tool.run).toBe("function");
192197 expect(tool.path).toContain("test_api.");
193− expect(tool.metadata).toBeDefined();
194− expect(tool.metadata!.argsType).toBeDefined();
195− expect(tool.metadata!.returnsType).toBeDefined();
198+ expect(tool.typing).toBeDefined();
199+ expect(tool.typing!.inputSchema).toBeDefined();
196200 }
197201 });
198202
@@ -218,8 +222,8 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 19 unmodified lines
218222 expect(rehydrated[i]!.description).toBe(tools[i]!.description);
219223 expect(rehydrated[i]!.approval).toBe(tools[i]!.approval);
220224 expect(rehydrated[i]!.source).toBe(tools[i]!.source);
221− expect(rehydrated[i]!.metadata?.argsType).toBe(tools[i]!.metadata?.argsType);
222− expect(rehydrated[i]!.metadata?.returnsType).toBe(tools[i]!.metadata?.returnsType);
225+ expect(rehydrated[i]!.typing?.typedRef?.operationId).toBe(tools[i]!.typing?.typedRef?.operationId);
226+ expect(rehydrated[i]!.typing?.requiredInputKeys).toEqual(tools[i]!.typing?.requiredInputKeys);
223227 }
224228 });
225229
@@ -251,7 +255,11 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 25 unmodified lines
251255 description: "An MCP tool",
252256 approval: "auto",
253257 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+ },
255263 _runSpec: {
256264 kind: "mcp" as const,
257265 url: "https://mcp.example.com/sse",
@@ -282,9 +290,10 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 24 unmodified lines
282290 description: "Execute GraphQL",
283291 approval: "auto",
284292 source: "graphql:linear",
285− metadata: {
286− argsType: "{ query: string; variables?: Record<string, unknown> }",
287− returnsType: "unknown",
293+ typing: {
294+ inputSchema: { type: "object", properties: { query: { type: "string" }, variables: {} }, required: ["query"] },
295+ requiredInputKeys: ["query"],
296+ previewInputKeys: ["query", "variables"],
288297 },
289298 _runSpec: {
290299 kind: "graphql_raw" as const,
@@ -298,9 +307,8 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 7 unmodified lines
298307 description: "List teams",
299308 approval: "auto",
300309 source: "graphql:linear",
301− metadata: {
302− argsType: "{}",
303− returnsType: "unknown",
310+ typing: {
311+ inputSchema: { type: "object", properties: {} },
304312 },
305313 _pseudoTool: true,
306314 _runSpec: {
@@ -404,11 +412,11 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 97 unmodified lines
404412 expect(restoredTools.some((tool) => tool.path === "echo")).toBe(false);
405413
406414 // 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")!;
408416 expect(listTool.approval).toBe("auto"); // GET = auto
409− expect(listTool.metadata!.argsType).toBeDefined();
417+ expect(listTool.typing!.inputSchema).toBeDefined();
410418
411− const createTool = restoredTools.find((t) => t.metadata?.operationId === "createWidget")!;
419+ const createTool = restoredTools.find((t) => t.typing?.typedRef?.operationId === "createWidget")!;
412420 expect(createTool.approval).toBe("required"); // POST = required
413421 });
414422
@@ -529,7 +537,6 @@ describe("workspace tool cache table", () => { · 114 unmodified lines
529537 storageId,
530538 toolCount: 0,
531539 sizeBytes: 27,
532− dtsStorageIds: [],
533540 });
534541
535542 // Hit
@@ -563,7 +570,6 @@ describe("workspace tool cache table", () => { · 27 unmodified lines
563570 storageId: storageId1,
564571 toolCount: 5,
565572 sizeBytes: 3,
566− dtsStorageIds: [],
567573 });
568574
569575 const storageId2 = await t.run(async (ctx) => {
@@ -576,7 +582,6 @@ describe("workspace tool cache table", () => { · 6 unmodified lines
576582 storageId: storageId2,
577583 toolCount: 10,
578584 sizeBytes: 3,
579− dtsStorageIds: [],
580585 });
581586
582587 // New entry
executor/scripts/dev/wipe/convex-db.ts+7−0
@@ -13,8 +13,15 @@ const tables = [ · 12 unmodified lines
1313 "organizations",
1414 "sourceCredentials",
1515 "taskEvents",
16+ "toolCalls",
1617 "tasks",
18+ "openApiSpecCache",
1719 "toolSources",
20+ "workspaceToolRegistryState",
21+ "workspaceToolRegistry",
22+ "workspaceToolNamespaces",
23+ "workspaceMembers",
24+ "workspaceToolCache",
1825 "workspaces",
1926 ] as const;
2027
executor/scripts/e2e/install.sandbox.e2e.test.ts+60−37
@@ -35,6 +35,16 @@ async function runSandboxBash( · 34 unmodified lines
3535 }
3636 }
3737
38+function 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+
3848 function assertSuccess(result: CommandResult, label: string): void {
3949 if (result.exitCode === 0) {
4050 return;
@@ -59,52 +69,65 @@ test("installer works in fresh Vercel sandbox", async () => { · 18 unmodified lines
5969 }
6070 : {};
6171
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+ };
6386
64− try {
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− }
87+ let sandbox: Sandbox | null = await createSandbox();
7688
7789 try {
78− const install = await runSandboxBash(
79− sandbox,
80− [
81− "set -euo pipefail",
82− "cd ~",
83− "if [ -x ~/.executor/bin/executor ]; then ~/.executor/bin/executor uninstall --yes || true; fi",
84− "rm -rf ~/.executor",
85− "start=$(date +%s)",
86− "curl -fsSL https://executor.sh/install | bash",
87− "end=$(date +%s)",
88− "echo INSTALL_SECONDS=$((end-start))",
89− "~/.executor/bin/executor doctor --verbose",
90− ].join("; "),
91− 900_000,
92− );
90+ const installScript = [
91+ "set -euo pipefail",
92+ "cd ~",
93+ "if [ -x ~/.executor/bin/executor ]; then ~/.executor/bin/executor uninstall --yes || true; fi",
94+ "rm -rf ~/.executor",
95+ "start=$(date +%s)",
96+ "curl -fsSL https://executor.sh/install | bash",
97+ "end=$(date +%s)",
98+ "echo INSTALL_SECONDS=$((end-start))",
99+ "~/.executor/bin/executor doctor --verbose",
100+ ].join("; ");
101+
102+ let install: CommandResult;
103+ try {
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+ }
93118 assertSuccess(install, "sandbox install + doctor");
94119
95120 const output = `${install.stdout}\n${install.stderr}`;
96121 expect(output).toContain("Executor status: ready");
97122 expect(output).toContain("Functions: bootstrapped");
98123
99− const uninstall = await runSandboxBash(
100− sandbox,
101− [
102− "set -euo pipefail",
103− "~/.executor/bin/executor uninstall --yes",
104− "test ! -e ~/.executor/runtime/convex-data/convex_local_backend.sqlite3",
105− ].join("; "),
106− 300_000,
107− );
124+ const uninstallScript = [
125+ "set -euo pipefail",
126+ "~/.executor/bin/executor uninstall --yes",
127+ "test ! -e ~/.executor/runtime/convex-data/convex_local_backend.sqlite3",
128+ ].join("; ");
129+
130+ const uninstall = await runSandboxBash(sandbox, uninstallScript, 300_000);
108131 assertSuccess(uninstall, "sandbox uninstall validation");
109132 } finally {
110133 await sandbox.stop();
package.json+1−1
@@ -24,7 +24,7 @@ · 23 unmodified lines
2424 "dev:assistant": "bun run --cwd assistant/packages/server dev",
2525 "dev:bot": "bun run --cwd assistant/packages/bot dev",
2626 "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",
2828 "test:assistant": "bun run --cwd assistant test",
2929 "typecheck": "bun run typecheck:executor && bun run typecheck:assistant",
3030 "typecheck:executor": "bunx tsc --noEmit -p executor/tsconfig.json",