fix(codemode): scope search by a tools.-prefixed namespace when a server is named tools

Search scoped a written namespace by stripping a tools. prefix only while no tool path started with tools., so a server named tools made search return nothing for every other namespace. Strip the prefix only when the written form names no namespace or tool, and normalize the query the same way.
This commit is contained in:
Kit Langton
2026-11-06 17:13:01 -08:00
Unverified
parent b2b5da600e
commit 6d11433e83
2 changed files with 71 additions and 40 deletions
+50 -40
View File
@@ -240,46 +240,56 @@ const rank = (entries: ReadonlyArray<SearchEntry>, query: string): Array<SearchE
.map(({ entry }) => entry)
}
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
_tag: "CodeModeTool",
description: "Search available tools",
input: SearchInput,
output: SearchOutput,
execute: (input) =>
Effect.sync(() => {
const request = input as typeof SearchInput.Type
const query = request.query ?? ""
const offset = request.offset ?? 0
let ns = request.namespace
if (ns !== undefined && !searchIndex.some((entry) => entry.description.path.startsWith("tools."))) {
if (ns === "tools") ns = undefined
else if (ns.startsWith("tools.")) ns = ns.slice("tools.".length)
}
const scoped =
ns === undefined
? searchIndex
: searchIndex.filter((entry) => entry.description.path === ns || entry.description.path.startsWith(`${ns}.`))
const trimmed = query.trim()
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
const exact =
pathQuery === ""
? undefined
: scoped.find(
(entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
)
const ranked = exact !== undefined ? [exact] : rank(scoped, query)
const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({
...description,
path: toolExpression(description.path),
}))
const remaining = Math.max(0, ranked.length - offset - items.length)
return {
items,
remaining,
next: remaining > 0 ? { offset: offset + items.length } : null,
}
}),
})
const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => {
// Paths are shown with a `tools.` prefix, so a namespace or a query may repeat it. A prefix can also be real: a
// server named `tools` publishes paths under that namespace. Drop it only when the written form names nothing.
const names = (value: string) =>
searchIndex.some((entry) => entry.description.path === value || entry.description.path.startsWith(`${value}.`))
const unprefixed = (value: string) => {
if (value === "tools") return undefined
if (!value.startsWith("tools.")) return value
return value.slice("tools.".length)
}
const normalized = (value: string) => (names(value) ? value : unprefixed(value))
return {
_tag: "CodeModeTool",
description: "Search available tools",
input: SearchInput,
output: SearchOutput,
execute: (input) =>
Effect.sync(() => {
const request = input as typeof SearchInput.Type
const query = request.query ?? ""
const offset = request.offset ?? 0
const ns = request.namespace === undefined ? undefined : normalized(request.namespace)
const scoped =
ns === undefined
? searchIndex
: searchIndex.filter(
(entry) => entry.description.path === ns || entry.description.path.startsWith(`${ns}.`),
)
const trimmed = query.trim()
const pathQuery = names(trimmed) ? trimmed : (unprefixed(trimmed) ?? "")
const exact =
pathQuery === ""
? undefined
: scoped.find(
(entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
)
const ranked = exact !== undefined ? [exact] : rank(scoped, query)
const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({
...description,
path: toolExpression(description.path),
}))
const remaining = Math.max(0, ranked.length - offset - items.length)
return {
items,
remaining,
next: remaining > 0 ? { offset: offset + items.length } : null,
}
}),
}
}
/** Exact callable signature of the built-in `search` function, for host-owned instructions. */
export const searchSignature = (() => {
+21
View File
@@ -75,6 +75,27 @@ describe("dotted tool names", () => {
"tools.slack.admin.users.list",
])
})
test("search scopes a `tools.` prefix even when a namespace is named tools", async () => {
const runtime = CodeMode.make({
tools: {
tools: { ping: echo("Ping the service", "pong") },
slack: { read: echo("Read Slack", "read") },
},
})
const prefixed = await value(runtime, `return search({ query: "", namespace: "tools.slack" })`)
expect((prefixed as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
"tools.slack.read",
])
// The literal namespace still wins when that is what the caller meant.
const literal = await value(runtime, `return search({ query: "", namespace: "tools.tools" })`)
expect((literal as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual(["tools.tools.ping"])
const exact = await value(runtime, `return search({ query: "tools.tools.ping" })`)
expect((exact as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
"tools.tools.ping",
])
})
})
describe("callable namespaces", () => {