mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 12:41:37 +03:00
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:
@@ -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 = (() => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user