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.
Unified · Split
| @@ -19,7 +19,7 @@ import type { · 18 unmodified lines | ||
| 19 | 19 | } from "../../core/src/types"; |
| 20 | 20 | import { asPayload, describeError } from "../../core/src/utils"; |
| 21 | 21 | import { getDecisionForContext, getToolDecision, isToolAllowedForTask } from "./policy"; |
| 22 | −import { resolveAliasedToolPath, suggestToolPaths } from "./tool_paths"; | |
| 22 | +import { resolveAliasedToolPath, resolveClosestToolPath, suggestToolPaths, toPreferredToolPath } from "./tool_paths"; | |
| 23 | 23 | import { baseTools, getWorkspaceTools } from "./workspace_tools"; |
| 24 | 24 | import { 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 | 198 | if (!tool) { |
| 190 | 199 | const availableTools = workspaceTools ?? baseTools; |
| 191 | 200 | const suggestions = suggestToolPaths(toolPath, availableTools); |
| 201 | + const queryHint = toolPath | |
| 202 | + .split(".") | |
| 203 | + .filter(Boolean) | |
| 204 | + .join(" "); | |
| 192 | 205 | 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 | 212 | let decision: PolicyDecision; |
| @@ -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 | +}); | |
| @@ -2,18 +2,83 @@ · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import type { ToolDefinition } from "../../core/src/types"; |
| 4 | 4 | |
| 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(""); | |
| 7 | 47 | } |
| 8 | 48 | |
| 9 | 49 | function normalizeToolPath(path: string): string { |
| 10 | − return path | |
| 50 | + const segments = path | |
| 11 | 51 | .split(".") |
| 12 | − .filter(Boolean) | |
| 13 | − .map((segment) => normalizeToolPathSegment(segment)) | |
| 52 | + .filter(Boolean); | |
| 53 | + | |
| 54 | + return segments | |
| 55 | + .map((segment, index) => normalizeToolPathSegment(segment, index === 0)) | |
| 14 | 56 | .join("."); |
| 15 | 57 | } |
| 16 | 58 | |
| 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 | + | |
| 17 | 82 | function levenshteinDistance(a: string, b: string): number { |
| 18 | 83 | if (a === b) return 0; |
| 19 | 84 | if (a.length === 0) return b.length; |
| @@ -103,3 +168,51 @@ export function suggestToolPaths( · 83 unmodified lines | ||
| 103 | 168 | .slice(0, limit) |
| 104 | 169 | .map((item) => item.path); |
| 105 | 170 | } |
| 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 | +} | |
| @@ -3,7 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | import type { ActionCtx } from "../_generated/server"; |
| 4 | 4 | import { internal } from "../_generated/api"; |
| 5 | 5 | import type { Id } from "../_generated/dataModel"; |
| 6 | −import { createDiscoverTool } from "../../core/src/tool-discovery"; | |
| 6 | +import { createCatalogTools, createDiscoverTool } from "../../core/src/tool-discovery"; | |
| 7 | 7 | import { |
| 8 | 8 | materializeCompiledToolSource, |
| 9 | 9 | materializeWorkspaceSnapshot, |
| @@ -101,6 +101,10 @@ export async function getWorkspaceTools(ctx: ActionCtx, workspaceId: Id<"workspa · 91 unmodified lines | ||
| 101 | 101 | if (tool.path === "discover") continue; |
| 102 | 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 | 108 | const discover = createDiscoverTool([...merged.values()]); |
| 105 | 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 | 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 | 153 | const discover = createDiscoverTool([...merged.values()]); |
| 145 | 154 | merged.set(discover.path, discover); |
| 146 | 155 | |
| @@ -235,7 +235,7 @@ function buildRunCodeDescription(tools?: ToolDescriptor[]): string { · 234 unmodified lines | ||
| 235 | 235 | ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}` |
| 236 | 236 | : ""; |
| 237 | 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 | 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 | 240 | const graphqlNote = hasGraphqlTools |
| 241 | 241 | ? "\n\nGraphQL tip: prefer `source.query.*` / `source.mutation.*` helper paths when available; GraphQL tools return `{ data, errors }`." |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import type { Id } from "../../convex/_generated/dataModel"; |
| 3 | −import { createDiscoverTool } from "./tool-discovery"; | |
| 3 | +import { createCatalogTools, createDiscoverTool } from "./tool-discovery"; | |
| 4 | 4 | import type { ToolDefinition } from "./types"; |
| 5 | 5 | |
| 6 | 6 | const TEST_WORKSPACE_ID = "w" as Id<"workspaces">; |
| @@ -208,3 +208,82 @@ test("discover namespace hint suppresses cross-namespace bestPath", async () => · 201 unmodified lines | ||
| 208 | 208 | expect(result.bestPath).toBe("linear.query.teams"); |
| 209 | 209 | expect(result.results.some((entry) => entry.path.startsWith("github."))).toBe(false); |
| 210 | 210 | }); |
| 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 | +}); | |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | |
| 10 | 10 | interface DiscoverIndexEntry { |
| 11 | 11 | path: string; |
| 12 | + preferredPath: string; | |
| 12 | 13 | aliases: string[]; |
| 13 | 14 | description: string; |
| 14 | 15 | approval: ToolDefinition["approval"]; |
| @@ -36,6 +37,15 @@ const DISCOVER_STOP_WORDS = new Set([ · 21 unmodified lines | ||
| 36 | 37 | "with", |
| 37 | 38 | ]); |
| 38 | 39 | |
| 40 | +const GENERIC_NAMESPACE_SUFFIXES = new Set([ | |
| 41 | + "api", | |
| 42 | + "apis", | |
| 43 | + "openapi", | |
| 44 | + "sdk", | |
| 45 | + "service", | |
| 46 | + "services", | |
| 47 | +]); | |
| 48 | + | |
| 39 | 49 | function normalizeType(type?: string): string { |
| 40 | 50 | return type && type.trim().length > 0 ? type : "unknown"; |
| 41 | 51 | } |
| @@ -44,6 +54,48 @@ function normalizeSearchToken(value: string): string { · 2 unmodified lines | ||
| 44 | 54 | return value.toLowerCase().replace(/[^a-z0-9]/g, ""); |
| 45 | 55 | } |
| 46 | 56 | |
| 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 | + | |
| 47 | 99 | function toCamelSegment(segment: string): string { |
| 48 | 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 | 104 | const segments = path.split(".").filter(Boolean); |
| 53 | 105 | if (segments.length === 0) return []; |
| 54 | 106 | |
| 107 | + const canonicalPath = path; | |
| 108 | + const publicPath = preferredToolPath(path); | |
| 109 | + | |
| 55 | 110 | 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(); | |
| 59 | 115 | |
| 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); | |
| 63 | 120 | |
| 64 | 121 | return [...aliases].slice(0, 4); |
| 65 | 122 | } |
| 66 | 123 | |
| 67 | 124 | function buildExampleCall(entry: DiscoverIndexEntry): string { |
| 125 | + const callPath = entry.preferredPath; | |
| 68 | 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 | 130 | if (entry.argsType === "{}") { |
| 73 | − return `await tools.${entry.path}({});`; | |
| 131 | + return `await tools.${callPath}({});`; | |
| 74 | 132 | } |
| 75 | 133 | |
| 76 | 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 | 136 | const argsSnippet = keys.slice(0, 5) |
| 79 | 137 | .map((key) => `${key}: ${key.toLowerCase().includes("input") ? "{ /* ... */ }" : "..."}`) |
| 80 | 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 | |
| 87 | 145 | function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] { |
| 88 | 146 | return tools |
| 89 | − .filter((tool) => tool.path !== "discover") | |
| 147 | + .filter((tool) => tool.path !== "discover" && !tool.path.startsWith("catalog.")) | |
| 90 | 148 | .map((tool) => { |
| 149 | + const preferredPath = preferredToolPath(tool.path); | |
| 91 | 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 | 153 | return { |
| 95 | 154 | path: tool.path, |
| 155 | + preferredPath, | |
| 96 | 156 | aliases, |
| 97 | 157 | description: tool.description, |
| 98 | 158 | approval: tool.approval, |
| @@ -161,7 +221,7 @@ function chooseBestPath( · 62 unmodified lines | ||
| 161 | 221 | return null; |
| 162 | 222 | } |
| 163 | 223 | |
| 164 | − return best.entry.path; | |
| 224 | + return best.entry.preferredPath; | |
| 165 | 225 | } |
| 166 | 226 | |
| 167 | 227 | function scoreEntry( |
| @@ -235,6 +295,113 @@ function formatSignature(entry: DiscoverIndexEntry, depth: number, compact: bool · 67 unmodified lines | ||
| 235 | 295 | return `(input: ${entry.argsType}): Promise<${entry.returnsType}> [source=${entry.source}]`; |
| 236 | 296 | } |
| 237 | 297 | |
| 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 | + | |
| 238 | 405 | export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { |
| 239 | 406 | const index = buildIndex(tools); |
| 240 | 407 | |
| @@ -243,7 +410,7 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 2 unmodified lines | ||
| 243 | 410 | source: "system", |
| 244 | 411 | approval: "auto", |
| 245 | 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 | 414 | metadata: { |
| 248 | 415 | argsType: "{ query: string; depth?: number; limit?: number; compact?: boolean }", |
| 249 | 416 | returnsType: |
| @@ -274,7 +441,7 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 24 unmodified lines | ||
| 274 | 441 | |
| 275 | 442 | const results = ranked |
| 276 | 443 | .map(({ entry }) => ({ |
| 277 | − path: entry.path, | |
| 444 | + path: entry.preferredPath, | |
| 278 | 445 | aliases: entry.aliases, |
| 279 | 446 | source: entry.source, |
| 280 | 447 | approval: entry.approval, |
| @@ -231,7 +231,8 @@ describe("generateToolInventory", () => { · 230 unmodified lines | ||
| 231 | 231 | expect(result).toContain("You have access to these tool namespaces:"); |
| 232 | 232 | expect(result).toContain("admin (1)"); |
| 233 | 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 | 236 | expect(result).toContain("Never shadow the global `tools` object"); |
| 236 | 237 | }); |
| 237 | 238 | |
| @@ -475,8 +475,9 @@ export function generateToolInventory(tools: ToolDescriptor[]): string { · 474 unmodified lines | ||
| 475 | 475 | "You have access to these tool namespaces:", |
| 476 | 476 | ` ${namespaces.join(", ")}`, |
| 477 | 477 | "", |
| 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.", | |
| 480 | 481 | "For migration/ETL tasks: discover once, then execute in small batches and return compact summaries (counts, IDs, top-N samples).", |
| 481 | 482 | "Never shadow the global `tools` object (do NOT write `const tools = ...`).", |
| 482 | 483 | "Then call tools directly using the returned path.", |