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 | } from "../../core/src/types"; | 19 | } from "../../core/src/types"; |
| 20 | import { asPayload, describeError } from "../../core/src/utils"; | 20 | import { asPayload, describeError } from "../../core/src/utils"; |
| 21 | import { getDecisionForContext, getToolDecision, isToolAllowedForTask } from "./policy"; | 21 | import { getDecisionForContext, getToolDecision, isToolAllowedForTask } from "./policy"; |
| 22 | import { resolveAliasedToolPath, suggestToolPaths } from "./tool_paths"; | 22 | import { resolveAliasedToolPath, resolveClosestToolPath, suggestToolPaths, toPreferredToolPath } from "./tool_paths"; |
| 23 | import { baseTools, getWorkspaceTools } from "./workspace_tools"; | 23 | import { baseTools, getWorkspaceTools } from "./workspace_tools"; |
| 24 | import { publishTaskEvent } from "./events"; | 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 | 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 > 0 | 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 | let decision: PolicyDecision; | 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 | import type { ToolDefinition } from "../../core/src/types"; | 3 | import type { ToolDefinition } from "../../core/src/types"; |
| 4 | 4 | ||
| 5 | function normalizeToolPathSegment(segment: string): string { | 5 | const 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 | |||
| 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 | function normalizeToolPath(path: string): string { | 49 | function normalizeToolPath(path: string): string { |
| 10 | return path | 50 | 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 | ||
| 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 | function levenshteinDistance(a: string, b: string): number { | 82 | function 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 | |||
| 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 | import type { ActionCtx } from "../_generated/server"; | 3 | import type { ActionCtx } from "../_generated/server"; |
| 4 | import { internal } from "../_generated/api"; | 4 | import { internal } from "../_generated/api"; |
| 5 | import type { Id } from "../_generated/dataModel"; | 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 | import { | 7 | import { |
| 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 | ||
| @@ -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 = hasGraphqlTools | 240 | 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 }`." |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { expect, test } from "bun:test"; | 1 | import { expect, test } from "bun:test"; |
| 2 | import type { Id } from "../../convex/_generated/dataModel"; | 2 | import type { Id } from "../../convex/_generated/dataModel"; |
| 3 | import { createDiscoverTool } from "./tool-discovery"; | 3 | import { createCatalogTools, createDiscoverTool } from "./tool-discovery"; |
| 4 | import type { ToolDefinition } from "./types"; | 4 | import type { ToolDefinition } from "./types"; |
| 5 | 5 | ||
| 6 | const TEST_WORKSPACE_ID = "w" as Id<"workspaces">; | 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 | 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 | |||
| 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 | interface DiscoverIndexEntry { | 10 | interface 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 | ||
| 40 | const GENERIC_NAMESPACE_SUFFIXES = new Set([ | ||
| 41 | "api", | ||
| 42 | "apis", | ||
| 43 | "openapi", | ||
| 44 | "sdk", | ||
| 45 | "service", | ||
| 46 | "services", | ||
| 47 | ]); | ||
| 48 | |||
| 39 | function normalizeType(type?: string): string { | 49 | function 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 | ||
| 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 | function toCamelSegment(segment: string): string { | 99 | function 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 | ||
| 67 | function buildExampleCall(entry: DiscoverIndexEntry): string { | 124 | function 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 | ||
| 87 | function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] { | 145 | function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] { |
| 88 | return tools | 146 | 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 | ||
| 167 | function scoreEntry( | 227 | function 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 | ||
| 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 | export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { | 405 | export 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 = ranked | 442 | 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, |
| @@ -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 | ||
| @@ -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.", |