History

Add Convex tool registry for fast tool resolution

Rhys Sullivan committed 9 months ago

Commit 0e64ea81 parent 3b9433015 files · +1021 −95Browse files

Unified · Split

15 files
  1. executor/packages/convex/_generated/api.d.ts +110 −1
  2. executor/packages/convex/access-controls.test.ts +1 −0
  3. executor/packages/convex/database.test.ts +1 −0
  4. executor/packages/convex/executor-mcp.e2e.test.ts +1 −0
  5. executor/packages/convex/executorNode.github-profile.e2e.test.ts +1 −0
  6. executor/packages/convex/mcp/run/code-oom.e2e.test.ts +1 −0
  7. executor/packages/convex/openApiSpecCache.test.ts +1 −0
  8. executor/packages/convex/runtime/tool_call_resolution.ts +74 −53
  9. executor/packages/convex/runtime/tool_invocation.ts +146 −6
  10. executor/packages/convex/runtime/tool_paths.ts +7 −7
  11. executor/packages/convex/runtime/workspace_tools.ts +309 −12
  12. executor/packages/convex/schema.ts +11 −6
  13. executor/packages/convex/toolRegistry.ts +331 −0
  14. executor/packages/convex/workspace.ts +24 −10
  15. executor/scripts/dev/wipe/convex-db.ts +3 −0
