Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions src/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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<Array<{ name: string; description: string }>> {
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 }));
}
Expand Down
89 changes: 89 additions & 0 deletions src/tools/search-ranking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { ToolSchema } from "../engine/types.ts";

export type ToolSearchResult = Pick<ToolSchema, "name" | "description">;

interface ScoredTool<T extends ToolSearchResult> {
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<string> {
const tokens = new Set<string>();
for (const token of tokenize(value)) {
for (const variant of tokenVariants(token)) tokens.add(variant);
}
return tokens;
}

function hasTerm(tokens: Set<string>, 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<T extends ToolSearchResult>(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<T>[] = [];
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);
}
17 changes: 9 additions & 8 deletions src/tools/system-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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
Expand All @@ -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,
};
},
Expand Down
129 changes: 129 additions & 0 deletions test/unit/system-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,50 @@ async function makeRegistry(): Promise<ToolRegistry> {
return registry;
}

async function makeTodoSearchRegistry(): Promise<ToolRegistry> {
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<ToolRegistry> {
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<T>(result: { structuredContent?: unknown }): T | undefined {
return result.structuredContent as T | undefined;
}
Expand All @@ -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);
Expand Down