From 6d11433e8334b7bdbb372aeabb505bbd76532310 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 6 Nov 2026 17:13:01 -0800 Subject: [PATCH] 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. --- packages/codemode/src/tool-runtime.ts | 90 +++++++++++++---------- packages/codemode/test/tool-paths.test.ts | 21 ++++++ 2 files changed, 71 insertions(+), 40 deletions(-) diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 8660d4fd630..b43d21d358a 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -240,46 +240,56 @@ const rank = (entries: ReadonlyArray, query: string): Array entry) } -const makeSearchTool = (searchIndex: ReadonlyArray): 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): 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 = (() => { diff --git a/packages/codemode/test/tool-paths.test.ts b/packages/codemode/test/tool-paths.test.ts index 76c4a875c44..b2b7d24bc6e 100644 --- a/packages/codemode/test/tool-paths.test.ts +++ b/packages/codemode/test/tool-paths.test.ts @@ -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", () => {