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.
This commit is contained in:
Aiden Cline
2026-06-30 10:08:02 -05:00
parent acc1742859
commit ba12049ca5
2 changed files with 172 additions and 9 deletions
+79 -8
View File
@@ -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<SearchEntry>,
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<string, AITool>,
mcpDefs: Record<string, MCPToolDef>,
@@ -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) => {