feat(opencode): progressive tool discovery for code mode
Shrink the execute tool description to namespaces only (server name, tool
count, and a brief note reused from the server's MCP instructions) instead of
inlining every tool signature, so the prompt stays small for large catalogs.
Add in-sandbox discovery: `tools.search(query, { namespace?, limit? })` returns
ranked tool paths + descriptions, and `tools.describe(path)` returns the full
signature and input schema on demand (with tool_not_found suggestions). The
proxy is now recursive so both `tools.<server>.<tool>(args)` and the dotted
`tools[path](args)` returned by search resolve to the catalog key.
This commit is contained in:
@@ -7,7 +7,7 @@ export const CODE_MODE_TOOL = "execute"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
code: Schema.String.annotate({
|
||||
description: "JavaScript to run. Call tools as `await tools.<server>.<tool>(input)` and `return` the final value.",
|
||||
description: "JavaScript to run. Discover tools with `tools.search`/`tools.describe`, call them, and `return` the final value.",
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -16,6 +16,8 @@ type Metadata = {
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
export type Namespace = { name: string; description?: string }
|
||||
|
||||
// `new Function`/`AsyncFunction` is not on the global scope, so reach it via the
|
||||
// prototype of an async function literal. The body may use top-level `await` and `return`.
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as {
|
||||
@@ -23,24 +25,41 @@ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as
|
||||
}
|
||||
|
||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||
const SEARCH = "search"
|
||||
const DESCRIBE = "describe"
|
||||
|
||||
type NamespacedTool = { local: string; key: string; tool: AITool }
|
||||
type CatalogEntry = { path: string; key: string; server: string; local: string; description: string; tool: AITool }
|
||||
|
||||
const firstLine = (text: string | undefined) => (text ?? "").split("\n", 1)[0]!.trim()
|
||||
const brief = (text: string | undefined, max = 120) => {
|
||||
const line = firstLine(text)
|
||||
return line.length > max ? line.slice(0, max - 1) + "…" : line
|
||||
}
|
||||
|
||||
/** Re-join accessed segments into the flat catalog key (`server_tool`). The
|
||||
* server/tool split is cosmetic, so both `tools.a.b` and `tools["a.b"]` resolve. */
|
||||
const toKey = (segments: readonly string[]) => segments.join("_").replaceAll(".", "_")
|
||||
|
||||
/**
|
||||
* Group the flat `server_tool` catalog into per-server namespaces for display.
|
||||
* `servers` are the sanitized MCP client names; the longest matching prefix wins
|
||||
* so a server named `a_b` is preferred over `a` for the key `a_b_tool`. Routing
|
||||
* never depends on this split — it re-joins `${server}_${local}` back to the key.
|
||||
* Group the flat `server_tool` catalog into per-server namespaces. `servers` are
|
||||
* the sanitized MCP client names; the longest matching prefix wins so a server
|
||||
* named `a_b` beats `a` for the key `a_b_tool`.
|
||||
*/
|
||||
export function groupByServer(mcpTools: Record<string, AITool>, servers: readonly string[]): Map<string, NamespacedTool[]> {
|
||||
export function groupByServer(mcpTools: Record<string, AITool>, servers: readonly string[]): Map<string, CatalogEntry[]> {
|
||||
const byLongest = [...servers].sort((a, b) => b.length - a.length)
|
||||
const groups = new Map<string, NamespacedTool[]>()
|
||||
const groups = new Map<string, CatalogEntry[]>()
|
||||
for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) {
|
||||
const server = byLongest.find((name) => key.startsWith(name + "_")) ?? key.slice(0, key.indexOf("_"))
|
||||
const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key
|
||||
const entry = groups.get(server) ?? []
|
||||
entry.push({ local, key, tool: mcpTools[key]! })
|
||||
groups.set(server, entry)
|
||||
const entry: CatalogEntry = {
|
||||
path: `${server}.${local}`,
|
||||
key,
|
||||
server,
|
||||
local,
|
||||
description: mcpTools[key]!.description ?? "",
|
||||
tool: mcpTools[key]!,
|
||||
}
|
||||
groups.set(server, [...(groups.get(server) ?? []), entry])
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -78,26 +97,34 @@ function inputHint(tool: AITool): string {
|
||||
}
|
||||
}
|
||||
|
||||
const firstLine = (text: string | undefined) => (text ?? "").split("\n", 1)[0]!.trim()
|
||||
const signatureFor = (entry: CatalogEntry) =>
|
||||
`tools${access(entry.server)}${access(entry.local)}(${inputHint(entry.tool)})`
|
||||
|
||||
export function describe(groups: Map<string, NamespacedTool[]>): string {
|
||||
/**
|
||||
* The execute tool description: the calling convention, the discovery API, and a
|
||||
* list of namespaces only — never the full tool catalog. Per-tool signatures are
|
||||
* fetched on demand with `tools.describe` so the prompt stays small.
|
||||
*/
|
||||
export function describe(groups: Map<string, CatalogEntry[]>, descriptions?: Map<string, string | undefined>): string {
|
||||
const lines = [
|
||||
"Execute JavaScript with access to connected MCP tools.",
|
||||
"Every connected MCP server is a namespace on `tools`. Call a tool with `await tools.<server>.<tool>(input)`; each returns a Promise.",
|
||||
"Compose multiple calls in one program and `return` the final value — intermediate results stay in the sandbox and never re-enter the conversation.",
|
||||
"Execute JavaScript with access to connected MCP tools, grouped into namespaces (one per MCP server).",
|
||||
"",
|
||||
"Discover tools inside your program, then call them:",
|
||||
"- `await tools.search(query, { namespace?, limit? })` -> `{ items: [{ path, description }], total }`",
|
||||
"- `await tools.describe(path)` -> `{ path, description, signature, inputSchema }`",
|
||||
"- Call a tool by its path: `await tools.<server>.<tool>(input)` or `await tools[path](input)`. Each returns a Promise.",
|
||||
"",
|
||||
"Compose multiple calls in one program and `return` the final value — intermediate results stay in the sandbox and never re-enter the conversation. Use `tools.search('', { namespace })` to list a namespace's tools.",
|
||||
]
|
||||
if (groups.size === 0) {
|
||||
lines.push("", "No MCP servers are currently connected.")
|
||||
return lines.join("\n")
|
||||
}
|
||||
lines.push("", "Available namespaces:")
|
||||
for (const [server, tools] of [...groups].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
lines.push("", `// ${server}`)
|
||||
for (const { local, tool } of tools) {
|
||||
const signature = `tools${access(server)}${access(local)}(${inputHint(tool)})`
|
||||
const summary = firstLine(tool.description)
|
||||
lines.push(summary ? `${signature} // ${summary}` : signature)
|
||||
}
|
||||
for (const [server, entries] of [...groups].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const note = brief(descriptions?.get(server))
|
||||
const count = `${entries.length} tool${entries.length === 1 ? "" : "s"}`
|
||||
lines.push(`- ${server} (${count})${note ? ` — ${note}` : ""}`)
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
@@ -143,12 +170,53 @@ function errorMessage(error: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function define(mcpTools: Record<string, AITool>, servers: readonly string[]) {
|
||||
const groups = groupByServer(mcpTools, servers)
|
||||
export function define(mcpTools: Record<string, AITool>, namespaces: ReadonlyArray<Namespace>) {
|
||||
const groups = groupByServer(
|
||||
mcpTools,
|
||||
namespaces.map((n) => n.name),
|
||||
)
|
||||
const descriptions = new Map(namespaces.map((n) => [n.name, n.description] as const))
|
||||
const catalog: CatalogEntry[] = [...groups.values()].flat()
|
||||
const byKey = new Map(catalog.map((entry) => [entry.key, entry] as const))
|
||||
|
||||
const search = (query: unknown, options: unknown) => {
|
||||
const q = (typeof query === "string" ? query : "").toLowerCase()
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
const describeTool = (path: unknown) => {
|
||||
if (typeof path !== "string") return { error: { code: "invalid_path", message: "describe expects a tool path string." } }
|
||||
const entry = byKey.get(toKey([path]))
|
||||
if (!entry) {
|
||||
const segment = path.split(/[._]/)[0] ?? ""
|
||||
const suggestions = catalog
|
||||
.filter((item) => item.server === segment || item.path.includes(path))
|
||||
.slice(0, 5)
|
||||
.map((item) => item.path)
|
||||
return { error: { code: "tool_not_found", message: `No tool at '${path}'.`, suggestions } }
|
||||
}
|
||||
let inputSchema: unknown
|
||||
try {
|
||||
inputSchema = asSchema(entry.tool.inputSchema).jsonSchema
|
||||
} catch {
|
||||
inputSchema = undefined
|
||||
}
|
||||
return { path: entry.path, description: entry.description, signature: signatureFor(entry), inputSchema }
|
||||
}
|
||||
|
||||
return Tool.define(
|
||||
CODE_MODE_TOOL,
|
||||
Effect.succeed<Tool.DefWithoutID<typeof Parameters, Metadata>>({
|
||||
description: describe(groups),
|
||||
description: describe(groups, descriptions),
|
||||
parameters: Parameters,
|
||||
execute: Effect.fn("CodeMode.execute")(function* (params, ctx) {
|
||||
const run = yield* EffectBridge.make()
|
||||
@@ -171,32 +239,31 @@ export function define(mcpTools: Record<string, AITool>, servers: readonly strin
|
||||
return toolResultValue(result)
|
||||
})
|
||||
|
||||
// `tools.<server>.<tool>(args)` — the server/tool split is cosmetic; routing
|
||||
// re-joins `${server}_${tool}` back into the original flat catalog key.
|
||||
const namespace = (server: string) =>
|
||||
new Proxy(Object.create(null) as Record<string, unknown>, {
|
||||
// Recursive path-accumulating proxy: `tools.<server>.<tool>(args)` and
|
||||
// `tools[path](args)` both resolve to a flat catalog key, while the reserved
|
||||
// top-level `tools.search`/`tools.describe` provide on-demand discovery.
|
||||
const make = (segments: readonly string[]): unknown =>
|
||||
new Proxy(function () {} as object, {
|
||||
get(_target, prop) {
|
||||
if (typeof prop !== "string" || prop === "then") return undefined
|
||||
const key = `${server}_${prop}`
|
||||
return make([...segments, prop])
|
||||
},
|
||||
apply(_target, _thisArg, args: unknown[]) {
|
||||
if (segments.length === 1 && segments[0] === SEARCH) return search(args[0], args[1])
|
||||
if (segments.length === 1 && segments[0] === DESCRIBE) return describeTool(args[0])
|
||||
const key = toKey(segments)
|
||||
const tool = mcpTools[key]
|
||||
if (!tool || !tool.execute) {
|
||||
return () => {
|
||||
throw new Error(`Unknown tool 'tools.${server}.${prop}'. Available: ${Object.keys(mcpTools).join(", ")}`)
|
||||
}
|
||||
}
|
||||
return (args: unknown) => {
|
||||
calls.push(key)
|
||||
return run.promise(invoke(key, tool, args))
|
||||
throw new Error(
|
||||
`Unknown tool 'tools.${segments.join(".")}'. Use tools.search(query) to discover available tools.`,
|
||||
)
|
||||
}
|
||||
calls.push(key)
|
||||
return run.promise(invoke(key, tool, args[0]))
|
||||
},
|
||||
})
|
||||
|
||||
const tools = new Proxy(Object.create(null) as Record<string, unknown>, {
|
||||
get(_target, prop) {
|
||||
if (typeof prop !== "string" || prop === "then") return undefined
|
||||
return namespace(prop)
|
||||
},
|
||||
})
|
||||
const tools = make([])
|
||||
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => new AsyncFunction("tools", params.code)(tools),
|
||||
|
||||
Reference in New Issue
Block a user