executor/packages/convex/_generated/api.d.ts+110−1
@@ -280,7 +280,7 @@ export declare const api: { · 279 unmodified lines
280280 bootstrapAnonymousSession: FunctionReference<
281281 "mutation",
282282 "public",
283− { sessionId?: string },
283+ { actorId?: string; sessionId?: string },
284284 any
285285 >;
286286 deleteToolSource: FunctionReference<
@@ -1219,6 +1219,115 @@ export declare const internal: { · 932 unmodified lines
12191219 any
12201220 >;
12211221 };
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+ };
12221331 workspaceAuthInternal: {
12231332 getWorkspaceAccessForAnonymousSubject: FunctionReference<
12241333 "query",
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/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+1−0
@@ -15,6 +15,7 @@ function setup() { · 14 unmodified lines
1515 "./auth.ts": () => import("./auth"),
1616 "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),
1717 "./workspaceToolCache.ts": () => import("./workspaceToolCache"),
18+ "./toolRegistry.ts": () => import("./toolRegistry"),
1819 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),
1920 "./_generated/api.js": () => import("./_generated/api.js"),
2021 });
executor/packages/convex/executorNode.github-profile.e2e.test.ts+1−0
@@ -12,6 +12,7 @@ function setup() { · 11 unmodified lines
1212 "./executorNode.ts": () => import("./executorNode"),
1313 "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),
1414 "./workspaceToolCache.ts": () => import("./workspaceToolCache"),
15+ "./toolRegistry.ts": () => import("./toolRegistry"),
1516 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),
1617 "./_generated/api.js": () => import("./_generated/api.js"),
1718 });
executor/packages/convex/mcp/run/code-oom.e2e.test.ts+1−0
@@ -16,6 +16,7 @@ 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 });
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+74−53
@@ -1,17 +1,19 @@
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";
911
1012 export function getGraphqlDecision(
1113 task: TaskRecord,
1214 tool: ToolDefinition,
1315 input: unknown,
14− workspaceTools: Map<string, ToolDefinition>,
16+ workspaceTools: Map<string, ToolDefinition> | undefined,
1517 policies: AccessPolicyRecord[],
1618 ): { decision: PolicyDecision; effectivePaths: string[] } {
1719 const sourceName = tool._graphqlSource!;
@@ -30,7 +32,7 @@ export function getGraphqlDecision( · 12 unmodified lines
3032 let worstDecision: PolicyDecision = "allow";
3133
3234 for (const fieldPath of fieldPaths) {
33− const pseudoTool = workspaceTools.get(fieldPath);
35+ const pseudoTool = workspaceTools?.get(fieldPath);
3436 const fieldDecision = pseudoTool
3537 ? getDecisionForContext(
3638 pseudoTool,
@@ -63,31 +65,43 @@ export function getGraphqlDecision( · 26 unmodified lines
6365 return { decision: worstDecision, effectivePaths: fieldPaths };
6466 }
6567
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}`;
68+async function resolveRegistryBuildId(
69+ ctx: ActionCtx,
70+ workspaceId: TaskRecord["workspaceId"],
71+): Promise<string> {
72+ const state = await ctx.runQuery(internal.toolRegistry.getState, { workspaceId }) as
73+ | null
74+ | { readyBuildId?: string };
75+ const buildId = state?.readyBuildId;
76+ if (!buildId) {
77+ throw new Error("Tool registry is not ready yet. Open Tools in the UI to build the registry.");
78+ }
79+ return buildId;
7780 }
7881
79−export async function ensureWorkspaceTools(
82+async function suggestFromRegistry(
8083 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;
84+ workspaceId: TaskRecord["workspaceId"],
85+ buildId: string,
86+ toolPath: string,
87+): Promise<string[]> {
88+ const term = toolPath.split(".").filter(Boolean).join(" ");
89+ const hits = await ctx.runQuery(internal.toolRegistry.searchTools, {
90+ workspaceId,
91+ buildId,
92+ query: term,
93+ limit: 3,
94+ }) as Array<{ preferredPath: string }>;
95+ return hits.map((hit) => hit.preferredPath);
96+}
97+
98+function unknownToolErrorMessage(toolPath: string, suggestions: string[]): string {
99+ const suggestionText = suggestions.length > 0
100+ ? `\nDid you mean: ${suggestions.map((path) => `tools.${path}`).join(", ")}`
101+ : "";
102+ const queryHint = toolPath.split(".").filter(Boolean).join(" ");
103+ const discoverHint = `\nTry: const found = await tools.discover({ query: "${queryHint}", compact: true, depth: 1, limit: 12 });`;
104+ return `Unknown tool: ${toolPath}${suggestionText}${discoverHint}`;
91105 }
92106
93107 export async function resolveToolForCall(
@@ -97,41 +111,48 @@ export async function resolveToolForCall( · 3 unmodified lines
97111 ): Promise<{
98112 tool: ToolDefinition;
99113 resolvedToolPath: string;
100− workspaceTools?: Map<string, ToolDefinition>;
101114 }> {
102− let workspaceTools: Map<string, ToolDefinition> | undefined;
115+ const builtin = baseTools.get(toolPath);
116+ if (builtin) {
117+ return { tool: builtin, resolvedToolPath: toolPath };
118+ }
119+
120+ const buildId = await resolveRegistryBuildId(ctx, task.workspaceId);
121+
103122 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− }
123+ let entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, {
124+ workspaceId: task.workspaceId,
125+ buildId,
126+ path: toolPath,
127+ }) as null | { path: string; serializedToolJson: string };
128+
129+ if (!entry) {
130+ const normalized = normalizeToolPathForLookup(toolPath);
131+ const hits = await ctx.runQuery(internal.toolRegistry.getToolsByNormalizedPath, {
132+ workspaceId: task.workspaceId,
133+ buildId,
134+ normalizedPath: normalized,
135+ limit: 5,
136+ }) as Array<{ path: string; serializedToolJson: string }>;
137+
138+ if (hits.length > 0) {
139+ // Prefer exact match on preferred path formatting, otherwise shortest canonical path.
140+ const exact = hits.find((hit) => toPreferredToolPath(hit.path) === toPreferredToolPath(toolPath));
141+ entry = exact ?? hits.sort((a, b) => a.path.length - b.path.length || a.path.localeCompare(b.path))[0]!;
142+ resolvedToolPath = entry.path;
115143 }
116144 }
117145
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− }
146+ if (!entry) {
147+ const suggestions = await suggestFromRegistry(ctx, task.workspaceId, buildId, toolPath);
148+ throw new Error(unknownToolErrorMessage(toolPath, suggestions));
125149 }
126150
151+ const serialized = JSON.parse(entry.serializedToolJson) as SerializedTool;
152+ const [tool] = rehydrateTools([serialized], baseTools);
127153 if (!tool) {
128− const availableTools = workspaceTools ?? baseTools;
129− throw new Error(unknownToolErrorMessage(toolPath, availableTools));
154+ throw new Error(`Failed to rehydrate tool: ${resolvedToolPath}`);
130155 }
131156
132− return {
133− tool,
134− resolvedToolPath,
135− workspaceTools,
136− };
157+ return { tool, resolvedToolPath };
137158 }
executor/packages/convex/runtime/tool_invocation.ts+146−6
@@ -8,18 +8,19 @@ 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";
2324
2425 function createApprovalId(): string {
2526 return `approval_${crypto.randomUUID()}`;
@@ -57,13 +58,151 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 31 unmodified lines
5758 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });
5859 const typedPolicies = policies as AccessPolicyRecord[];
5960
60− let { tool, resolvedToolPath, workspaceTools } = await resolveToolForCall(ctx, task, toolPath);
61+ // Fast system tools are handled server-side from the registry.
62+ if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {
63+ const toolRegistry = internal.toolRegistry;
64+ const state = await ctx.runQuery(toolRegistry.getState, { workspaceId: task.workspaceId }) as
65+ | null
66+ | { readyBuildId?: string };
67+ const buildId = state?.readyBuildId;
68+ if (!buildId) {
69+ throw new Error("Tool registry is not ready yet. Open Tools in the UI to build the registry.");
70+ }
71+
72+ const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
73+ const isAllowed = (path: string, approval: ToolDefinition["approval"]) =>
74+ getDecisionForContext(
75+ { path, approval, description: "", run: async () => null } as ToolDefinition,
76+ { workspaceId: task.workspaceId, actorId: task.actorId, clientId: task.clientId },
77+ typedPolicies,
78+ ) !== "deny";
79+
80+ const formatSignature = (inputHint: string | undefined, outputHint: string | undefined) => {
81+ const args = (inputHint && inputHint.trim().length > 0) ? inputHint.trim() : "{}";
82+ const out = (outputHint && outputHint.trim().length > 0) ? outputHint.trim() : "unknown";
83+ return `(input: ${args}): Promise<${out}>`;
84+ };
85+
86+ const buildExampleCall = (path: string, previewKeys?: string[]) => {
87+ const keys = Array.isArray(previewKeys) ? previewKeys : [];
88+ if (keys.length === 0) {
89+ return `await tools.${path}({});`;
90+ }
91+ const snippet = keys
92+ .slice(0, 5)
93+ .map((key) => `${key}: ...`)
94+ .join(", ");
95+ return `await tools.${path}({ ${snippet} });`;
96+ };
97+
98+ if (toolPath === "catalog.namespaces") {
99+ const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 200)));
100+ const namespaces = await ctx.runQuery(toolRegistry.listNamespaces, {
101+ workspaceId: task.workspaceId,
102+ buildId,
103+ limit,
104+ }) as Array<{ namespace: string; toolCount: number; samplePaths: string[] }>;
105+ return { namespaces, total: namespaces.length };
106+ }
107+
108+ if (toolPath === "catalog.tools") {
109+ const namespace = String(payload.namespace ?? "").trim().toLowerCase();
110+ const query = String(payload.query ?? "").trim();
111+ const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));
112+
113+ const raw = query
114+ ? await ctx.runQuery(toolRegistry.searchTools, {
115+ workspaceId: task.workspaceId,
116+ buildId,
117+ query,
118+ limit,
119+ })
120+ : namespace
121+ ? await ctx.runQuery(toolRegistry.listToolsByNamespace, {
122+ workspaceId: task.workspaceId,
123+ buildId,
124+ namespace,
125+ limit,
126+ })
127+ : [];
128+
129+ const results = (raw as Array<any>)
130+ .filter((entry) => !namespace || String(entry.preferredPath ?? entry.path ?? "").toLowerCase().startsWith(`${namespace}.`))
131+ .filter((entry) => isAllowed(entry.path, entry.approval))
132+ .slice(0, limit)
133+ .map((entry) => {
134+ const preferredPath = entry.preferredPath ?? entry.path;
135+ return {
136+ path: preferredPath,
137+ aliases: entry.aliases ?? [],
138+ source: entry.source,
139+ approval: entry.approval,
140+ description: entry.description,
141+ signatureInfo: {
142+ input: entry.displayInput,
143+ output: entry.displayOutput,
144+ requiredKeys: entry.requiredInputKeys ?? [],
145+ previewKeys: entry.previewInputKeys ?? [],
146+ },
147+ signatureText: formatSignature(entry.displayInput, entry.displayOutput),
148+ canonicalSignature: formatSignature(entry.displayInput, entry.displayOutput),
149+ exampleCall: buildExampleCall(preferredPath, entry.previewInputKeys),
150+ };
151+ });
152+
153+ return { results, total: results.length };
154+ }
155+
156+ // discover
157+ const query = String(payload.query ?? "").trim();
158+ const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));
159+ const compact = payload.compact === false ? false : true;
160+ const hits = await ctx.runQuery(toolRegistry.searchTools, {
161+ workspaceId: task.workspaceId,
162+ buildId,
163+ query,
164+ limit: Math.max(limit * 2, limit),
165+ }) as Array<any>;
166+
167+ const filtered = hits
168+ .filter((entry) => isAllowed(entry.path, entry.approval))
169+ .slice(0, limit);
170+
171+ const results = filtered.map((entry) => {
172+ const preferredPath = entry.preferredPath ?? entry.path;
173+ const description = compact ? String(entry.description ?? "").split("\n")[0] : entry.description;
174+ return {
175+ path: preferredPath,
176+ aliases: entry.aliases ?? [],
177+ source: entry.source,
178+ approval: entry.approval,
179+ description,
180+ signature: formatSignature(entry.displayInput, entry.displayOutput),
181+ canonicalSignature: formatSignature(entry.displayInput, entry.displayOutput),
182+ signatureInfo: {
183+ input: entry.displayInput,
184+ output: entry.displayOutput,
185+ requiredKeys: entry.requiredInputKeys ?? [],
186+ previewKeys: entry.previewInputKeys ?? [],
187+ },
188+ exampleCall: buildExampleCall(preferredPath, entry.previewInputKeys),
189+ };
190+ });
191+
192+ const bestPath = results[0]?.path ?? null;
193+ return {
194+ bestPath,
195+ results,
196+ total: results.length,
197+ };
198+ }
199+
200+ let { tool, resolvedToolPath } = await resolveToolForCall(ctx, task, toolPath);
61201
62202 let decision: PolicyDecision;
63203 let effectiveToolPath = resolvedToolPath;
64204 if (tool._graphqlSource) {
65− workspaceTools = await ensureWorkspaceTools(ctx, task, workspaceTools);
66− const result = getGraphqlDecision(task, tool, input, workspaceTools, typedPolicies);
205+ const result = getGraphqlDecision(task, tool, input, undefined, typedPolicies);
67206 decision = result.decision;
68207 if (result.effectivePaths.length > 0) {
69208 effectiveToolPath = result.effectivePaths.join(", ");
@@ -179,7 +318,8 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 109 unmodified lines
179318 actorId: task.actorId,
180319 clientId: task.clientId,
181320 credential,
182− isToolAllowed: (path) => isToolAllowedForTask(task, path, workspaceTools ?? baseTools, typedPolicies),
321+ // Tool visibility is enforced server-side; runtime tool implementations don't use this.
322+ isToolAllowed: (_path) => true,
183323 };
184324 const value = await tool.run(input, context);
185325 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/workspace_tools.ts+309−12
@@ -3,14 +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";
76 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";
89 import {
910 materializeCompiledToolSource,
1011 materializeWorkspaceSnapshot,
1112 type CompiledToolSourceArtifact,
1213 type WorkspaceToolSnapshot,
1314 } from "../../core/src/tool-sources";
15+import { serializeTools } from "../../core/src/tool/source-serialization";
1416 import type { ExternalToolSourceConfig } from "../../core/src/tool/source-types";
1517 import type {
1618 AccessPolicyRecord,
@@ -21,6 +23,7 @@ import type { · 4 unmodified lines
2123 } from "../../core/src/types";
2224 import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors";
2325 import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";
26+import { normalizeToolPathForLookup } from "./tool_paths";
2427
2528 const baseTools = new Map<string, ToolDefinition>();
2629
@@ -88,6 +91,91 @@ baseTools.set("admin.delete_data", { · 61 unmodified lines
8891 },
8992 });
9093
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+});
178+
91179 interface WorkspaceToolsResult {
92180 tools: Map<string, ToolDefinition>;
93181 warnings: string[];
@@ -176,27 +264,227 @@ function computeSourceAuthProfiles(tools: Map<string, ToolDefinition>): Record<s · 82 unmodified lines
176264 return profiles;
177265 }
178266
179−function mergeToolsWithCatalog(externalTools: Iterable<ToolDefinition>): Map<string, ToolDefinition> {
267+function mergeTools(externalTools: Iterable<ToolDefinition>): Map<string, ToolDefinition> {
180268 const merged = new Map<string, ToolDefinition>();
181269
182270 for (const tool of baseTools.values()) {
183− if (tool.path === "discover") continue;
184271 merged.set(tool.path, tool);
185272 }
186273
187274 for (const tool of externalTools) {
188− if (tool.path === "discover") continue;
189275 merged.set(tool.path, tool);
190276 }
277+ return merged;
278+}
191279
192− const catalogTools = createCatalogTools([...merged.values()]);
193− for (const tool of catalogTools) {
194− merged.set(tool.path, tool);
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);
290+}
291+
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);
195309 }
196310
197− const discover = createDiscoverTool([...merged.values()]);
198− merged.set(discover.path, discover);
199− return merged;
311+ while (collapsed.length > 1) {
312+ const last = collapsed[collapsed.length - 1];
313+ if (!last || !GENERIC_NAMESPACE_SUFFIXES.has(last)) break;
314+ collapsed.pop();
315+ }
316+
317+ return collapsed.join("_");
318+}
319+
320+function preferredToolPath(path: string): string {
321+ const segments = path.split(".").filter(Boolean);
322+ if (segments.length === 0) return path;
323+
324+ const simplifiedNamespace = simplifyNamespaceSegment(segments[0]!);
325+ if (!simplifiedNamespace || simplifiedNamespace === segments[0]) {
326+ return path;
327+ }
328+
329+ return [simplifiedNamespace, ...segments.slice(1)].join(".");
330+}
331+
332+function toCamelSegment(segment: string): string {
333+ return segment.replace(/_+([a-z0-9])/g, (_m, char: string) => char.toUpperCase());
334+}
335+
336+function getPathAliases(path: string): string[] {
337+ const segments = path.split(".").filter(Boolean);
338+ if (segments.length === 0) return [];
339+
340+ const canonicalPath = path;
341+ const publicPath = preferredToolPath(path);
342+
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);
355+}
356+
357+
358+function normalizeHint(type?: string): string {
359+ return type && type.trim().length > 0 ? type : "unknown";
360+}
361+
362+function isEmptyObjectSchema(schema: Record<string, unknown>): boolean {
363+ if (Object.keys(schema).length === 0) return true;
364+ const props = schema.properties && typeof schema.properties === "object" ? schema.properties as Record<string, unknown> : {};
365+ const required = Array.isArray(schema.required) ? schema.required : [];
366+ return Object.keys(props).length === 0 && required.length === 0;
367+}
368+
369+async function buildWorkspaceToolRegistry(
370+ ctx: ActionCtx,
371+ args: {
372+ workspaceId: Id<"workspaces">;
373+ signature: string;
374+ tools: ToolDefinition[];
375+ },
376+): Promise<{ buildId: string }> {
377+ const buildId = `toolreg_${crypto.randomUUID()}`;
378+ await ctx.runMutation(internal.toolRegistry.beginBuild, {
379+ workspaceId: args.workspaceId,
380+ signature: args.signature,
381+ buildId,
382+ });
383+
384+ const serialized = serializeTools(args.tools);
385+
386+ const entries = serialized.map((st) => {
387+ if (st.path === "discover" || st.path.startsWith("catalog.")) {
388+ return null;
389+ }
390+ const preferredPath = preferredToolPath(st.path);
391+ const aliases = getPathAliases(st.path);
392+ const namespace = (preferredPath.split(".")[0] ?? "default").toLowerCase();
393+ const normalizedPath = normalizeToolPathForLookup(st.path);
394+ const searchText = `${st.path} ${preferredPath} ${aliases.join(" ")} ${st.description} ${st.source ?? ""}`.toLowerCase();
395+
396+ const inputSchema = (st.typing?.inputSchema && typeof st.typing.inputSchema === "object")
397+ ? st.typing.inputSchema
398+ : {};
399+ const outputSchema = (st.typing?.outputSchema && typeof st.typing.outputSchema === "object")
400+ ? st.typing.outputSchema
401+ : {};
402+
403+ const requiredInputKeys = Array.isArray(st.typing?.requiredInputKeys)
404+ ? st.typing!.requiredInputKeys!.filter((v): v is string => typeof v === "string")
405+ : extractTopLevelRequiredKeys(inputSchema as any);
406+ const previewInputKeys = Array.isArray(st.typing?.previewInputKeys)
407+ ? st.typing!.previewInputKeys!.filter((v): v is string => typeof v === "string")
408+ : buildPreviewKeys(inputSchema as any);
409+
410+ const displayInput = normalizeHint(
411+ isEmptyObjectSchema(inputSchema as any) ? "{}" : jsonSchemaTypeHintFallback(inputSchema),
412+ );
413+ const displayOutput = normalizeHint(
414+ Object.keys(outputSchema as any).length === 0 ? "unknown" : jsonSchemaTypeHintFallback(outputSchema),
415+ );
416+
417+ const typedRef = st.typing?.typedRef && st.typing.typedRef.kind === "openapi_operation"
418+ ? {
419+ kind: "openapi_operation" as const,
420+ sourceKey: st.typing.typedRef.sourceKey,
421+ operationId: st.typing.typedRef.operationId,
422+ }
423+ : undefined;
424+
425+ return {
426+ path: st.path,
427+ preferredPath,
428+ namespace,
429+ normalizedPath,
430+ aliases,
431+ description: st.description,
432+ approval: st.approval,
433+ source: st.source,
434+ searchText,
435+ displayInput,
436+ displayOutput,
437+ requiredInputKeys,
438+ previewInputKeys,
439+ typedRef,
440+ serializedToolJson: JSON.stringify(st),
441+ };
442+ });
443+
444+ const filteredEntries = entries.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
445+
446+ const namespaceMap = new Map<string, { toolCount: number; samplePaths: string[] }>();
447+ for (const entry of filteredEntries) {
448+ const current = namespaceMap.get(entry.namespace) ?? { toolCount: 0, samplePaths: [] };
449+ current.toolCount += 1;
450+ if (current.samplePaths.length < 6) {
451+ current.samplePaths.push(entry.preferredPath);
452+ }
453+ namespaceMap.set(entry.namespace, current);
454+ }
455+
456+ const namespaces = [...namespaceMap.entries()]
457+ .map(([namespace, meta]) => ({
458+ namespace,
459+ toolCount: meta.toolCount,
460+ samplePaths: [...meta.samplePaths].sort((a, b) => a.localeCompare(b)).slice(0, 3),
461+ }))
462+ .sort((a, b) => a.namespace.localeCompare(b.namespace));
463+
464+ const TOOL_BATCH = 100;
465+ for (let i = 0; i < filteredEntries.length; i += TOOL_BATCH) {
466+ await ctx.runMutation(internal.toolRegistry.putToolsBatch, {
467+ workspaceId: args.workspaceId,
468+ buildId,
469+ tools: filteredEntries.slice(i, i + TOOL_BATCH),
470+ });
471+ }
472+
473+ const NS_BATCH = 100;
474+ for (let i = 0; i < namespaces.length; i += NS_BATCH) {
475+ await ctx.runMutation(internal.toolRegistry.putNamespacesBatch, {
476+ workspaceId: args.workspaceId,
477+ buildId,
478+ namespaces: namespaces.slice(i, i + NS_BATCH),
479+ });
480+ }
481+
482+ await ctx.runMutation(internal.toolRegistry.finishBuild, {
483+ workspaceId: args.workspaceId,
484+ buildId,
485+ });
486+
487+ return { buildId };
200488 }
201489
202490 export async function getWorkspaceTools(
@@ -251,7 +539,7 @@ export async function getWorkspaceTools( · 48 unmodified lines
251539 if (blob) {
252540 const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot;
253541 const restored = materializeWorkspaceSnapshot(snapshot);
254− const merged = mergeToolsWithCatalog(restored);
542+ const merged = mergeTools(restored);
255543 traceStep("cacheHydrate", cacheHydrateStartedAt);
256544
257545 const typesStorageId = cacheEntry.typesStorageId as Id<"_storage"> | undefined;
@@ -362,7 +650,7 @@ export async function getWorkspaceTools( · 104 unmodified lines
362650 const timedOutSources = loadedSources
363651 .filter((loaded) => loaded.timedOut)
364652 .map((loaded) => loaded.sourceName);
365− const merged = mergeToolsWithCatalog(externalTools);
653+ const merged = mergeTools(externalTools);
366654
367655 let typesStorageId: Id<"_storage"> | undefined;
368656 try {
@@ -387,6 +675,15 @@ export async function getWorkspaceTools( · 18 unmodified lines
387675 const snapshotWriteStartedAt = Date.now();
388676 const allTools = [...merged.values()];
389677
678+ // Build a per-tool registry for fast discover + invocation.
679+ const registryStartedAt = Date.now();
680+ await buildWorkspaceToolRegistry(ctx, {
681+ workspaceId,
682+ signature,
683+ tools: allTools,
684+ });
685+ traceStep("toolRegistryWrite", registryStartedAt);
686+
390687 // Build and store a workspace-wide Monaco type bundle.
391688 const openApiDtsBySource: Record<string, string> = {};
392689 for (const loaded of loadedSources) {
executor/packages/convex/schema.ts+11−6
@@ -444,6 +444,8 @@ export default defineSchema({ · 443 unmodified lines
444444 buildId: v.string(),
445445 path: v.string(),
446446 preferredPath: v.string(),
447+ namespace: v.string(),
448+ normalizedPath: v.string(),
447449 aliases: v.array(v.string()),
448450 description: v.string(),
449451 approval: toolApprovalMode,
@@ -463,22 +465,25 @@ export default defineSchema({ · 13 unmodified lines
463465 createdAt: v.number(),
464466 })
465467 .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"])
466470 .index("by_workspace_build", ["workspaceId", "buildId"])
467471 .searchIndex("search_text", {
468472 searchField: "searchText",
469473 filterFields: ["workspaceId", "buildId"],
470474 }),
471475
472− // Alias -> canonical path mapping for registry lookups.
473− workspaceToolAliases: defineTable({
476+ // Precomputed namespace summaries for fast catalog.namespaces.
477+ workspaceToolNamespaces: defineTable({
474478 workspaceId: v.id("workspaces"),
475479 buildId: v.string(),
476− alias: v.string(),
477− path: v.string(),
480+ namespace: v.string(),
481+ toolCount: v.number(),
482+ samplePaths: v.array(v.string()),
478483 createdAt: v.number(),
479484 })
480− .index("by_workspace_build_alias", ["workspaceId", "buildId", "alias"])
481− .index("by_workspace_build", ["workspaceId", "buildId"]),
485+ .index("by_workspace_build", ["workspaceId", "buildId"])
486+ .index("by_workspace_build_namespace", ["workspaceId", "buildId", "namespace"]),
482487
483488 // Anonymous session linkage.
484489 // Used to map an unauthenticated/anonymous actor to a backing `accounts` row and a
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+24−10
@@ -18,24 +18,38 @@ function redactCredential<T extends { secretJson: Record<string, unknown> }>(cre · 17 unmodified lines
1818 }
1919
2020 export const bootstrapAnonymousSession = mutation({
21− args: { sessionId: v.optional(v.string()) },
22− handler: async (ctx) => {
21+ args: {
22+ sessionId: v.optional(v.string()),
23+ actorId: v.optional(v.string()),
24+ },
25+ handler: async (ctx, args) => {
2326 const identity = await ctx.auth.getUserIdentity();
24− if (!identity) {
25− throw new Error("Anonymous auth token is required");
26− }
27+ const requestedSessionId = args.sessionId?.trim();
28+ const requestedActorId = args.actorId?.trim();
29+ const isAnonymousIdentityRequest = Boolean(identity && isAnonymousIdentity(identity));
2730
28− if (!isAnonymousIdentity(identity)) {
31+ if (!isAnonymousIdentityRequest && identity) {
2932 throw new Error("Anonymous auth token is required");
3033 }
3134
32− const actorId = identity.subject;
33− if (!actorId.startsWith("anon_")) {
35+ const actorId = isAnonymousIdentityRequest
36+ ? identity!.subject
37+ : requestedActorId;
38+
39+ if (actorId && !actorId.startsWith("anon_")) {
3440 throw new Error("Anonymous token subject must use anon_* actor ids");
3541 }
36− const stableSessionId = `anon_session_${actorId}`;
42+
43+ if (!actorId && !requestedSessionId) {
44+ throw new Error("Anonymous auth token is required");
45+ }
46+
47+ const sessionId = actorId
48+ ? `anon_session_${actorId}`
49+ : requestedSessionId;
50+
3751 return await ctx.runMutation(internal.database.bootstrapAnonymousSession, {
38− sessionId: stableSessionId,
52+ sessionId,
3953 actorId,
4054 clientId: "web",
4155 });
executor/scripts/dev/wipe/convex-db.ts+3−0
@@ -17,6 +17,9 @@ const tables = [ · 16 unmodified lines
1717 "tasks",
1818 "openApiSpecCache",
1919 "toolSources",
20+ "workspaceToolRegistryState",
21+ "workspaceToolRegistry",
22+ "workspaceToolNamespaces",
2023 "workspaceMembers",
2124 "workspaceToolCache",
2225 "workspaces",