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", () => {