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
1919 } from "../../core/src/types";
2020 import { asPayload, describeError } from "../../core/src/utils";
2121 import { getDecisionForContext, getToolDecision, isToolAllowedForTask } from "./policy";
22−import { resolveAliasedToolPath, suggestToolPaths } from "./tool_paths";
22+import { resolveAliasedToolPath, resolveClosestToolPath, suggestToolPaths, toPreferredToolPath } from "./tool_paths";
2323 import { baseTools, getWorkspaceTools } from "./workspace_tools";
2424 import { publishTaskEvent } from "./events";
2525
@@ -186,13 +186,27 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 160 unmodified lines
186186 }
187187 }
188188
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+
189198 if (!tool) {
190199 const availableTools = workspaceTools ?? baseTools;
191200 const suggestions = suggestToolPaths(toolPath, availableTools);
201+ const queryHint = toolPath
202+ .split(".")
203+ .filter(Boolean)
204+ .join(" ");
192205 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(", ")}`
194207 : "";
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}`);
196210 }
197211
198212 let decision: PolicyDecision;
executor/packages/convex/executor_node/tool_paths.test.ts+36−0
@@ -0,0 +1,36 @@
1+import { expect, test } from "bun:test";
2+import type { ToolDefinition } from "../../core/src/types";
3+import { resolveAliasedToolPath, resolveClosestToolPath, toPreferredToolPath } from "./tool_paths";
4+
5+function makeTool(path: string): ToolDefinition {
6+ return {
7+ path,
8+ description: path,
9+ approval: "auto",
10+ source: "test",
11+ run: async () => null,
12+ };
13+}
14+
15+test("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+
24+test("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+
34+test("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
22
33 import type { ToolDefinition } from "../../core/src/types";
44
5−function normalizeToolPathSegment(segment: string): string {
6− return segment.toLowerCase().replace(/[^a-z0-9]/g, "");
5+const GENERIC_NAMESPACE_SUFFIXES = new Set([
6+ "api",
7+ "apis",
8+ "openapi",
9+ "sdk",
10+ "service",
11+ "services",
12+]);
13+
14+function 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+
26+function 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("");
747 }
848
949 function normalizeToolPath(path: string): string {
10− return path
50+ const segments = path
1151 .split(".")
12− .filter(Boolean)
13− .map((segment) => normalizeToolPathSegment(segment))
52+ .filter(Boolean);
53+
54+ return segments
55+ .map((segment, index) => normalizeToolPathSegment(segment, index === 0))
1456 .join(".");
1557 }
1658
59+export 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+
1782 function levenshteinDistance(a: string, b: string): number {
1883 if (a === b) return 0;
1984 if (a.length === 0) return b.length;
@@ -103,3 +168,51 @@ export function suggestToolPaths( · 83 unmodified lines
103168 .slice(0, limit)
104169 .map((item) => item.path);
105170 }
171+
172+export 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
33 import type { ActionCtx } from "../_generated/server";
44 import { internal } from "../_generated/api";
55 import type { Id } from "../_generated/dataModel";
6−import { createDiscoverTool } from "../../core/src/tool-discovery";
6+import { createCatalogTools, createDiscoverTool } from "../../core/src/tool-discovery";
77 import {
88 materializeCompiledToolSource,
99 materializeWorkspaceSnapshot,
@@ -101,6 +101,10 @@ export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspa · 91 unmodified lines
101101 if (tool.path === "discover") continue;
102102 merged.set(tool.path, tool);
103103 }
104+ const catalogTools = createCatalogTools([...merged.values()]);
105+ for (const tool of catalogTools) {
106+ merged.set(tool.path, tool);
107+ }
104108 const discover = createDiscoverTool([...merged.values()]);
105109 merged.set(discover.path, discover);
106110
@@ -141,6 +145,11 @@ export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspa · 34 unmodified lines
141145 merged.set(tool.path, tool);
142146 }
143147
148+ const catalogTools = createCatalogTools([...merged.values()]);
149+ for (const tool of catalogTools) {
150+ merged.set(tool.path, tool);
151+ }
152+
144153 const discover = createDiscoverTool([...merged.values()]);
145154 merged.set(discover.path, discover);
146155
executor/packages/core/src/mcp-server.ts+1−1
@@ -235,7 +235,7 @@ function buildRunCodeDescription(tools?: ToolDescriptor[]): string { · 234 unmodified lines
235235 ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}`
236236 : "";
237237 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 = ...`).";
239239 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.";
240240 const graphqlNote = hasGraphqlTools
241241 ? "\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 @@
11 import { expect, test } from "bun:test";
22 import type { Id } from "../../convex/_generated/dataModel";
3−import { createDiscoverTool } from "./tool-discovery";
3+import { createCatalogTools, createDiscoverTool } from "./tool-discovery";
44 import type { ToolDefinition } from "./types";
55
66 const TEST_WORKSPACE_ID = "w" as Id<"workspaces">;
@@ -208,3 +208,82 @@ test("discover namespace hint suppresses cross-namespace bestPath", async () => · 201 unmodified lines
208208 expect(result.bestPath).toBe("linear.query.teams");
209209 expect(result.results.some((entry) => entry.path.startsWith("github."))).toBe(false);
210210 });
211+
212+test("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+
241+test("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
99
1010 interface DiscoverIndexEntry {
1111 path: string;
12+ preferredPath: string;
1213 aliases: string[];
1314 description: string;
1415 approval: ToolDefinition["approval"];
@@ -36,6 +37,15 @@ const DISCOVER_STOP_WORDS = new Set([ · 21 unmodified lines
3637 "with",
3738 ]);
3839
40+const GENERIC_NAMESPACE_SUFFIXES = new Set([
41+ "api",
42+ "apis",
43+ "openapi",
44+ "sdk",
45+ "service",
46+ "services",
47+]);
48+
3949 function normalizeType(type?: string): string {
4050 return type && type.trim().length > 0 ? type : "unknown";
4151 }
@@ -44,6 +54,48 @@ function normalizeSearchToken(value: string): string { · 2 unmodified lines
4454 return value.toLowerCase().replace(/[^a-z0-9]/g, "");
4555 }
4656
57+function 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+
68+function 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+
87+function 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+
4799 function toCamelSegment(segment: string): string {
48100 return segment.replace(/_+([a-z0-9])/g, (_m, char: string) => char.toUpperCase());
49101 }
@@ -52,25 +104,31 @@ function getPathAliases(path: string): string[] { · 2 unmodified lines
52104 const segments = path.split(".").filter(Boolean);
53105 if (segments.length === 0) return [];
54106
107+ const canonicalPath = path;
108+ const publicPath = preferredToolPath(path);
109+
55110 const aliases = new Set<string>();
56− const camelPath = segments.map(toCamelSegment).join(".");
57− const compactPath = segments.map((segment) => segment.replace(/[_-]/g, "")).join(".");
58− const lowerPath = path.toLowerCase();
111+ const publicSegments = publicPath.split(".").filter(Boolean);
112+ const camelPath = publicSegments.map(toCamelSegment).join(".");
113+ const compactPath = publicSegments.map((segment) => segment.replace(/[_-]/g, "")).join(".");
114+ const lowerPath = publicPath.toLowerCase();
59115
60− if (camelPath !== path) aliases.add(camelPath);
61− if (compactPath !== path) aliases.add(compactPath);
62− if (lowerPath !== path) aliases.add(lowerPath);
116+ if (publicPath !== canonicalPath) aliases.add(publicPath);
117+ if (camelPath !== publicPath) aliases.add(camelPath);
118+ if (compactPath !== publicPath) aliases.add(compactPath);
119+ if (lowerPath !== publicPath) aliases.add(lowerPath);
63120
64121 return [...aliases].slice(0, 4);
65122 }
66123
67124 function buildExampleCall(entry: DiscoverIndexEntry): string {
125+ const callPath = entry.preferredPath;
68126 if (entry.path.endsWith(".graphql")) {
69− return `await tools.${entry.path}({ query: "query { __typename }", variables: {} });`;
127+ return `await tools.${callPath}({ query: "query { __typename }", variables: {} });`;
70128 }
71129
72130 if (entry.argsType === "{}") {
73− return `await tools.${entry.path}({});`;
131+ return `await tools.${callPath}({});`;
74132 }
75133
76134 const keys = entry.argPreviewKeys.length > 0 ? entry.argPreviewKeys : extractTopLevelTypeKeys(entry.argsType);
@@ -78,21 +136,23 @@ function buildExampleCall(entry: DiscoverIndexEntry): string { · 1 unmodified line
78136 const argsSnippet = keys.slice(0, 5)
79137 .map((key) => `${key}: ${key.toLowerCase().includes("input") ? "{ /* ... */ }" : "..."}`)
80138 .join(", ");
81− return `await tools.${entry.path}({ ${argsSnippet} });`;
139+ return `await tools.${callPath}({ ${argsSnippet} });`;
82140 }
83141
84− return `await tools.${entry.path}({ /* ... */ });`;
142+ return `await tools.${callPath}({ /* ... */ });`;
85143 }
86144
87145 function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] {
88146 return tools
89− .filter((tool) => tool.path !== "discover")
147+ .filter((tool) => tool.path !== "discover" && !tool.path.startsWith("catalog."))
90148 .map((tool) => {
149+ const preferredPath = preferredToolPath(tool.path);
91150 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();
93152
94153 return {
95154 path: tool.path,
155+ preferredPath,
96156 aliases,
97157 description: tool.description,
98158 approval: tool.approval,
@@ -161,7 +221,7 @@ function chooseBestPath( · 62 unmodified lines
161221 return null;
162222 }
163223
164− return best.entry.path;
224+ return best.entry.preferredPath;
165225 }
166226
167227 function scoreEntry(
@@ -235,6 +295,113 @@ function formatSignature(entry: DiscoverIndexEntry, depth: number, compact: bool · 67 unmodified lines
235295 return `(input: ${entry.argsType}): Promise<${entry.returnsType}> [source=${entry.source}]`;
236296 }
237297
298+function listIndexForContext(index: DiscoverIndexEntry[], isToolAllowed: (toolPath: string) => boolean): DiscoverIndexEntry[] {
299+ return index.filter((entry) => isToolAllowed(entry.path));
300+}
301+
302+export 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+
238405 export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition {
239406 const index = buildIndex(tools);
240407
@@ -243,7 +410,7 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 2 unmodified lines
243410 source: "system",
244411 approval: "auto",
245412 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.",
247414 metadata: {
248415 argsType: "{ query: string; depth?: number; limit?: number; compact?: boolean }",
249416 returnsType:
@@ -274,7 +441,7 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 24 unmodified lines
274441
275442 const results = ranked
276443 .map(({ entry }) => ({
277− path: entry.path,
444+ path: entry.preferredPath,
278445 aliases: entry.aliases,
279446 source: entry.source,
280447 approval: entry.approval,
executor/packages/core/src/typechecker.test.ts+2−1
@@ -231,7 +231,8 @@ describe("generateToolInventory", () => { · 230 unmodified lines
231231 expect(result).toContain("You have access to these tool namespaces:");
232232 expect(result).toContain("admin (1)");
233233 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? })");
235236 expect(result).toContain("Never shadow the global `tools` object");
236237 });
237238
executor/packages/core/src/typechecker.ts+3−2
@@ -475,8 +475,9 @@ export function generateToolInventory(tools: ToolDescriptor[]): string { · 474 unmodified lines
475475 "You have access to these tool namespaces:",
476476 ` ${namespaces.join(", ")}`,
477477 "",
478− "Use `tools.discover({ query, depth?, limit?, compact? })` first. It returns `{ bestPath, results, total }` (not an array).",
479− "Prefer `bestPath` when present, otherwise use a `results[i].path`; use `exampleCall` for invocation shape.",
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+ "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.",
480481 "For migration/ETL tasks: discover once, then execute in small batches and return compact summaries (counts, IDs, top-N samples).",
481482 "Never shadow the global `tools` object (do NOT write `const tools = ...`).",
482483 "Then call tools directly using the returned path.",