From ba12049ca56bd51feffc0382f9832950823e8e34 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 10:08:02 -0500 Subject: [PATCH] feat(opencode): tokenized, ranked tool search in code mode Replace the contiguous-substring search with tokenized, field-weighted scoring adapted from the deferred-tool-search bridge (#34368): exact tool name > path > description > indexed parameter text, summed across terms and ranked by relevance. Index each tool's parameter names/descriptions so tools are findable by their inputs. Keep the namespace filter and { items, total } shape. Add unit + end-to-end search tests. --- packages/opencode/src/session/code-mode.ts | 87 +++++++++++++++-- .../opencode/test/session/code-mode.test.ts | 94 ++++++++++++++++++- 2 files changed, 172 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index bce0b03d89..8816f5666e 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -309,6 +309,77 @@ export function fromReturn(value: unknown): { output: string; attachments?: Atta return { output: formatValue(value) } } +/** A search-indexed catalog entry: the fields ranking matches against, with + * `searchText` (path + description + parameter names/descriptions) precomputed. */ +export type SearchEntry = { path: string; server: string; description: string; searchText: string } + +/** The lowercased searchable text for a tool: its path, description, and the name + * (and description, when present) of each input parameter. */ +function searchTextFor(entry: CatalogEntry): string { + const parts = [entry.path, entry.description] + try { + const schema = asSchema(entry.tool.inputSchema).jsonSchema as JSONSchema7 | undefined + const props = schema?.properties + if (props && typeof props === "object") { + for (const [name, value] of Object.entries(props)) { + parts.push(name) + const desc = (value as JSONSchema7 | undefined)?.description + if (typeof desc === "string") parts.push(desc) + } + } + } catch { + // fall back to path + description only + } + return parts.join("\n").toLowerCase() +} + +/** Split a query into lowercased search terms, dropping empties and the `*` wildcard. */ +const tokenize = (query: string) => + query + .toLowerCase() + .split(/[^a-z0-9_-]+/) + .map((term) => term.trim()) + .filter((term) => term.length > 0 && term !== "*") + +/** + * Rank catalog entries against a query using tokenized, field-weighted scoring + * (adapted from the deferred-tool-search bridge). Each term contributes per field: + * exact tool name (20) > path substring (8) > description (4) > any searchable text (2), + * summed across terms. Because paths are `server.tool`, the exact tier matches a + * whole path segment (e.g. the term `search` matches `github.search`). An empty + * query lists everything (alphabetical). Results are ranked by score, tie-broken by path. + */ +export function rankTools( + entries: ReadonlyArray, + query: string, + namespace?: string, + limit = 25, +): { items: { path: string; description: string }[]; total: number } { + const terms = tokenize(query) + const scoped = namespace ? entries.filter((entry) => entry.server === namespace) : entries + const ranked = scoped + .map((entry) => { + const path = entry.path.toLowerCase() + const description = entry.description.toLowerCase() + const score = terms.reduce( + (total, term) => + total + + (path === term || path.endsWith(`.${term}`) ? 20 : 0) + + (path.includes(term) ? 8 : 0) + + (description.includes(term) ? 4 : 0) + + (entry.searchText.includes(term) ? 2 : 0), + 0, + ) + return { entry, score } + }) + .filter((item) => terms.length === 0 || item.score > 0) + .sort((a, b) => b.score - a.score || a.entry.path.localeCompare(b.entry.path)) + return { + items: ranked.slice(0, limit).map(({ entry }) => ({ path: entry.path, description: brief(entry.description) })), + total: ranked.length, + } +} + export function define( mcpTools: Record, mcpDefs: Record, @@ -317,19 +388,19 @@ export function define( const groups = groupByServer(mcpTools, servers, mcpDefs) const catalog: CatalogEntry[] = [...groups.values()].flat() const byKey = new Map(catalog.map((entry) => [entry.key, entry] as const)) + const index: SearchEntry[] = catalog.map((entry) => ({ + path: entry.path, + server: entry.server, + description: entry.description, + searchText: searchTextFor(entry), + })) const search = (query: unknown, options: unknown) => { - const q = (typeof query === "string" ? query : "").toLowerCase() + const q = typeof query === "string" ? query : "" const opts = (options ?? {}) as { namespace?: unknown; limit?: unknown } const namespace = typeof opts.namespace === "string" ? opts.namespace : undefined const limit = typeof opts.limit === "number" && opts.limit > 0 ? Math.floor(opts.limit) : 25 - const matched = catalog - .filter((entry) => (namespace ? entry.server === namespace : true)) - .filter((entry) => (q ? `${entry.path} ${entry.description}`.toLowerCase().includes(q) : true)) - return { - items: matched.slice(0, limit).map((entry) => ({ path: entry.path, description: brief(entry.description) })), - total: matched.length, - } + return rankTools(index, q, namespace, limit) } const describeTool = (path: unknown) => { diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index eaead70a64..21ca486c63 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -1,5 +1,14 @@ import { describe, expect, test } from "bun:test" -import { Parameters, define, describe as describeTools, formatValue, groupByServer, toEnvelope } from "@/session/code-mode" +import { + Parameters, + define, + describe as describeTools, + formatValue, + groupByServer, + rankTools, + toEnvelope, + type SearchEntry, +} from "@/session/code-mode" import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import { Agent } from "@/agent/agent" import { Tool } from "@/tool/tool" @@ -323,4 +332,87 @@ describe("code mode execute", () => { expect(suppressed.attachments).toBeUndefined() expect(JSON.parse(suppressed.output)).toEqual({ name: "shot.png" }) }) + + test("indexes parameter names so tools are searchable by their inputs", async () => { + const tool = await build({ + // The query word appears only as a parameter name, not in path or description. + traces_lookup: mcpTool("lookup", () => "", { + type: "object", + properties: { trace_id: { type: "string", description: "the distributed trace identifier" } }, + }), + other_noop: mcpTool("noop", () => ""), + }) + const out = await Effect.runPromise(tool.execute({ code: "return await tools.search('trace_id')" }, ctx)) + const result = JSON.parse(out.output) + expect(result.items.map((i: any) => i.path)).toEqual(["traces.lookup"]) + }) +}) + +describe("rankTools", () => { + const E = (path: string, description: string, params = ""): SearchEntry => ({ + path, + server: path.split(".")[0]!, + description, + searchText: [path, description, params].join("\n").toLowerCase(), + }) + + test("matches multiple non-contiguous terms (not just a contiguous substring)", () => { + const entries = [ + E("github.create_issue", "Create a new issue on a repository"), + E("github.list_pulls", "List pull requests"), + ] + const { items, total } = rankTools(entries, "create issue") + expect(total).toBe(1) + expect(items[0]!.path).toBe("github.create_issue") + }) + + test("ranks an exact tool-name match above a substring match", () => { + const entries = [E("github.search_issues", "Search issues"), E("github.search", "Full text search")] + const { items } = rankTools(entries, "search") + expect(items[0]!.path).toBe("github.search") + }) + + test("ranks a name match above a description-only match", () => { + const entries = [ + E("datadog.list_monitors", "Enumerate alerting definitions"), + E("datadog.get_dashboard", "List the monitors on a dashboard"), + ] + const { items } = rankTools(entries, "monitors") + expect(items[0]!.path).toBe("datadog.list_monitors") + }) + + test("matches against indexed parameter text", () => { + const entries = [E("traces.lookup", "Fetch a span", "trace_id the distributed trace id"), E("other.noop", "Does nothing")] + const { items, total } = rankTools(entries, "trace_id") + expect(total).toBe(1) + expect(items[0]!.path).toBe("traces.lookup") + }) + + test("respects the namespace filter", () => { + const entries = [E("github.search", "search"), E("linear.search", "search")] + const { items, total } = rankTools(entries, "search", "linear") + expect(total).toBe(1) + expect(items[0]!.path).toBe("linear.search") + }) + + test("an empty query (or bare wildcard) lists everything alphabetically", () => { + const entries = [E("b.two", "second"), E("a.one", "first")] + for (const q of ["", "*"]) { + const { items, total } = rankTools(entries, q) + expect(total).toBe(2) + expect(items.map((i) => i.path)).toEqual(["a.one", "b.two"]) + } + }) + + test("honors the limit while reporting the full match total", () => { + const entries = Array.from({ length: 10 }, (_, i) => E(`s.tool_${i}`, "searchable tool")) + const { items, total } = rankTools(entries, "searchable", undefined, 3) + expect(total).toBe(10) + expect(items).toHaveLength(3) + }) + + test("returns nothing when no term matches", () => { + const entries = [E("github.search", "search")] + expect(rankTools(entries, "nonexistent")).toEqual({ items: [], total: 0 }) + }) })