History

Improve run_code tool discovery ergonomics and self-healing

Add one-call catalog tools, prefer cleaner public tool paths, and auto-heal near-miss tool invocations so agents converge faster with fewer discovery loops.

Rhys Sullivan committed 9 months ago

Commit e66daeb1 parent 2ed5c869 files · +449 −29Browse files

Unified · Split

9 files
  1. executor/packages/convex/executor_node/tool_invocation.ts +17 −3
  2. executor/packages/convex/executor_node/tool_paths.test.ts +36 −0
  3. executor/packages/convex/executor_node/tool_paths.ts +118 −5
  4. executor/packages/convex/executor_node/workspace_tools.ts +10 −1
  5. executor/packages/core/src/mcp-server.ts +1 −1
  6. executor/packages/core/src/tool-discovery.test.ts +80 −1
  7. executor/packages/core/src/tool-discovery.ts +182 −15
  8. executor/packages/core/src/typechecker.test.ts +2 −1
  9. executor/packages/core/src/typechecker.ts +3 −2
executor/packages/convex/executor_node/tool_invocation.ts+17−3
@@ -19,7 +19,7 @@ import type { · 18 unmodified lines
19} from "../../core/src/types";19} from "../../core/src/types";
20import { asPayload, describeError } from "../../core/src/utils";20import { asPayload, describeError } from "../../core/src/utils";
21import { getDecisionForContext, getToolDecision, isToolAllowedForTask } from "./policy";21import { getDecisionForContext, getToolDecision, isToolAllowedForTask } from "./policy";
22import { resolveAliasedToolPath, suggestToolPaths } from "./tool_paths";22import { resolveAliasedToolPath, resolveClosestToolPath, suggestToolPaths, toPreferredToolPath } from "./tool_paths";
23import { baseTools, getWorkspaceTools } from "./workspace_tools";23import { baseTools, getWorkspaceTools } from "./workspace_tools";
24import { publishTaskEvent } from "./events";24import { publishTaskEvent } from "./events";
25 25
@@ -186,13 +186,27 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 160 unmodified lines
186 }186 }
187 }187 }
188 188
189 if (!tool) {
190 const availableTools = workspaceTools ?? baseTools;
191 const healedPath = resolveClosestToolPath(toolPath, availableTools);
192 if (healedPath) {
193 resolvedToolPath = healedPath;
194 tool = availableTools.get(healedPath);
195 }
196 }
197
189 if (!tool) {198 if (!tool) {
190 const availableTools = workspaceTools ?? baseTools;199 const availableTools = workspaceTools ?? baseTools;
191 const suggestions = suggestToolPaths(toolPath, availableTools);200 const suggestions = suggestToolPaths(toolPath, availableTools);
201 const queryHint = toolPath
202 .split(".")
203 .filter(Boolean)
204 .join(" ");
192 const suggestionText = suggestions.length > 0205 const suggestionText = suggestions.length > 0
193 ? `\nDid you mean: ${suggestions.map((path) => `tools.${path}`).join(", ")}`206 ? `\nDid you mean: ${suggestions.map((path) => `tools.${toPreferredToolPath(path)}`).join(", ")}`
194 : "";207 : "";
195 throw new Error(`Unknown tool: ${toolPath}${suggestionText}`);208 const discoverHint = `\nTry: const found = await tools.discover({ query: \"${queryHint}\", compact: false, depth: 2, limit: 12 });`;
209 throw new Error(`Unknown tool: ${toolPath}${suggestionText}${discoverHint}`);
196 }210 }
197 211
198 let decision: PolicyDecision;212 let decision: PolicyDecision;
executor/packages/convex/executor_node/tool_paths.test.ts+36−0
@@ -0,0 +1,36 @@
1import { expect, test } from "bun:test";
2import type { ToolDefinition } from "../../core/src/types";
3import { resolveAliasedToolPath, resolveClosestToolPath, toPreferredToolPath } from "./tool_paths";
4
5function makeTool(path: string): ToolDefinition {
6 return {
7 path,
8 description: path,
9 approval: "auto",
10 source: "test",
11 run: async () => null,
12 };
13}
14
15test("resolveAliasedToolPath matches simplified vendor namespace aliases", () => {
16 const tools = new Map<string, ToolDefinition>([
17 ["vercel_vercel_api.domains.get_domain", makeTool("vercel_vercel_api.domains.get_domain")],
18 ]);
19
20 const resolved = resolveAliasedToolPath("vercel.domains.get_domain", tools);
21 expect(resolved).toBe("vercel_vercel_api.domains.get_domain");
22});
23
24test("resolveClosestToolPath heals small typos safely", () => {
25 const tools = new Map<string, ToolDefinition>([
26 ["vercel_vercel_api.dns.update_record", makeTool("vercel_vercel_api.dns.update_record")],
27 ["vercel_vercel_api.dns.get_records", makeTool("vercel_vercel_api.dns.get_records")],
28 ]);
29
30 const healed = resolveClosestToolPath("vercel.dns.updte_record", tools);
31 expect(healed).toBe("vercel_vercel_api.dns.update_record");
32});
33
34test("toPreferredToolPath simplifies ugly source namespace", () => {
35 expect(toPreferredToolPath("vercel_vercel_api.dns.get_records")).toBe("vercel.dns.get_records");
36});
executor/packages/convex/executor_node/tool_paths.ts+118−5
@@ -2,18 +2,83 @@ · 1 unmodified line
2 2
3import type { ToolDefinition } from "../../core/src/types";3import type { ToolDefinition } from "../../core/src/types";
4 4
5function normalizeToolPathSegment(segment: string): string {5const GENERIC_NAMESPACE_SUFFIXES = new Set([
6 return segment.toLowerCase().replace(/[^a-z0-9]/g, "");6 "api",
7 "apis",
8 "openapi",
9 "sdk",
10 "service",
11 "services",
12]);
13
14function tokenizePathSegment(segment: string): string[] {
15 const normalized = segment
16 .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
17 .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
18 .toLowerCase();
19
20 return normalized
21 .split(/[^a-z0-9]+/)
22 .map((token) => token.trim())
23 .filter((token) => token.length > 0);
24}
25
26function normalizeToolPathSegment(segment: string, isNamespace = false): string {
27 const tokens = tokenizePathSegment(segment);
28 if (tokens.length === 0) {
29 return segment.toLowerCase().replace(/[^a-z0-9]/g, "");
30 }
31
32 const collapsed: string[] = [];
33 for (const token of tokens) {
34 if (collapsed[collapsed.length - 1] === token) continue;
35 collapsed.push(token);
36 }
37
38 if (isNamespace) {
39 while (collapsed.length > 1) {
40 const last = collapsed[collapsed.length - 1];
41 if (!last || !GENERIC_NAMESPACE_SUFFIXES.has(last)) break;
42 collapsed.pop();
43 }
44 }
45
46 return collapsed.join("");
7}47}
8 48
9function normalizeToolPath(path: string): string {49function normalizeToolPath(path: string): string {
10 return path50 const segments = path
11 .split(".")51 .split(".")
12 .filter(Boolean)52 .filter(Boolean);
13 .map((segment) => normalizeToolPathSegment(segment))53
54 return segments
55 .map((segment, index) => normalizeToolPathSegment(segment, index === 0))
14 .join(".");56 .join(".");
15}57}
16 58
59export function toPreferredToolPath(path: string): string {
60 const segments = path
61 .split(".")
62 .filter(Boolean);
63 if (segments.length === 0) return path;
64
65 const namespaceTokens = tokenizePathSegment(segments[0]!);
66 const collapsedNamespace: string[] = [];
67 for (const token of namespaceTokens) {
68 if (collapsedNamespace[collapsedNamespace.length - 1] === token) continue;
69 collapsedNamespace.push(token);
70 }
71 while (collapsedNamespace.length > 1) {
72 const last = collapsedNamespace[collapsedNamespace.length - 1];
73 if (!last || !GENERIC_NAMESPACE_SUFFIXES.has(last)) break;
74 collapsedNamespace.pop();
75 }
76
77 const namespace = collapsedNamespace.join("_");
78 if (!namespace || namespace === segments[0]) return path;
79 return [namespace, ...segments.slice(1)].join(".");
80}
81
17function levenshteinDistance(a: string, b: string): number {82function levenshteinDistance(a: string, b: string): number {
18 if (a === b) return 0;83 if (a === b) return 0;
19 if (a.length === 0) return b.length;84 if (a.length === 0) return b.length;
@@ -103,3 +168,51 @@ export function suggestToolPaths( · 83 unmodified lines
103 .slice(0, limit)168 .slice(0, limit)
104 .map((item) => item.path);169 .map((item) => item.path);
105}170}
171
172export function resolveClosestToolPath(
173 requestedPath: string,
174 toolMap: Map<string, ToolDefinition>,
175): string | null {
176 const normalizedRequested = normalizeToolPath(requestedPath);
177 if (!normalizedRequested) return null;
178
179 const requestedNamespace = normalizedRequested.split(".").filter(Boolean)[0] ?? "";
180 const requestedLength = normalizedRequested.length;
181 const maxDistance = Math.max(2, Math.floor(requestedLength * 0.2));
182
183 const ranked = [...toolMap.keys()]
184 .map((path) => {
185 const normalizedCandidate = normalizeToolPath(path);
186 const candidateNamespace = normalizedCandidate.split(".").filter(Boolean)[0] ?? "";
187 const distance = levenshteinDistance(normalizedRequested, normalizedCandidate);
188 let score = -distance;
189
190 if (requestedNamespace && requestedNamespace === candidateNamespace) {
191 score += 8;
192 }
193
194 if (normalizedCandidate.includes(normalizedRequested) || normalizedRequested.includes(normalizedCandidate)) {
195 score += 4;
196 }
197
198 return {
199 path,
200 score,
201 distance,
202 candidateNamespace,
203 };
204 })
205 .sort((a, b) => b.score - a.score);
206
207 const best = ranked[0];
208 if (!best) return null;
209 if (best.distance > maxDistance) return null;
210 if (requestedNamespace && best.candidateNamespace !== requestedNamespace && best.distance > 1) return null;
211
212 const second = ranked[1];
213 if (second && best.score - second.score < 3) {
214 return null;
215 }
216
217 return best.path;
218}
executor/packages/convex/executor_node/workspace_tools.ts+10−1
@@ -3,7 +3,7 @@ · 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";5import type { Id } from "../_generated/dataModel";
6import { createDiscoverTool } from "../../core/src/tool-discovery";6import { createCatalogTools, createDiscoverTool } from "../../core/src/tool-discovery";
7import {7import {
8 materializeCompiledToolSource,8 materializeCompiledToolSource,
9 materializeWorkspaceSnapshot,9 materializeWorkspaceSnapshot,
@@ -101,6 +101,10 @@ export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspa · 91 unmodified lines
101 if (tool.path === "discover") continue;101 if (tool.path === "discover") continue;
102 merged.set(tool.path, tool);102 merged.set(tool.path, tool);
103 }103 }
104 const catalogTools = createCatalogTools([...merged.values()]);
105 for (const tool of catalogTools) {
106 merged.set(tool.path, tool);
107 }
104 const discover = createDiscoverTool([...merged.values()]);108 const discover = createDiscoverTool([...merged.values()]);
105 merged.set(discover.path, discover);109 merged.set(discover.path, discover);
106 110
@@ -141,6 +145,11 @@ export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspa · 34 unmodified lines
141 merged.set(tool.path, tool);145 merged.set(tool.path, tool);
142 }146 }
143 147
148 const catalogTools = createCatalogTools([...merged.values()]);
149 for (const tool of catalogTools) {
150 merged.set(tool.path, tool);
151 }
152
144 const discover = createDiscoverTool([...merged.values()]);153 const discover = createDiscoverTool([...merged.values()]);
145 merged.set(discover.path, discover);154 merged.set(discover.path, discover);
146 155
executor/packages/core/src/mcp-server.ts+1−1
@@ -235,7 +235,7 @@ function buildRunCodeDescription(tools?: ToolDescriptor[]): string { · 234 unmodified lines
235 ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}`235 ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}`
236 : "";236 : "";
237 const hasGraphqlTools = toolList.some((tool) => tool.path.endsWith(".graphql"));237 const hasGraphqlTools = toolList.some((tool) => tool.path.endsWith(".graphql"));
238 const discoverNote = "\n\nTip: use `tools.discover({ query, depth?, limit?, compact? })` first. It returns `{ bestPath, results, total }`; prefer `bestPath` when present, otherwise call `results[i].path` (or copy `results[i].exampleCall`). Compact mode is on by default (set `compact: false` for full signatures). Do not assign to `const tools = ...`; use a different variable name (e.g. `const discovered = ...`).";238 const discoverNote = "\n\nTooling tip: avoid repeated tiny discovery calls. Start with a single broad inventory pass via `tools.catalog.namespaces({})` and `tools.catalog.tools({ namespace?, query?, compact: false, depth: 2, limit: 20 })` to get typed signatures, then do at most one focused `tools.discover({ query, compact: false, depth: 2, limit: 12 })`. `discover` returns `{ bestPath, results, total }`; prefer `bestPath` when present, otherwise copy `results[i].exampleCall`. Do not assign to `const tools = ...`; use a different variable name (e.g. `const discovered = ...`).";
239 const executionNote = "\n\nExecution tip: for migration/ETL-style tasks, discover once, then run in small batches and `return` compact summaries (counts, IDs, and top-N samples) instead of full objects.";239 const executionNote = "\n\nExecution tip: for migration/ETL-style tasks, discover once, then run in small batches and `return` compact summaries (counts, IDs, and top-N samples) instead of full objects.";
240 const graphqlNote = hasGraphqlTools240 const graphqlNote = hasGraphqlTools
241 ? "\n\nGraphQL tip: prefer `source.query.*` / `source.mutation.*` helper paths when available; GraphQL tools return `{ data, errors }`."241 ? "\n\nGraphQL tip: prefer `source.query.*` / `source.mutation.*` helper paths when available; GraphQL tools return `{ data, errors }`."
executor/packages/core/src/tool-discovery.test.ts+80−1
@@ -1,6 +1,6 @@
1import { expect, test } from "bun:test";1import { expect, test } from "bun:test";
2import type { Id } from "../../convex/_generated/dataModel";2import type { Id } from "../../convex/_generated/dataModel";
3import { createDiscoverTool } from "./tool-discovery";3import { createCatalogTools, createDiscoverTool } from "./tool-discovery";
4import type { ToolDefinition } from "./types";4import type { ToolDefinition } from "./types";
5 5
6const TEST_WORKSPACE_ID = "w" as Id<"workspaces">;6const TEST_WORKSPACE_ID = "w" as Id<"workspaces">;
@@ -208,3 +208,82 @@ test("discover namespace hint suppresses cross-namespace bestPath", async () => · 201 unmodified lines
208 expect(result.bestPath).toBe("linear.query.teams");208 expect(result.bestPath).toBe("linear.query.teams");
209 expect(result.results.some((entry) => entry.path.startsWith("github."))).toBe(false);209 expect(result.results.some((entry) => entry.path.startsWith("github."))).toBe(false);
210});210});
211
212test("discover prefers simplified alias path for ugly namespaces", async () => {
213 const tool = createDiscoverTool([
214 {
215 path: "vercel_vercel_api.domains.get_domain",
216 description: "Get Vercel domain",
217 approval: "auto",
218 source: "openapi:vercel",
219 metadata: {
220 argsType: "{ domain: string; teamId?: string }",
221 returnsType: "{ domain: string }",
222 },
223 run: async () => ({ domain: "executor.sh" }),
224 } satisfies ToolDefinition,
225 ]);
226
227 const result = await tool.run(
228 { query: "vercel domain", depth: 2 },
229 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
230 ) as {
231 bestPath: string | null;
232 results: Array<{ path: string; aliases: string[]; exampleCall: string }>;
233 };
234
235 expect(result.bestPath).toBe("vercel.domains.get_domain");
236 expect(result.results[0]?.path).toBe("vercel.domains.get_domain");
237 expect(result.results[0]?.aliases).toContain("vercel.domains.get_domain");
238 expect(result.results[0]?.exampleCall).toContain("tools.vercel.domains.get_domain");
239});
240
241test("catalog tools list namespaces and typed signatures", async () => {
242 const [namespacesTool, toolsTool] = createCatalogTools([
243 {
244 path: "vercel_vercel_api.domains.get_domain",
245 description: "Get domain",
246 approval: "auto",
247 source: "openapi:vercel",
248 metadata: {
249 argsType: "{ domain: string; teamId?: string }",
250 returnsType: "{ domain: string }",
251 },
252 run: async () => ({ domain: "executor.sh" }),
253 } satisfies ToolDefinition,
254 {
255 path: "utils.get_time",
256 description: "Get time",
257 approval: "auto",
258 source: "local",
259 metadata: {
260 argsType: "{}",
261 returnsType: "{ iso: string; unix: number }",
262 },
263 run: async () => ({ iso: "", unix: 0 }),
264 } satisfies ToolDefinition,
265 ]);
266
267 const namespaces = await namespacesTool!.run(
268 {},
269 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
270 ) as { namespaces: Array<{ namespace: string; toolCount: number }>; total: number };
271
272 expect(namespaces.total).toBeGreaterThanOrEqual(2);
273 expect(namespaces.namespaces.some((item) => item.namespace === "vercel" && item.toolCount >= 1)).toBe(true);
274
275 const listed = await toolsTool!.run(
276 { namespace: "vercel", compact: false, depth: 2, limit: 10 },
277 { taskId: "t", workspaceId: TEST_WORKSPACE_ID, isToolAllowed: () => true },
278 ) as {
279 results: Array<{ path: string; aliases: string[]; signature: string; argsType: string; returnsType: string }>;
280 total: number;
281 };
282
283 expect(listed.total).toBe(1);
284 expect(listed.results[0]?.path).toBe("vercel.domains.get_domain");
285 expect(listed.results[0]?.aliases).toContain("vercel.domains.get_domain");
286 expect(listed.results[0]?.signature).toContain("domain: string");
287 expect(listed.results[0]?.argsType).toContain("domain: string");
288 expect(listed.results[0]?.returnsType).toContain("domain: string");
289});
executor/packages/core/src/tool-discovery.ts+182−15
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 9
10interface DiscoverIndexEntry {10interface DiscoverIndexEntry {
11 path: string;11 path: string;
12 preferredPath: string;
12 aliases: string[];13 aliases: string[];
13 description: string;14 description: string;
14 approval: ToolDefinition["approval"];15 approval: ToolDefinition["approval"];
@@ -36,6 +37,15 @@ const DISCOVER_STOP_WORDS = new Set([ · 21 unmodified lines
36 "with",37 "with",
37]);38]);
38 39
40const GENERIC_NAMESPACE_SUFFIXES = new Set([
41 "api",
42 "apis",
43 "openapi",
44 "sdk",
45 "service",
46 "services",
47]);
48
39function normalizeType(type?: string): string {49function normalizeType(type?: string): string {
40 return type && type.trim().length > 0 ? type : "unknown";50 return type && type.trim().length > 0 ? type : "unknown";
41}51}
@@ -44,6 +54,48 @@ function normalizeSearchToken(value: string): string { · 2 unmodified lines
44 return value.toLowerCase().replace(/[^a-z0-9]/g, "");54 return value.toLowerCase().replace(/[^a-z0-9]/g, "");
45}55}
46 56
57function tokenizePathSegment(value: string): string[] {
58 const normalized = value
59 .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
60 .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
61 .toLowerCase();
62 return normalized
63 .split(/[^a-z0-9]+/)
64 .map((token) => token.trim())
65 .filter((token) => token.length > 0);
66}
67
68function simplifyNamespaceSegment(segment: string): string {
69 const tokens = tokenizePathSegment(segment);
70 if (tokens.length === 0) return segment;
71
72 const collapsed: string[] = [];
73 for (const token of tokens) {
74 if (collapsed[collapsed.length - 1] === token) continue;
75 collapsed.push(token);
76 }
77
78 while (collapsed.length > 1) {
79 const last = collapsed[collapsed.length - 1];
80 if (!last || !GENERIC_NAMESPACE_SUFFIXES.has(last)) break;
81 collapsed.pop();
82 }
83
84 return collapsed.join("_");
85}
86
87function preferredToolPath(path: string): string {
88 const segments = path.split(".").filter(Boolean);
89 if (segments.length === 0) return path;
90
91 const simplifiedNamespace = simplifyNamespaceSegment(segments[0]!);
92 if (!simplifiedNamespace || simplifiedNamespace === segments[0]) {
93 return path;
94 }
95
96 return [simplifiedNamespace, ...segments.slice(1)].join(".");
97}
98
47function toCamelSegment(segment: string): string {99function toCamelSegment(segment: string): string {
48 return segment.replace(/_+([a-z0-9])/g, (_m, char: string) => char.toUpperCase());100 return segment.replace(/_+([a-z0-9])/g, (_m, char: string) => char.toUpperCase());
49}101}
@@ -52,25 +104,31 @@ function getPathAliases(path: string): string[] { · 2 unmodified lines
52 const segments = path.split(".").filter(Boolean);104 const segments = path.split(".").filter(Boolean);
53 if (segments.length === 0) return [];105 if (segments.length === 0) return [];
54 106
107 const canonicalPath = path;
108 const publicPath = preferredToolPath(path);
109
55 const aliases = new Set<string>();110 const aliases = new Set<string>();
56 const camelPath = segments.map(toCamelSegment).join(".");111 const publicSegments = publicPath.split(".").filter(Boolean);
57 const compactPath = segments.map((segment) => segment.replace(/[_-]/g, "")).join(".");112 const camelPath = publicSegments.map(toCamelSegment).join(".");
58 const lowerPath = path.toLowerCase();113 const compactPath = publicSegments.map((segment) => segment.replace(/[_-]/g, "")).join(".");
114 const lowerPath = publicPath.toLowerCase();
59 115
60 if (camelPath !== path) aliases.add(camelPath);116 if (publicPath !== canonicalPath) aliases.add(publicPath);
61 if (compactPath !== path) aliases.add(compactPath);117 if (camelPath !== publicPath) aliases.add(camelPath);
62 if (lowerPath !== path) aliases.add(lowerPath);118 if (compactPath !== publicPath) aliases.add(compactPath);
119 if (lowerPath !== publicPath) aliases.add(lowerPath);
63 120
64 return [...aliases].slice(0, 4);121 return [...aliases].slice(0, 4);
65}122}
66 123
67function buildExampleCall(entry: DiscoverIndexEntry): string {124function buildExampleCall(entry: DiscoverIndexEntry): string {
125 const callPath = entry.preferredPath;
68 if (entry.path.endsWith(".graphql")) {126 if (entry.path.endsWith(".graphql")) {
69 return `await tools.${entry.path}({ query: "query { __typename }", variables: {} });`;127 return `await tools.${callPath}({ query: "query { __typename }", variables: {} });`;
70 }128 }
71 129
72 if (entry.argsType === "{}") {130 if (entry.argsType === "{}") {
73 return `await tools.${entry.path}({});`;131 return `await tools.${callPath}({});`;
74 }132 }
75 133
76 const keys = entry.argPreviewKeys.length > 0 ? entry.argPreviewKeys : extractTopLevelTypeKeys(entry.argsType);134 const keys = entry.argPreviewKeys.length > 0 ? entry.argPreviewKeys : extractTopLevelTypeKeys(entry.argsType);
@@ -78,21 +136,23 @@ function buildExampleCall(entry: DiscoverIndexEntry): string { · 1 unmodified line
78 const argsSnippet = keys.slice(0, 5)136 const argsSnippet = keys.slice(0, 5)
79 .map((key) => `${key}: ${key.toLowerCase().includes("input") ? "{ /* ... */ }" : "..."}`)137 .map((key) => `${key}: ${key.toLowerCase().includes("input") ? "{ /* ... */ }" : "..."}`)
80 .join(", ");138 .join(", ");
81 return `await tools.${entry.path}({ ${argsSnippet} });`;139 return `await tools.${callPath}({ ${argsSnippet} });`;
82 }140 }
83 141
84 return `await tools.${entry.path}({ /* ... */ });`;142 return `await tools.${callPath}({ /* ... */ });`;
85}143}
86 144
87function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] {145function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] {
88 return tools146 return tools
89 .filter((tool) => tool.path !== "discover")147 .filter((tool) => tool.path !== "discover" && !tool.path.startsWith("catalog."))
90 .map((tool) => {148 .map((tool) => {
149 const preferredPath = preferredToolPath(tool.path);
91 const aliases = getPathAliases(tool.path);150 const aliases = getPathAliases(tool.path);
92 const searchText = `${tool.path} ${aliases.join(" ")} ${tool.description} ${tool.source ?? ""}`.toLowerCase();151 const searchText = `${tool.path} ${preferredPath} ${aliases.join(" ")} ${tool.description} ${tool.source ?? ""}`.toLowerCase();
93 152
94 return {153 return {
95 path: tool.path,154 path: tool.path,
155 preferredPath,
96 aliases,156 aliases,
97 description: tool.description,157 description: tool.description,
98 approval: tool.approval,158 approval: tool.approval,
@@ -161,7 +221,7 @@ function chooseBestPath( · 62 unmodified lines
161 return null;221 return null;
162 }222 }
163 223
164 return best.entry.path;224 return best.entry.preferredPath;
165}225}
166 226
167function scoreEntry(227function scoreEntry(
@@ -235,6 +295,113 @@ function formatSignature(entry: DiscoverIndexEntry, depth: number, compact: bool · 67 unmodified lines
235 return `(input: ${entry.argsType}): Promise<${entry.returnsType}> [source=${entry.source}]`;295 return `(input: ${entry.argsType}): Promise<${entry.returnsType}> [source=${entry.source}]`;
236}296}
237 297
298function listIndexForContext(index: DiscoverIndexEntry[], isToolAllowed: (toolPath: string) => boolean): DiscoverIndexEntry[] {
299 return index.filter((entry) => isToolAllowed(entry.path));
300}
301
302export function createCatalogTools(tools: ToolDefinition[]): ToolDefinition[] {
303 const index = buildIndex(tools);
304
305 const namespacesTool: ToolDefinition = {
306 path: "catalog.namespaces",
307 source: "system",
308 approval: "auto",
309 description: "List available tool namespaces with counts and sample callable paths.",
310 metadata: {
311 argsType: "{}",
312 returnsType: "{ namespaces: Array<{ namespace: string; toolCount: number; samplePaths: string[] }>; total: number }",
313 displayArgsType: "{}",
314 displayReturnsType: "{ namespaces: ...; total: number }",
315 },
316 run: async (_input: unknown, context) => {
317 const visible = listIndexForContext(index, context.isToolAllowed);
318 const grouped = new Map<string, string[]>();
319
320 for (const entry of visible) {
321 const namespace = entry.preferredPath.split(".")[0] ?? entry.path.split(".")[0] ?? "default";
322 const list = grouped.get(namespace) ?? [];
323 list.push(entry.preferredPath);
324 grouped.set(namespace, list);
325 }
326
327 const namespaces = [...grouped.entries()]
328 .sort((a, b) => a[0].localeCompare(b[0]))
329 .map(([namespace, paths]) => ({
330 namespace,
331 toolCount: paths.length,
332 samplePaths: [...paths].sort((a, b) => a.localeCompare(b)).slice(0, 3),
333 }));
334
335 return {
336 namespaces,
337 total: namespaces.length,
338 };
339 },
340 };
341
342 const toolsTool: ToolDefinition = {
343 path: "catalog.tools",
344 source: "system",
345 approval: "auto",
346 description: "List tools with typed signatures. Supports namespace and query filters in one call.",
347 metadata: {
348 argsType: "{ namespace?: string; query?: string; depth?: number; limit?: number; compact?: boolean }",
349 returnsType:
350 "{ results: Array<{ path: string; aliases: string[]; source: string; approval: 'auto' | 'required'; description: string; argsType: string; returnsType: string; signature: string; exampleCall: string }>; total: number }",
351 displayArgsType: "{ namespace?: string; query?: string; depth?: number; limit?: number; compact?: boolean }",
352 displayReturnsType: "{ results: ...; total: number }",
353 },
354 run: async (input: unknown, context) => {
355 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
356 const namespaceFilter = String(payload.namespace ?? "").trim().toLowerCase();
357 const query = String(payload.query ?? "").trim().toLowerCase();
358 const depth = Math.max(0, Math.min(2, Number(payload.depth ?? 1)));
359 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));
360 const compact = payload.compact === false ? false : true;
361 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];
362
363 const visible = listIndexForContext(index, context.isToolAllowed);
364 const namespaceScoped = namespaceFilter.length > 0
365 ? visible.filter((entry) => {
366 const namespace = entry.preferredPath.split(".")[0]?.toLowerCase() ?? "";
367 const canonicalNamespace = entry.path.split(".")[0]?.toLowerCase() ?? "";
368 return namespace === namespaceFilter || canonicalNamespace === namespaceFilter;
369 })
370 : visible;
371
372 const ranked = (terms.length > 0
373 ? namespaceScoped
374 .map((entry) => ({ entry, score: scoreEntry(entry, terms, new Set<string>(), "") }))
375 .filter((item) => item.score > 0)
376 .sort((a, b) => b.score - a.score)
377 .map((item) => item.entry)
378 : [...namespaceScoped].sort((a, b) => a.preferredPath.localeCompare(b.preferredPath)))
379 .slice(0, limit);
380
381 const results = ranked.map((entry) => ({
382 path: entry.preferredPath,
383 aliases: entry.aliases,
384 source: entry.source,
385 approval: entry.approval,
386 description: compact ? compactDescriptionLine(entry.description) : entry.description,
387 argsType: compact
388 ? (entry.argPreviewKeys.length > 0 ? compactArgKeysHint(entry.argPreviewKeys) : compactArgTypeHint(entry.argsType))
389 : entry.argsType,
390 returnsType: compact ? compactReturnTypeHint(entry.returnsType) : entry.returnsType,
391 signature: formatSignature(entry, depth, compact),
392 exampleCall: buildExampleCall(entry),
393 }));
394
395 return {
396 results,
397 total: results.length,
398 };
399 },
400 };
401
402 return [namespacesTool, toolsTool];
403}
404
238export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition {405export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition {
239 const index = buildIndex(tools);406 const index = buildIndex(tools);
240 407
@@ -243,7 +410,7 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 2 unmodified lines
243 source: "system",410 source: "system",
244 approval: "auto",411 approval: "auto",
245 description:412 description:
246 "Search available tools by keyword. Returns canonical path, aliases, signature hints, and ready-to-copy call examples. Compact mode is enabled by default.",413 "Search available tools by keyword. Returns preferred path aliases, signature hints, and ready-to-copy call examples. Compact mode is enabled by default.",
247 metadata: {414 metadata: {
248 argsType: "{ query: string; depth?: number; limit?: number; compact?: boolean }",415 argsType: "{ query: string; depth?: number; limit?: number; compact?: boolean }",
249 returnsType:416 returnsType:
@@ -274,7 +441,7 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 24 unmodified lines
274 441
275 const results = ranked442 const results = ranked
276 .map(({ entry }) => ({443 .map(({ entry }) => ({
277 path: entry.path,444 path: entry.preferredPath,
278 aliases: entry.aliases,445 aliases: entry.aliases,
279 source: entry.source,446 source: entry.source,
280 approval: entry.approval,447 approval: entry.approval,
executor/packages/core/src/typechecker.test.ts+2−1
@@ -231,7 +231,8 @@ describe("generateToolInventory", () => { · 230 unmodified lines
231 expect(result).toContain("You have access to these tool namespaces:");231 expect(result).toContain("You have access to these tool namespaces:");
232 expect(result).toContain("admin (1)");232 expect(result).toContain("admin (1)");
233 expect(result).toContain("math (1)");233 expect(result).toContain("math (1)");
234 expect(result).toContain("Use `tools.discover({ query, depth?, limit?, compact? })` first");234 expect(result).toContain("tools.catalog.namespaces({})");
235 expect(result).toContain("tools.discover({ query, depth?, limit?, compact? })");
235 expect(result).toContain("Never shadow the global `tools` object");236 expect(result).toContain("Never shadow the global `tools` object");
236 });237 });
237 238
executor/packages/core/src/typechecker.ts+3−2
@@ -475,8 +475,9 @@ export function generateToolInventory(tools: ToolDescriptor[]): string { · 474 unmodified lines
475 "You have access to these tool namespaces:",475 "You have access to these tool namespaces:",
476 ` ${namespaces.join(", ")}`,476 ` ${namespaces.join(", ")}`,
477 "",477 "",
478 "Use `tools.discover({ query, depth?, limit?, compact? })` first. It returns `{ bestPath, results, total }` (not an array).",478 "Prefer one broad lookup over many small ones: use `tools.catalog.namespaces({})` and `tools.catalog.tools({ namespace?, query?, compact: false, depth: 2, limit: 20 })` first.",
479 "Prefer `bestPath` when present, otherwise use a `results[i].path`; use `exampleCall` for invocation shape.",479 "Then use `tools.discover({ query, depth?, limit?, compact? })` when you need ranking. It returns `{ bestPath, results, total }` (not an array).",
480 "Prefer `bestPath` when present, otherwise copy a `results[i].exampleCall` for invocation shape.",
480 "For migration/ETL tasks: discover once, then execute in small batches and return compact summaries (counts, IDs, top-N samples).",481 "For migration/ETL tasks: discover once, then execute in small batches and return compact summaries (counts, IDs, top-N samples).",
481 "Never shadow the global `tools` object (do NOT write `const tools = ...`).",482 "Never shadow the global `tools` object (do NOT write `const tools = ...`).",
482 "Then call tools directly using the returned path.",483 "Then call tools directly using the returned path.",