diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 8b99159d..a80ad770 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -3,6 +3,7 @@ import { textContent } from "../engine/content-helpers.ts"; import type { ToolCall, ToolResult, ToolRouter, ToolSchema } from "../engine/types.ts"; import type { PermissionStore } from "../permissions/permission-store.ts"; import type { McpSource } from "./mcp-source.ts"; +import { rankToolSearchResults } from "./search-ranking.ts"; import type { Tool, ToolSource } from "./types.ts"; /** @@ -189,12 +190,10 @@ export class ToolRegistry implements ToolRouter { return source.execute(localName, call.input, signal); } - /** Search all tools by keyword (substring match on name + description). */ + /** Search all tools by natural-language terms over name + description. */ private async searchTools(query: string): Promise> { - const q = query.toLowerCase(); const all = await this.availableTools(); - return all - .filter((t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q)) + return rankToolSearchResults(all, query) .slice(0, 5) .map((t) => ({ name: t.name, description: t.description })); } diff --git a/src/tools/search-ranking.ts b/src/tools/search-ranking.ts new file mode 100644 index 00000000..52be8be0 --- /dev/null +++ b/src/tools/search-ranking.ts @@ -0,0 +1,89 @@ +import type { ToolSchema } from "../engine/types.ts"; + +export type ToolSearchResult = Pick; + +interface ScoredTool { + tool: T; + score: number; + matchedTerms: number; +} + +function tokenize(value: string): string[] { + return value + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); +} + +function tokenVariants(token: string): string[] { + if (token.length > 3 && token.endsWith("s")) return [token, token.slice(0, -1)]; + return [token]; +} + +function tokenSet(value: string): Set { + const tokens = new Set(); + for (const token of tokenize(value)) { + for (const variant of tokenVariants(token)) tokens.add(variant); + } + return tokens; +} + +function hasTerm(tokens: Set, term: string): boolean { + for (const variant of tokenVariants(term)) { + if (tokens.has(variant)) return true; + } + return false; +} + +/** + * Rank installed tools for natural-language discovery queries. + * + * Matching is deterministic and dependency-free: full-query substring matches + * still work, but multi-term queries also match tokenized source names, tool + * names, and descriptions. Full query-term coverage ranks above partial hits. + */ +export function rankToolSearchResults(tools: T[], query: string): T[] { + const normalizedQuery = query.toLowerCase().trim(); + const queryTerms = [...new Set(tokenize(normalizedQuery))]; + if (queryTerms.length === 0) return tools; + + const scored: ScoredTool[] = []; + for (const tool of tools) { + const name = tool.name.toLowerCase(); + const description = tool.description.toLowerCase(); + const nameSubstringMatch = name.includes(normalizedQuery); + const descriptionSubstringMatch = description.includes(normalizedQuery); + const nameTokens = tokenSet(tool.name); + const descriptionTokens = tokenSet(tool.description); + + let score = 0; + if (nameSubstringMatch) score += 200; + if (descriptionSubstringMatch) score += 100; + + let matchedTerms = 0; + for (const term of queryTerms) { + const nameMatch = hasTerm(nameTokens, term); + const descriptionMatch = hasTerm(descriptionTokens, term); + if (!nameMatch && !descriptionMatch) continue; + + matchedTerms++; + score += nameMatch ? 20 : 0; + score += descriptionMatch ? 10 : 0; + } + + if (matchedTerms === 0 && !nameSubstringMatch && !descriptionSubstringMatch) continue; + const fullCoverage = matchedTerms === queryTerms.length; + score += matchedTerms * 1000; + if (fullCoverage) score += 500; + + scored.push({ tool, score, matchedTerms }); + } + + scored.sort((a, b) => { + if (b.matchedTerms !== a.matchedTerms) return b.matchedTerms - a.matchedTerms; + if (b.score !== a.score) return b.score - a.score; + return a.tool.name.localeCompare(b.tool.name); + }); + + return scored.map((s) => s.tool); +} diff --git a/src/tools/system-tools.ts b/src/tools/system-tools.ts index c9d64d7d..034d5df0 100644 --- a/src/tools/system-tools.ts +++ b/src/tools/system-tools.ts @@ -19,6 +19,7 @@ import { McpSource } from "./mcp-source.ts"; import { createManageToolsToolDefs } from "./platform/manage-tools.ts"; import type { ToolRegistry } from "./registry.ts"; import { createManageRegistriesTool } from "./registry-tools.ts"; +import { rankToolSearchResults } from "./search-ranking.ts"; import { createManageUsersTool, type ManageUsersContext } from "./user-tools.ts"; import { createManageWorkspacesTool, @@ -95,7 +96,7 @@ export async function createSystemTools( query: { type: "string", description: - "Search query (substring match on name + description). Optional — omit to list everything in scope.", + "Search query (natural-language terms over name + description). Optional — omit to list everything in scope.", }, }, required: ["scope"], @@ -136,7 +137,7 @@ export async function createSystemTools( } // scope === "tools" (default) - const q = query.toLowerCase(); + const q = query.toLowerCase().trim(); // Identity-level discovery: search the identity's full // cross-workspace tool union (the aggregator), not just the // calling workspace. The aggregator namespaces nb__search per @@ -153,16 +154,16 @@ export async function createSystemTools( toolEligibilityCtx?.isToolEligible(t) ?? !t.annotations?.["ai.nimblebrain/internal"], ); if (!q) return groupToolsBySource(all); - const matches = all.filter( - (t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q), - ); + const matches = rankToolSearchResults(all, q); if (matches.length === 0) return { content: textContent(`No tools matched "${query}".`), isError: false }; - const lines = [`Found ${matches.length} tool(s) for "${query}":\n`]; - for (const t of matches) lines.push(`- **${t.name}**: ${t.description}`); + const shown = matches.slice(0, 25); + const suffix = matches.length > shown.length ? ` (showing top ${shown.length})` : ""; + const lines = [`Found ${matches.length} tool(s) for "${query}"${suffix}:\n`]; + for (const t of shown) lines.push(`- **${t.name}**: ${t.description}`); return { content: textContent(lines.join("\n")), - structuredContent: { tools: matches.map((t) => ({ name: t.name })) }, + structuredContent: { tools: shown.map((t) => ({ name: t.name })) }, isError: false, }; }, diff --git a/test/unit/system-tools.test.ts b/test/unit/system-tools.test.ts index d2657653..ccf96725 100644 --- a/test/unit/system-tools.test.ts +++ b/test/unit/system-tools.test.ts @@ -53,6 +53,50 @@ async function makeRegistry(): Promise { return registry; } +async function makeTodoSearchRegistry(): Promise { + const registry = new ToolRegistry(); + const todoSource = await makeInProcessSource("synapse-todo-board", [ + { + name: "create_board_task", + description: "Create a task on a specific board", + inputSchema: { type: "object", properties: {} }, + handler: async () => ({ content: textContent("ok"), isError: false }), + }, + { + name: "list_boards", + description: "List available boards", + inputSchema: { type: "object", properties: {} }, + handler: async () => ({ content: textContent("ok"), isError: false }), + }, + ]); + const genericSource = await makeInProcessSource("scratch", [ + { + name: "create_task_template", + description: "Create a reusable task template", + inputSchema: { type: "object", properties: {} }, + handler: async () => ({ content: textContent("ok"), isError: false }), + }, + ]); + registry.addSource(todoSource); + registry.addSource(genericSource); + return registry; +} + +async function makeManyMatchingToolsRegistry(count: number): Promise { + const registry = new ToolRegistry(); + const source = await makeInProcessSource( + "many", + Array.from({ length: count }, (_, i) => ({ + name: `common_tool_${String(i).padStart(2, "0")}`, + description: "Common searchable helper", + inputSchema: { type: "object", properties: {} }, + handler: async () => ({ content: textContent("ok"), isError: false }), + })), + ); + registry.addSource(source); + return registry; +} + function getStructured(result: { structuredContent?: unknown }): T | undefined { return result.structuredContent as T | undefined; } @@ -72,6 +116,91 @@ describe("System Tools", () => { ]); }); + it("search with scope=tools preserves single-word prefix substring matches", async () => { + const registry = new ToolRegistry(); + const source = await makeInProcessSource("test", [ + { + name: "greeting", + description: "Friendly salutation helper", + inputSchema: { type: "object", properties: {} }, + handler: async () => ({ content: textContent("ok"), isError: false }), + }, + ]); + registry.addSource(source); + const systemTools = await createSystemTools(() => registry); + const result = await systemTools.execute("search", { + scope: "tools", + query: "greet", + }); + + expect(result.isError).toBe(false); + expect(getStructured<{ tools?: Array<{ name: string }> }>(result)?.tools).toEqual([ + { name: "test__greeting" }, + ]); + }); + + it("search with scope=tools matches natural-language terms across source, name, and description", async () => { + const registry = await makeTodoSearchRegistry(); + const systemTools = await createSystemTools(() => registry); + const result = await systemTools.execute("search", { + scope: "tools", + query: "todo task create", + }); + + expect(result.isError).toBe(false); + expect(getStructured<{ tools?: Array<{ name: string }> }>(result)?.tools?.[0]).toEqual({ + name: "synapse-todo-board__create_board_task", + }); + expect(extractText(result.content)).toContain("synapse-todo-board__create_board_task"); + }); + + it("search with scope=tools tokenizes hyphenated source names", async () => { + const registry = await makeTodoSearchRegistry(); + const systemTools = await createSystemTools(() => registry); + const result = await systemTools.execute("search", { + scope: "tools", + query: "todo board", + }); + + expect(result.isError).toBe(false); + const names = getStructured<{ tools?: Array<{ name: string }> }>(result)?.tools?.map( + (t) => t.name, + ); + expect(names).toContain("synapse-todo-board__create_board_task"); + expect(names).toContain("synapse-todo-board__list_boards"); + }); + + it("search with scope=tools matches description terms", async () => { + const registry = await makeTodoSearchRegistry(); + const systemTools = await createSystemTools(() => registry); + const result = await systemTools.execute("search", { + scope: "tools", + query: "specific board", + }); + + expect(result.isError).toBe(false); + expect(getStructured<{ tools?: Array<{ name: string }> }>(result)?.tools?.[0]).toEqual({ + name: "synapse-todo-board__create_board_task", + }); + }); + + it("search with scope=tools caps broad matches at the top 25 results", async () => { + const registry = await makeManyMatchingToolsRegistry(30); + const systemTools = await createSystemTools(() => registry); + const result = await systemTools.execute("search", { + scope: "tools", + query: "common", + }); + + expect(result.isError).toBe(false); + expect(extractText(result.content)).toContain( + 'Found 30 tool(s) for "common" (showing top 25):', + ); + expect(getStructured<{ tools?: Array<{ name: string }> }>(result)?.tools).toHaveLength(25); + expect(extractText(result.content)).toContain("many__common_tool_24"); + expect(extractText(result.content)).not.toContain("many__common_tool_25"); + }); + it("search with scope=tools and empty query returns all tools grouped", async () => { const registry = await makeRegistry(); const systemTools = await createSystemTools(() => registry);