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:
Aiden Cline
2026-06-29 18:39:55 -05:00
parent b0aa6bfb61
commit cad83ab53e
3 changed files with 177 additions and 77 deletions
+111 -44
View File
@@ -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),