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
280 bootstrapAnonymousSession: FunctionReference<280 bootstrapAnonymousSession: FunctionReference<
281 "mutation",281 "mutation",
282 "public",282 "public",
283 { sessionId?: string },283 { actorId?: string; sessionId?: string },
284 any284 any
285 >;285 >;
286 deleteToolSource: FunctionReference<286 deleteToolSource: FunctionReference<
@@ -1219,6 +1219,115 @@ export declare const internal: { · 932 unmodified lines
1219 any1219 any
1220 >;1220 >;
1221 };1221 };
1222 toolRegistry: {
1223 beginBuild: FunctionReference<
1224 "mutation",
1225 "internal",
1226 { buildId: string; signature: string; workspaceId: Id<"workspaces"> },
1227 any
1228 >;
1229 finishBuild: FunctionReference<
1230 "mutation",
1231 "internal",
1232 { buildId: string; workspaceId: Id<"workspaces"> },
1233 any
1234 >;
1235 getState: FunctionReference<
1236 "query",
1237 "internal",
1238 { workspaceId: Id<"workspaces"> },
1239 any
1240 >;
1241 getToolByPath: FunctionReference<
1242 "query",
1243 "internal",
1244 { buildId: string; path: string; workspaceId: Id<"workspaces"> },
1245 any
1246 >;
1247 getToolsByNormalizedPath: FunctionReference<
1248 "query",
1249 "internal",
1250 {
1251 buildId: string;
1252 limit: number;
1253 normalizedPath: string;
1254 workspaceId: Id<"workspaces">;
1255 },
1256 any
1257 >;
1258 listNamespaces: FunctionReference<
1259 "query",
1260 "internal",
1261 { buildId: string; limit: number; workspaceId: Id<"workspaces"> },
1262 any
1263 >;
1264 listToolsByNamespace: FunctionReference<
1265 "query",
1266 "internal",
1267 {
1268 buildId: string;
1269 limit: number;
1270 namespace: string;
1271 workspaceId: Id<"workspaces">;
1272 },
1273 any
1274 >;
1275 putNamespacesBatch: FunctionReference<
1276 "mutation",
1277 "internal",
1278 {
1279 buildId: string;
1280 namespaces: Array<{
1281 namespace: string;
1282 samplePaths: Array<string>;
1283 toolCount: number;
1284 }>;
1285 workspaceId: Id<"workspaces">;
1286 },
1287 any
1288 >;
1289 putToolsBatch: FunctionReference<
1290 "mutation",
1291 "internal",
1292 {
1293 buildId: string;
1294 tools: Array<{
1295 aliases: Array<string>;
1296 approval: "auto" | "required";
1297 description: string;
1298 displayInput?: string;
1299 displayOutput?: string;
1300 namespace: string;
1301 normalizedPath: string;
1302 path: string;
1303 preferredPath: string;
1304 previewInputKeys?: Array<string>;
1305 requiredInputKeys?: Array<string>;
1306 searchText: string;
1307 serializedToolJson: string;
1308 source?: string;
1309 typedRef?: {
1310 kind: "openapi_operation";
1311 operationId: string;
1312 sourceKey: string;
1313 };
1314 }>;
1315 workspaceId: Id<"workspaces">;
1316 },
1317 any
1318 >;
1319 searchTools: FunctionReference<
1320 "query",
1321 "internal",
1322 {
1323 buildId: string;
1324 limit: number;
1325 query: string;
1326 workspaceId: Id<"workspaces">;
1327 },
1328 any
1329 >;
1330 };
1222 workspaceAuthInternal: {1331 workspaceAuthInternal: {
1223 getWorkspaceAccessForAnonymousSubject: FunctionReference<1332 getWorkspaceAccessForAnonymousSubject: FunctionReference<
1224 "query",1333 "query",
executor/packages/convex/access-controls.test.ts+1−0
@@ -21,6 +21,7 @@ function setup() { · 20 unmodified lines
21 "./app.ts": () => import("./app"),21 "./app.ts": () => import("./app"),
22 "./billingInternal.ts": () => import("./billingInternal"),22 "./billingInternal.ts": () => import("./billingInternal"),
23 "./billingSync.ts": () => import("./billingSync"),23 "./billingSync.ts": () => import("./billingSync"),
24 "./toolRegistry.ts": () => import("./toolRegistry"),
24 "./_generated/api.js": () => import("./_generated/api.js"),25 "./_generated/api.js": () => import("./_generated/api.js"),
25 });26 });
26}27}
executor/packages/convex/database.test.ts+1−0
@@ -7,6 +7,7 @@ import schema from "./schema"; · 6 unmodified lines
7function setup() {7function setup() {
8 return convexTest(schema, {8 return convexTest(schema, {
9 "./database.ts": () => import("./database"),9 "./database.ts": () => import("./database"),
10 "./toolRegistry.ts": () => import("./toolRegistry"),
10 "./_generated/api.js": () => import("./_generated/api.js"),11 "./_generated/api.js": () => import("./_generated/api.js"),
11 });12 });
12}13}
executor/packages/convex/executor-mcp.e2e.test.ts+1−0
@@ -15,6 +15,7 @@ function setup() { · 14 unmodified lines
15 "./auth.ts": () => import("./auth"),15 "./auth.ts": () => import("./auth"),
16 "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),16 "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),
17 "./workspaceToolCache.ts": () => import("./workspaceToolCache"),17 "./workspaceToolCache.ts": () => import("./workspaceToolCache"),
18 "./toolRegistry.ts": () => import("./toolRegistry"),
18 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),19 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),
19 "./_generated/api.js": () => import("./_generated/api.js"),20 "./_generated/api.js": () => import("./_generated/api.js"),
20 });21 });
executor/packages/convex/executorNode.github-profile.e2e.test.ts+1−0
@@ -12,6 +12,7 @@ function setup() { · 11 unmodified lines
12 "./executorNode.ts": () => import("./executorNode"),12 "./executorNode.ts": () => import("./executorNode"),
13 "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),13 "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),
14 "./workspaceToolCache.ts": () => import("./workspaceToolCache"),14 "./workspaceToolCache.ts": () => import("./workspaceToolCache"),
15 "./toolRegistry.ts": () => import("./toolRegistry"),
15 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),16 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),
16 "./_generated/api.js": () => import("./_generated/api.js"),17 "./_generated/api.js": () => import("./_generated/api.js"),
17 });18 });
executor/packages/convex/mcp/run/code-oom.e2e.test.ts+1−0
@@ -16,6 +16,7 @@ function setup() { · 15 unmodified lines
16 "../../auth.ts": () => import("../../auth"),16 "../../auth.ts": () => import("../../auth"),
17 "../../workspaceAuthInternal.ts": () => import("../../workspaceAuthInternal"),17 "../../workspaceAuthInternal.ts": () => import("../../workspaceAuthInternal"),
18 "../../workspaceToolCache.ts": () => import("../../workspaceToolCache"),18 "../../workspaceToolCache.ts": () => import("../../workspaceToolCache"),
19 "../../toolRegistry.ts": () => import("../../toolRegistry"),
19 "../../openApiSpecCache.ts": () => import("../../openApiSpecCache"),20 "../../openApiSpecCache.ts": () => import("../../openApiSpecCache"),
20 "../../_generated/api.js": () => import("../../_generated/api.js"),21 "../../_generated/api.js": () => import("../../_generated/api.js"),
21 });22 });
executor/packages/convex/openApiSpecCache.test.ts+1−0
@@ -6,6 +6,7 @@ import schema from "./schema"; · 5 unmodified lines
6function setup() {6function setup() {
7 return convexTest(schema, {7 return convexTest(schema, {
8 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),8 "./openApiSpecCache.ts": () => import("./openApiSpecCache"),
9 "./toolRegistry.ts": () => import("./toolRegistry"),
9 "./_generated/api.js": () => import("./_generated/api.js"),10 "./_generated/api.js": () => import("./_generated/api.js"),
10 });11 });
11}12}
executor/packages/convex/runtime/tool_call_resolution.ts+74−53
@@ -1,17 +1,19 @@
1"use node";1"use node";
2 2
3import type { ActionCtx } from "../_generated/server";3import type { ActionCtx } from "../_generated/server";
4import { internal } from "../_generated/api";
4import { parseGraphqlOperationPaths } from "../../core/src/graphql/operation-paths";5import { parseGraphqlOperationPaths } from "../../core/src/graphql/operation-paths";
5import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../core/src/types";6import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../core/src/types";
7import { rehydrateTools, type SerializedTool } from "../../core/src/tool/source-serialization";
6import { getDecisionForContext, getToolDecision } from "./policy";8import { getDecisionForContext, getToolDecision } from "./policy";
7import { resolveAliasedToolPath, resolveClosestToolPath, suggestToolPaths, toPreferredToolPath } from "./tool_paths";9import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";
8import { baseTools, getWorkspaceTools } from "./workspace_tools";10import { baseTools } from "./workspace_tools";
9 11
10export function getGraphqlDecision(12export function getGraphqlDecision(
11 task: TaskRecord,13 task: TaskRecord,
12 tool: ToolDefinition,14 tool: ToolDefinition,
13 input: unknown,15 input: unknown,
14 workspaceTools: Map<string, ToolDefinition>,16 workspaceTools: Map<string, ToolDefinition> | undefined,
15 policies: AccessPolicyRecord[],17 policies: AccessPolicyRecord[],
16): { decision: PolicyDecision; effectivePaths: string[] } {18): { decision: PolicyDecision; effectivePaths: string[] } {
17 const sourceName = tool._graphqlSource!;19 const sourceName = tool._graphqlSource!;
@@ -30,7 +32,7 @@ export function getGraphqlDecision( · 12 unmodified lines
30 let worstDecision: PolicyDecision = "allow";32 let worstDecision: PolicyDecision = "allow";
31 33
32 for (const fieldPath of fieldPaths) {34 for (const fieldPath of fieldPaths) {
33 const pseudoTool = workspaceTools.get(fieldPath);35 const pseudoTool = workspaceTools?.get(fieldPath);
34 const fieldDecision = pseudoTool36 const fieldDecision = pseudoTool
35 ? getDecisionForContext(37 ? getDecisionForContext(
36 pseudoTool,38 pseudoTool,
@@ -63,31 +65,43 @@ export function getGraphqlDecision( · 26 unmodified lines
63 return { decision: worstDecision, effectivePaths: fieldPaths };65 return { decision: worstDecision, effectivePaths: fieldPaths };
64}66}
65 67
66function unknownToolErrorMessage(toolPath: string, availableTools: Map<string, ToolDefinition>): string {68async function resolveRegistryBuildId(
67 const suggestions = suggestToolPaths(toolPath, availableTools);69 ctx: ActionCtx,
68 const queryHint = toolPath70 workspaceId: TaskRecord["workspaceId"],
69 .split(".")71): Promise<string> {
70 .filter(Boolean)72 const state = await ctx.runQuery(internal.toolRegistry.getState, { workspaceId }) as
71 .join(" ");73 | null
72 const suggestionText = suggestions.length > 074 | { readyBuildId?: string };
73 ? `\nDid you mean: ${suggestions.map((path) => `tools.${toPreferredToolPath(path)}`).join(", ")}`75 const buildId = state?.readyBuildId;
74 : "";76 if (!buildId) {
75 const discoverHint = `\nTry: const found = await tools.discover({ query: "${queryHint}", compact: false, depth: 2, limit: 12 });`;77 throw new Error("Tool registry is not ready yet. Open Tools in the UI to build the registry.");
76 return `Unknown tool: ${toolPath}${suggestionText}${discoverHint}`;78 }
79 return buildId;
77}80}
78 81
79export async function ensureWorkspaceTools(82async function suggestFromRegistry(
80 ctx: ActionCtx,83 ctx: ActionCtx,
81 task: TaskRecord,84 workspaceId: TaskRecord["workspaceId"],
82 workspaceTools?: Map<string, ToolDefinition>,85 buildId: string,
83): Promise<Map<string, ToolDefinition>> {86 toolPath: string,
84 if (workspaceTools) {87): Promise<string[]> {
85 return workspaceTools;88 const term = toolPath.split(".").filter(Boolean).join(" ");
86 }89 const hits = await ctx.runQuery(internal.toolRegistry.searchTools, {
87 const result = await getWorkspaceTools(ctx, task.workspaceId, {90 workspaceId,
88 actorId: task.actorId,91 buildId,
89 });92 query: term,
90 return result.tools;93 limit: 3,
94 }) as Array<{ preferredPath: string }>;
95 return hits.map((hit) => hit.preferredPath);
96}
97
98function 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}`;
91}105}
92 106
93export async function resolveToolForCall(107export async function resolveToolForCall(
@@ -97,41 +111,48 @@ export async function resolveToolForCall( · 3 unmodified lines
97): Promise<{111): Promise<{
98 tool: ToolDefinition;112 tool: ToolDefinition;
99 resolvedToolPath: string;113 resolvedToolPath: string;
100 workspaceTools?: Map<string, ToolDefinition>;
101}> {114}> {
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
103 let resolvedToolPath = toolPath;122 let resolvedToolPath = toolPath;
104 let tool = baseTools.get(toolPath);123 let entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, {
105 if (!tool) {124 workspaceId: task.workspaceId,
106 workspaceTools = await ensureWorkspaceTools(ctx, task, workspaceTools);125 buildId,
107 tool = workspaceTools.get(toolPath);126 path: toolPath,
108 127 }) as null | { path: string; serializedToolJson: string };
109 if (!tool) {128
110 const aliasedPath = resolveAliasedToolPath(toolPath, workspaceTools);129 if (!entry) {
111 if (aliasedPath) {130 const normalized = normalizeToolPathForLookup(toolPath);
112 resolvedToolPath = aliasedPath;131 const hits = await ctx.runQuery(internal.toolRegistry.getToolsByNormalizedPath, {
113 tool = workspaceTools.get(aliasedPath);132 workspaceId: task.workspaceId,
114 }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;
115 }143 }
116 }144 }
117 145
118 if (!tool) {146 if (!entry) {
119 const availableTools = workspaceTools ?? baseTools;147 const suggestions = await suggestFromRegistry(ctx, task.workspaceId, buildId, toolPath);
120 const healedPath = resolveClosestToolPath(toolPath, availableTools);148 throw new Error(unknownToolErrorMessage(toolPath, suggestions));
121 if (healedPath) {
122 resolvedToolPath = healedPath;
123 tool = availableTools.get(healedPath);
124 }
125 }149 }
126 150
151 const serialized = JSON.parse(entry.serializedToolJson) as SerializedTool;
152 const [tool] = rehydrateTools([serialized], baseTools);
127 if (!tool) {153 if (!tool) {
128 const availableTools = workspaceTools ?? baseTools;154 throw new Error(`Failed to rehydrate tool: ${resolvedToolPath}`);
129 throw new Error(unknownToolErrorMessage(toolPath, availableTools));
130 }155 }
131 156
132 return {157 return { tool, resolvedToolPath };
133 tool,
134 resolvedToolPath,
135 workspaceTools,
136 };
137}158}
executor/packages/convex/runtime/tool_invocation.ts+146−6
@@ -8,18 +8,19 @@ import type { · 7 unmodified lines
8 PolicyDecision,8 PolicyDecision,
9 ResolvedToolCredential,9 ResolvedToolCredential,
10 TaskRecord,10 TaskRecord,
11 ToolDefinition,
11 ToolCallRecord,12 ToolCallRecord,
12 ToolCallRequest,13 ToolCallRequest,
13 ToolRunContext,14 ToolRunContext,
14} from "../../core/src/types";15} from "../../core/src/types";
15import { describeError } from "../../core/src/utils";16import { describeError } from "../../core/src/utils";
16import { asPayload } from "../lib/object";17import { asPayload } from "../lib/object";
17import { getToolDecision, isToolAllowedForTask } from "./policy";18import { getToolDecision, getDecisionForContext } from "./policy";
18import { baseTools } from "./workspace_tools";19import { baseTools } from "./workspace_tools";
19import { publishTaskEvent } from "./events";20import { publishTaskEvent } from "./events";
20import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";21import { completeToolCall, denyToolCall, failToolCall } from "./tool_call_lifecycle";
21import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials";22import { assertPersistedCallRunnable, resolveCredentialHeaders } from "./tool_call_credentials";
22import { ensureWorkspaceTools, getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";23import { getGraphqlDecision, resolveToolForCall } from "./tool_call_resolution";
23 24
24function createApprovalId(): string {25function createApprovalId(): string {
25 return `approval_${crypto.randomUUID()}`;26 return `approval_${crypto.randomUUID()}`;
@@ -57,13 +58,151 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 31 unmodified lines
57 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });58 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });
58 const typedPolicies = policies as AccessPolicyRecord[];59 const typedPolicies = policies as AccessPolicyRecord[];
59 60
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);
61 201
62 let decision: PolicyDecision;202 let decision: PolicyDecision;
63 let effectiveToolPath = resolvedToolPath;203 let effectiveToolPath = resolvedToolPath;
64 if (tool._graphqlSource) {204 if (tool._graphqlSource) {
65 workspaceTools = await ensureWorkspaceTools(ctx, task, workspaceTools);205 const result = getGraphqlDecision(task, tool, input, undefined, typedPolicies);
66 const result = getGraphqlDecision(task, tool, input, workspaceTools, typedPolicies);
67 decision = result.decision;206 decision = result.decision;
68 if (result.effectivePaths.length > 0) {207 if (result.effectivePaths.length > 0) {
69 effectiveToolPath = result.effectivePaths.join(", ");208 effectiveToolPath = result.effectivePaths.join(", ");
@@ -179,7 +318,8 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 109 unmodified lines
179 actorId: task.actorId,318 actorId: task.actorId,
180 clientId: task.clientId,319 clientId: task.clientId,
181 credential,320 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,
183 };323 };
184 const value = await tool.run(input, context);324 const value = await tool.run(input, context);
185 await completeToolCall(ctx, {325 await completeToolCall(ctx, {
executor/packages/convex/runtime/tool_paths.ts+7−7
@@ -46,7 +46,7 @@ function normalizeToolPathSegment(segment: string, isNamespace = false): string · 45 unmodified lines
46 return collapsed.join("");46 return collapsed.join("");
47}47}
48 48
49function normalizeToolPath(path: string): string {49export function normalizeToolPathForLookup(path: string): string {
50 const segments = path50 const segments = path
51 .split(".")51 .split(".")
52 .filter(Boolean);52 .filter(Boolean);
@@ -110,12 +110,12 @@ export function resolveAliasedToolPath( · 57 unmodified lines
110): string | null {110): string | null {
111 if (toolMap.has(requestedPath)) return requestedPath;111 if (toolMap.has(requestedPath)) return requestedPath;
112 112
113 const normalizedRequested = normalizeToolPath(requestedPath);113 const normalizedRequested = normalizeToolPathForLookup(requestedPath);
114 if (!normalizedRequested) return null;114 if (!normalizedRequested) return null;
115 115
116 const matches: string[] = [];116 const matches: string[] = [];
117 for (const path of toolMap.keys()) {117 for (const path of toolMap.keys()) {
118 if (normalizeToolPath(path) === normalizedRequested) {118 if (normalizeToolPathForLookup(path) === normalizedRequested) {
119 matches.push(path);119 matches.push(path);
120 }120 }
121 }121 }
@@ -134,13 +134,13 @@ export function suggestToolPaths( · 12 unmodified lines
134 toolMap: Map<string, ToolDefinition>,134 toolMap: Map<string, ToolDefinition>,
135 limit = 3,135 limit = 3,
136): string[] {136): string[] {
137 const normalizedRequested = normalizeToolPath(requestedPath);137 const normalizedRequested = normalizeToolPathForLookup(requestedPath);
138 const requestedSegments = normalizedRequested.split(".").filter(Boolean);138 const requestedSegments = normalizedRequested.split(".").filter(Boolean);
139 const requestedNamespace = requestedSegments[0] ?? "";139 const requestedNamespace = requestedSegments[0] ?? "";
140 140
141 return [...toolMap.keys()]141 return [...toolMap.keys()]
142 .map((path) => {142 .map((path) => {
143 const normalizedCandidate = normalizeToolPath(path);143 const normalizedCandidate = normalizeToolPathForLookup(path);
144 const candidateSegments = normalizedCandidate.split(".").filter(Boolean);144 const candidateSegments = normalizedCandidate.split(".").filter(Boolean);
145 const candidateNamespace = candidateSegments[0] ?? "";145 const candidateNamespace = candidateSegments[0] ?? "";
146 146
@@ -173,7 +173,7 @@ export function resolveClosestToolPath( · 26 unmodified lines
173 requestedPath: string,173 requestedPath: string,
174 toolMap: Map<string, ToolDefinition>,174 toolMap: Map<string, ToolDefinition>,
175): string | null {175): string | null {
176 const normalizedRequested = normalizeToolPath(requestedPath);176 const normalizedRequested = normalizeToolPathForLookup(requestedPath);
177 if (!normalizedRequested) return null;177 if (!normalizedRequested) return null;
178 178
179 const requestedNamespace = normalizedRequested.split(".").filter(Boolean)[0] ?? "";179 const requestedNamespace = normalizedRequested.split(".").filter(Boolean)[0] ?? "";
@@ -182,7 +182,7 @@ export function resolveClosestToolPath( · 2 unmodified lines
182 182
183 const ranked = [...toolMap.keys()]183 const ranked = [...toolMap.keys()]
184 .map((path) => {184 .map((path) => {
185 const normalizedCandidate = normalizeToolPath(path);185 const normalizedCandidate = normalizeToolPathForLookup(path);
186 const candidateNamespace = normalizedCandidate.split(".").filter(Boolean)[0] ?? "";186 const candidateNamespace = normalizedCandidate.split(".").filter(Boolean)[0] ?? "";
187 const distance = levenshteinDistance(normalizedRequested, normalizedCandidate);187 const distance = levenshteinDistance(normalizedRequested, normalizedCandidate);
188 let score = -distance;188 let score = -distance;
executor/packages/convex/runtime/workspace_tools.ts+309−12
@@ -3,14 +3,16 @@ · 2 unmodified lines
3import type { ActionCtx } from "../_generated/server";3import type { ActionCtx } from "../_generated/server";
4import { internal } from "../_generated/api";4import { internal } from "../_generated/api";
5import type { Id } from "../_generated/dataModel.d.ts";5import type { Id } from "../_generated/dataModel.d.ts";
6import { createCatalogTools, createDiscoverTool } from "../../core/src/tool-discovery";
7import { buildWorkspaceTypeBundle } from "../../core/src/tool-typing/typebundle";6import { buildWorkspaceTypeBundle } from "../../core/src/tool-typing/typebundle";
7import { jsonSchemaTypeHintFallback } from "../../core/src/openapi/schema-hints";
8import { buildPreviewKeys, extractTopLevelRequiredKeys } from "../../core/src/tool-typing/schema-utils";
8import {9import {
9 materializeCompiledToolSource,10 materializeCompiledToolSource,
10 materializeWorkspaceSnapshot,11 materializeWorkspaceSnapshot,
11 type CompiledToolSourceArtifact,12 type CompiledToolSourceArtifact,
12 type WorkspaceToolSnapshot,13 type WorkspaceToolSnapshot,
13} from "../../core/src/tool-sources";14} from "../../core/src/tool-sources";
15import { serializeTools } from "../../core/src/tool/source-serialization";
14import type { ExternalToolSourceConfig } from "../../core/src/tool/source-types";16import type { ExternalToolSourceConfig } from "../../core/src/tool/source-types";
15import type {17import type {
16 AccessPolicyRecord,18 AccessPolicyRecord,
@@ -21,6 +23,7 @@ import type { · 4 unmodified lines
21} from "../../core/src/types";23} from "../../core/src/types";
22import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors";24import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_descriptors";
23import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";25import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";
26import { normalizeToolPathForLookup } from "./tool_paths";
24 27
25const baseTools = new Map<string, ToolDefinition>();28const baseTools = new Map<string, ToolDefinition>();
26 29
@@ -88,6 +91,91 @@ baseTools.set("admin.delete_data", { · 61 unmodified lines
88 },91 },
89});92});
90 93
94// System tools (discover/catalog) are resolved server-side.
95// Their execution is handled in the Convex tool invocation pipeline.
96baseTools.set("discover", {
97 path: "discover",
98 source: "system",
99 approval: "auto",
100 description:
101 "Search available tools by keyword. Returns preferred path aliases, signature hints, and ready-to-copy call examples. Compact mode is enabled by default.",
102 typing: {
103 inputSchema: {
104 type: "object",
105 properties: {
106 query: { type: "string" },
107 depth: { type: "number" },
108 limit: { type: "number" },
109 compact: { type: "boolean" },
110 },
111 required: ["query"],
112 },
113 outputSchema: {
114 type: "object",
115 properties: {
116 bestPath: {},
117 results: { type: "array" },
118 total: { type: "number" },
119 },
120 required: ["bestPath", "results", "total"],
121 },
122 },
123 run: async () => {
124 throw new Error("discover is handled by the server tool invocation pipeline");
125 },
126});
127
128baseTools.set("catalog.namespaces", {
129 path: "catalog.namespaces",
130 source: "system",
131 approval: "auto",
132 description: "List available tool namespaces with counts and sample callable paths.",
133 typing: {
134 inputSchema: { type: "object", properties: {} },
135 outputSchema: {
136 type: "object",
137 properties: {
138 namespaces: { type: "array" },
139 total: { type: "number" },
140 },
141 required: ["namespaces", "total"],
142 },
143 },
144 run: async () => {
145 throw new Error("catalog.namespaces is handled by the server tool invocation pipeline");
146 },
147});
148
149baseTools.set("catalog.tools", {
150 path: "catalog.tools",
151 source: "system",
152 approval: "auto",
153 description: "List tools with typed signatures. Supports namespace and query filters in one call.",
154 typing: {
155 inputSchema: {
156 type: "object",
157 properties: {
158 namespace: { type: "string" },
159 query: { type: "string" },
160 depth: { type: "number" },
161 limit: { type: "number" },
162 compact: { type: "boolean" },
163 },
164 },
165 outputSchema: {
166 type: "object",
167 properties: {
168 results: { type: "array" },
169 total: { type: "number" },
170 },
171 required: ["results", "total"],
172 },
173 },
174 run: async () => {
175 throw new Error("catalog.tools is handled by the server tool invocation pipeline");
176 },
177});
178
91interface WorkspaceToolsResult {179interface WorkspaceToolsResult {
92 tools: Map<string, ToolDefinition>;180 tools: Map<string, ToolDefinition>;
93 warnings: string[];181 warnings: string[];
@@ -176,27 +264,227 @@ function computeSourceAuthProfiles(tools: Map<string, ToolDefinition>): Record<s · 82 unmodified lines
176 return profiles;264 return profiles;
177}265}
178 266
179function mergeToolsWithCatalog(externalTools: Iterable<ToolDefinition>): Map<string, ToolDefinition> {267function mergeTools(externalTools: Iterable<ToolDefinition>): Map<string, ToolDefinition> {
180 const merged = new Map<string, ToolDefinition>();268 const merged = new Map<string, ToolDefinition>();
181 269
182 for (const tool of baseTools.values()) {270 for (const tool of baseTools.values()) {
183 if (tool.path === "discover") continue;
184 merged.set(tool.path, tool);271 merged.set(tool.path, tool);
185 }272 }
186 273
187 for (const tool of externalTools) {274 for (const tool of externalTools) {
188 if (tool.path === "discover") continue;
189 merged.set(tool.path, tool);275 merged.set(tool.path, tool);
190 }276 }
277 return merged;
278}
191 279
192 const catalogTools = createCatalogTools([...merged.values()]);280function tokenizePathSegment(value: string): string[] {
193 for (const tool of catalogTools) {281 const normalized = value
194 merged.set(tool.path, tool);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
292const GENERIC_NAMESPACE_SUFFIXES = new Set([
293 "api",
294 "apis",
295 "openapi",
296 "sdk",
297 "service",
298 "services",
299]);
300
301function simplifyNamespaceSegment(segment: string): string {
302 const tokens = tokenizePathSegment(segment);
303 if (tokens.length === 0) return segment;
304
305 const collapsed: string[] = [];
306 for (const token of tokens) {
307 if (collapsed[collapsed.length - 1] === token) continue;
308 collapsed.push(token);
195 }309 }
196 310
197 const discover = createDiscoverTool([...merged.values()]);311 while (collapsed.length > 1) {
198 merged.set(discover.path, discover);312 const last = collapsed[collapsed.length - 1];
199 return merged;313 if (!last || !GENERIC_NAMESPACE_SUFFIXES.has(last)) break;
314 collapsed.pop();
315 }
316
317 return collapsed.join("_");
318}
319
320function 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
332function toCamelSegment(segment: string): string {
333 return segment.replace(/_+([a-z0-9])/g, (_m, char: string) => char.toUpperCase());
334}
335
336function 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
358function normalizeHint(type?: string): string {
359 return type && type.trim().length > 0 ? type : "unknown";
360}
361
362function 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
369async 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 };
200}488}
201 489
202export async function getWorkspaceTools(490export async function getWorkspaceTools(
@@ -251,7 +539,7 @@ export async function getWorkspaceTools( · 48 unmodified lines
251 if (blob) {539 if (blob) {
252 const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot;540 const snapshot = JSON.parse(await blob.text()) as WorkspaceToolSnapshot;
253 const restored = materializeWorkspaceSnapshot(snapshot);541 const restored = materializeWorkspaceSnapshot(snapshot);
254 const merged = mergeToolsWithCatalog(restored);542 const merged = mergeTools(restored);
255 traceStep("cacheHydrate", cacheHydrateStartedAt);543 traceStep("cacheHydrate", cacheHydrateStartedAt);
256 544
257 const typesStorageId = cacheEntry.typesStorageId as Id<"_storage"> | undefined;545 const typesStorageId = cacheEntry.typesStorageId as Id<"_storage"> | undefined;
@@ -362,7 +650,7 @@ export async function getWorkspaceTools( · 104 unmodified lines
362 const timedOutSources = loadedSources650 const timedOutSources = loadedSources
363 .filter((loaded) => loaded.timedOut)651 .filter((loaded) => loaded.timedOut)
364 .map((loaded) => loaded.sourceName);652 .map((loaded) => loaded.sourceName);
365 const merged = mergeToolsWithCatalog(externalTools);653 const merged = mergeTools(externalTools);
366 654
367 let typesStorageId: Id<"_storage"> | undefined;655 let typesStorageId: Id<"_storage"> | undefined;
368 try {656 try {
@@ -387,6 +675,15 @@ export async function getWorkspaceTools( · 18 unmodified lines
387 const snapshotWriteStartedAt = Date.now();675 const snapshotWriteStartedAt = Date.now();
388 const allTools = [...merged.values()];676 const allTools = [...merged.values()];
389 677
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
390 // Build and store a workspace-wide Monaco type bundle.687 // Build and store a workspace-wide Monaco type bundle.
391 const openApiDtsBySource: Record<string, string> = {};688 const openApiDtsBySource: Record<string, string> = {};
392 for (const loaded of loadedSources) {689 for (const loaded of loadedSources) {
executor/packages/convex/schema.ts+11−6
@@ -444,6 +444,8 @@ export default defineSchema({ · 443 unmodified lines
444 buildId: v.string(),444 buildId: v.string(),
445 path: v.string(),445 path: v.string(),
446 preferredPath: v.string(),446 preferredPath: v.string(),
447 namespace: v.string(),
448 normalizedPath: v.string(),
447 aliases: v.array(v.string()),449 aliases: v.array(v.string()),
448 description: v.string(),450 description: v.string(),
449 approval: toolApprovalMode,451 approval: toolApprovalMode,
@@ -463,22 +465,25 @@ export default defineSchema({ · 13 unmodified lines
463 createdAt: v.number(),465 createdAt: v.number(),
464 })466 })
465 .index("by_workspace_build_path", ["workspaceId", "buildId", "path"])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"])
466 .index("by_workspace_build", ["workspaceId", "buildId"])470 .index("by_workspace_build", ["workspaceId", "buildId"])
467 .searchIndex("search_text", {471 .searchIndex("search_text", {
468 searchField: "searchText",472 searchField: "searchText",
469 filterFields: ["workspaceId", "buildId"],473 filterFields: ["workspaceId", "buildId"],
470 }),474 }),
471 475
472 // Alias -> canonical path mapping for registry lookups.476 // Precomputed namespace summaries for fast catalog.namespaces.
473 workspaceToolAliases: defineTable({477 workspaceToolNamespaces: defineTable({
474 workspaceId: v.id("workspaces"),478 workspaceId: v.id("workspaces"),
475 buildId: v.string(),479 buildId: v.string(),
476 alias: v.string(),480 namespace: v.string(),
477 path: v.string(),481 toolCount: v.number(),
482 samplePaths: v.array(v.string()),
478 createdAt: v.number(),483 createdAt: v.number(),
479 })484 })
480 .index("by_workspace_build_alias", ["workspaceId", "buildId", "alias"])485 .index("by_workspace_build", ["workspaceId", "buildId"])
481 .index("by_workspace_build", ["workspaceId", "buildId"]),486 .index("by_workspace_build_namespace", ["workspaceId", "buildId", "namespace"]),
482 487
483 // Anonymous session linkage.488 // Anonymous session linkage.
484 // Used to map an unauthenticated/anonymous actor to a backing `accounts` row and a489 // 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 @@
1import { v } from "convex/values";
2import { internalMutation, internalQuery } from "./_generated/server";
3
4export const getState = internalQuery({
5 args: {
6 workspaceId: v.id("workspaces"),
7 },
8 handler: async (ctx, args) => {
9 const entry = await ctx.db
10 .query("workspaceToolRegistryState")
11 .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
12 .unique();
13
14 if (!entry) return null;
15
16 return {
17 signature: entry.signature,
18 readyBuildId: entry.readyBuildId,
19 buildingBuildId: entry.buildingBuildId,
20 updatedAt: entry.updatedAt,
21 };
22 },
23});
24
25export const beginBuild = internalMutation({
26 args: {
27 workspaceId: v.id("workspaces"),
28 signature: v.string(),
29 buildId: v.string(),
30 },
31 handler: async (ctx, args) => {
32 const now = Date.now();
33 const existing = await ctx.db
34 .query("workspaceToolRegistryState")
35 .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
36 .unique();
37
38 if (existing) {
39 await ctx.db.patch(existing._id, {
40 signature: args.signature,
41 buildingBuildId: args.buildId,
42 updatedAt: now,
43 });
44 return;
45 }
46
47 await ctx.db.insert("workspaceToolRegistryState", {
48 workspaceId: args.workspaceId,
49 signature: args.signature,
50 readyBuildId: undefined,
51 buildingBuildId: args.buildId,
52 createdAt: now,
53 updatedAt: now,
54 });
55 },
56});
57
58export const putToolsBatch = internalMutation({
59 args: {
60 workspaceId: v.id("workspaces"),
61 buildId: v.string(),
62 tools: v.array(
63 v.object({
64 path: v.string(),
65 preferredPath: v.string(),
66 namespace: v.string(),
67 normalizedPath: v.string(),
68 aliases: v.array(v.string()),
69 description: v.string(),
70 approval: v.union(v.literal("auto"), v.literal("required")),
71 source: v.optional(v.string()),
72 searchText: v.string(),
73 displayInput: v.optional(v.string()),
74 displayOutput: v.optional(v.string()),
75 requiredInputKeys: v.optional(v.array(v.string())),
76 previewInputKeys: v.optional(v.array(v.string())),
77 typedRef: v.optional(
78 v.object({
79 kind: v.literal("openapi_operation"),
80 sourceKey: v.string(),
81 operationId: v.string(),
82 }),
83 ),
84 serializedToolJson: v.string(),
85 }),
86 ),
87 },
88 handler: async (ctx, args) => {
89 const now = Date.now();
90 for (const tool of args.tools) {
91 await ctx.db.insert("workspaceToolRegistry", {
92 workspaceId: args.workspaceId,
93 buildId: args.buildId,
94 path: tool.path,
95 preferredPath: tool.preferredPath,
96 namespace: tool.namespace,
97 normalizedPath: tool.normalizedPath,
98 aliases: tool.aliases,
99 description: tool.description,
100 approval: tool.approval,
101 source: tool.source,
102 searchText: tool.searchText,
103 displayInput: tool.displayInput,
104 displayOutput: tool.displayOutput,
105 requiredInputKeys: tool.requiredInputKeys,
106 previewInputKeys: tool.previewInputKeys,
107 typedRef: tool.typedRef,
108 serializedToolJson: tool.serializedToolJson,
109 createdAt: now,
110 });
111 }
112 },
113});
114
115export const putNamespacesBatch = internalMutation({
116 args: {
117 workspaceId: v.id("workspaces"),
118 buildId: v.string(),
119 namespaces: v.array(
120 v.object({
121 namespace: v.string(),
122 toolCount: v.number(),
123 samplePaths: v.array(v.string()),
124 }),
125 ),
126 },
127 handler: async (ctx, args) => {
128 const now = Date.now();
129 for (const ns of args.namespaces) {
130 await ctx.db.insert("workspaceToolNamespaces", {
131 workspaceId: args.workspaceId,
132 buildId: args.buildId,
133 namespace: ns.namespace,
134 toolCount: ns.toolCount,
135 samplePaths: ns.samplePaths,
136 createdAt: now,
137 });
138 }
139 },
140});
141
142export const finishBuild = internalMutation({
143 args: {
144 workspaceId: v.id("workspaces"),
145 buildId: v.string(),
146 },
147 handler: async (ctx, args) => {
148 const now = Date.now();
149 const state = await ctx.db
150 .query("workspaceToolRegistryState")
151 .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
152 .unique();
153
154 if (!state) {
155 await ctx.db.insert("workspaceToolRegistryState", {
156 workspaceId: args.workspaceId,
157 signature: "",
158 readyBuildId: args.buildId,
159 buildingBuildId: undefined,
160 createdAt: now,
161 updatedAt: now,
162 });
163 return;
164 }
165
166 if (state.buildingBuildId !== args.buildId) {
167 // Another build started; ignore finishing this one.
168 return;
169 }
170
171 await ctx.db.patch(state._id, {
172 readyBuildId: args.buildId,
173 buildingBuildId: undefined,
174 updatedAt: now,
175 });
176 },
177});
178
179export const getToolByPath = internalQuery({
180 args: {
181 workspaceId: v.id("workspaces"),
182 buildId: v.string(),
183 path: v.string(),
184 },
185 handler: async (ctx, args) => {
186 const entry = await ctx.db
187 .query("workspaceToolRegistry")
188 .withIndex("by_workspace_build_path", (q) =>
189 q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("path", args.path),
190 )
191 .first();
192
193 if (!entry) return null;
194
195 return {
196 path: entry.path,
197 preferredPath: entry.preferredPath,
198 approval: entry.approval,
199 namespace: entry.namespace,
200 aliases: entry.aliases,
201 description: entry.description,
202 source: entry.source,
203 displayInput: entry.displayInput,
204 displayOutput: entry.displayOutput,
205 requiredInputKeys: entry.requiredInputKeys,
206 previewInputKeys: entry.previewInputKeys,
207 serializedToolJson: entry.serializedToolJson,
208 };
209 },
210});
211
212export const listToolsByNamespace = internalQuery({
213 args: {
214 workspaceId: v.id("workspaces"),
215 buildId: v.string(),
216 namespace: v.string(),
217 limit: v.number(),
218 },
219 handler: async (ctx, args) => {
220 const namespace = args.namespace.trim().toLowerCase();
221 const limit = Math.max(1, Math.min(200, Math.floor(args.limit)));
222 if (!namespace) return [];
223
224 const entries = await ctx.db
225 .query("workspaceToolRegistry")
226 .withIndex("by_workspace_build_namespace", (q) =>
227 q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("namespace", namespace),
228 )
229 .take(limit);
230
231 return entries.map((entry) => ({
232 path: entry.path,
233 preferredPath: entry.preferredPath,
234 aliases: entry.aliases,
235 description: entry.description,
236 approval: entry.approval,
237 source: entry.source,
238 displayInput: entry.displayInput,
239 displayOutput: entry.displayOutput,
240 requiredInputKeys: entry.requiredInputKeys,
241 previewInputKeys: entry.previewInputKeys,
242 }));
243 },
244});
245
246export const getToolsByNormalizedPath = internalQuery({
247 args: {
248 workspaceId: v.id("workspaces"),
249 buildId: v.string(),
250 normalizedPath: v.string(),
251 limit: v.number(),
252 },
253 handler: async (ctx, args) => {
254 const normalized = args.normalizedPath.trim().toLowerCase();
255 if (!normalized) return [];
256 const limit = Math.max(1, Math.min(10, Math.floor(args.limit)));
257
258 const entries = await ctx.db
259 .query("workspaceToolRegistry")
260 .withIndex("by_workspace_build_normalized", (q) =>
261 q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId).eq("normalizedPath", normalized),
262 )
263 .take(limit);
264
265 return entries.map((entry) => ({
266 path: entry.path,
267 preferredPath: entry.preferredPath,
268 approval: entry.approval,
269 serializedToolJson: entry.serializedToolJson,
270 }));
271 },
272});
273
274export const searchTools = internalQuery({
275 args: {
276 workspaceId: v.id("workspaces"),
277 buildId: v.string(),
278 query: v.string(),
279 limit: v.number(),
280 },
281 handler: async (ctx, args) => {
282 const term = args.query.trim();
283 if (!term) return [];
284
285 const limit = Math.max(1, Math.min(50, Math.floor(args.limit)));
286 const hits = await ctx.db
287 .query("workspaceToolRegistry")
288 .withSearchIndex("search_text", (q) =>
289 q.search("searchText", term)
290 .eq("workspaceId", args.workspaceId)
291 .eq("buildId", args.buildId),
292 )
293 .take(limit);
294
295 return hits.map((entry) => ({
296 path: entry.path,
297 preferredPath: entry.preferredPath,
298 aliases: entry.aliases,
299 description: entry.description,
300 approval: entry.approval,
301 source: entry.source,
302 displayInput: entry.displayInput,
303 displayOutput: entry.displayOutput,
304 requiredInputKeys: entry.requiredInputKeys,
305 previewInputKeys: entry.previewInputKeys,
306 }));
307 },
308});
309
310export const listNamespaces = internalQuery({
311 args: {
312 workspaceId: v.id("workspaces"),
313 buildId: v.string(),
314 limit: v.number(),
315 },
316 handler: async (ctx, args) => {
317 const limit = Math.max(1, Math.min(200, Math.floor(args.limit)));
318 const entries = await ctx.db
319 .query("workspaceToolNamespaces")
320 .withIndex("by_workspace_build", (q) =>
321 q.eq("workspaceId", args.workspaceId).eq("buildId", args.buildId),
322 )
323 .take(limit);
324
325 return entries.map((entry) => ({
326 namespace: entry.namespace,
327 toolCount: entry.toolCount,
328 samplePaths: entry.samplePaths,
329 }));
330 },
331});
executor/packages/convex/workspace.ts+24−10
@@ -18,24 +18,38 @@ function redactCredential<T extends { secretJson: Record<string, unknown> }>(cre · 17 unmodified lines
18}18}
19 19
20export const bootstrapAnonymousSession = mutation({20export const bootstrapAnonymousSession = mutation({
21 args: { sessionId: v.optional(v.string()) },21 args: {
22 handler: async (ctx) => {22 sessionId: v.optional(v.string()),
23 actorId: v.optional(v.string()),
24 },
25 handler: async (ctx, args) => {
23 const identity = await ctx.auth.getUserIdentity();26 const identity = await ctx.auth.getUserIdentity();
24 if (!identity) {27 const requestedSessionId = args.sessionId?.trim();
25 throw new Error("Anonymous auth token is required");28 const requestedActorId = args.actorId?.trim();
26 }29 const isAnonymousIdentityRequest = Boolean(identity && isAnonymousIdentity(identity));
27 30
28 if (!isAnonymousIdentity(identity)) {31 if (!isAnonymousIdentityRequest && identity) {
29 throw new Error("Anonymous auth token is required");32 throw new Error("Anonymous auth token is required");
30 }33 }
31 34
32 const actorId = identity.subject;35 const actorId = isAnonymousIdentityRequest
33 if (!actorId.startsWith("anon_")) {36 ? identity!.subject
37 : requestedActorId;
38
39 if (actorId && !actorId.startsWith("anon_")) {
34 throw new Error("Anonymous token subject must use anon_* actor ids");40 throw new Error("Anonymous token subject must use anon_* actor ids");
35 }41 }
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
37 return await ctx.runMutation(internal.database.bootstrapAnonymousSession, {51 return await ctx.runMutation(internal.database.bootstrapAnonymousSession, {
38 sessionId: stableSessionId,52 sessionId,
39 actorId,53 actorId,
40 clientId: "web",54 clientId: "web",
41 });55 });
executor/scripts/dev/wipe/convex-db.ts+3−0
@@ -17,6 +17,9 @@ const tables = [ · 16 unmodified lines
17 "tasks",17 "tasks",
18 "openApiSpecCache",18 "openApiSpecCache",
19 "toolSources",19 "toolSources",
20 "workspaceToolRegistryState",
21 "workspaceToolRegistry",
22 "workspaceToolNamespaces",
20 "workspaceMembers",23 "workspaceMembers",
21 "workspaceToolCache",24 "workspaceToolCache",
22 "workspaces",25 "workspaces",