feat(opencode): unify code mode discovery under tools.$rune
Remove Rune's vestigial built-in $rune.search/$rune.describe (which only saw effect-Schema Definitions, never our dynamic MCP tools) and the reserved-namespace guard. Move our ranked search and typed describe under tools.$rune.* — the runtime's own namespace, separate from MCP server namespaces and collision-proof since $ never appears in a sanitized server name. One discovery implementation, no dead code.
This commit is contained in:
@@ -20,7 +20,7 @@ const CODE_LIMITS: ExecutionLimits = {
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
code: Schema.String.annotate({
|
||||
description: "JavaScript to run. Discover tools with `tools.search`/`tools.describe`, call them, and `return` the final value.",
|
||||
description: "JavaScript to run. Discover tools with `tools.$rune.search`/`tools.$rune.describe`, call them, and `return` the final value.",
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -42,6 +42,10 @@ export type Envelope = { result: unknown; attachments?: Attachment[] }
|
||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||
const SEARCH = "search"
|
||||
const DESCRIBE = "describe"
|
||||
// The runtime's own capabilities live under `tools.$rune.*`, separated from the
|
||||
// MCP server namespaces. `$` can never appear in a sanitized server name, so this
|
||||
// namespace is collision-proof.
|
||||
const RUNE_NS = "$rune"
|
||||
|
||||
type CatalogEntry = {
|
||||
path: string
|
||||
@@ -164,15 +168,16 @@ const PREVIEW_BUDGET = 2000
|
||||
/**
|
||||
* The execute tool description: the calling convention, the discovery API, and the
|
||||
* list of namespaces. A budgeted preview of individual tools is inlined; the full
|
||||
* per-tool signatures are still fetched on demand with `tools.describe`.
|
||||
* per-tool signatures are still fetched on demand with `tools.$rune.describe`.
|
||||
*/
|
||||
export function describe(groups: Map<string, CatalogEntry[]>): string {
|
||||
const lines = [
|
||||
"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, outputSchema? }`",
|
||||
"The runtime provides two discovery capabilities under `tools.$rune` (its own namespace, separate",
|
||||
"from your MCP servers):",
|
||||
"- `await tools.$rune.search(query, { namespace?, limit? })` -> `{ items: [{ path, description }], total }`",
|
||||
"- `await tools.$rune.describe(path)` -> `{ path, description, signature, inputSchema, outputSchema? }`",
|
||||
"- Call a tool by its path: `await tools.<server>.<tool>(input)`. Each resolves to `{ result, attachments? }`.",
|
||||
"",
|
||||
"Every tool call and your final `return` use the same envelope: `{ result, attachments? }`.",
|
||||
@@ -181,13 +186,13 @@ export function describe(groups: Map<string, CatalogEntry[]>): string {
|
||||
"contents of an attachment in code — only pass it along.",
|
||||
"",
|
||||
"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.",
|
||||
"sandbox and never re-enter the conversation. Use `tools.$rune.search('', { namespace })` to list a namespace.",
|
||||
]
|
||||
if (groups.size === 0) {
|
||||
lines.push("", "No MCP servers are currently connected.")
|
||||
return lines.join("\n")
|
||||
}
|
||||
lines.push("", "Available namespaces (use tools.search / tools.describe to explore tools not shown):")
|
||||
lines.push("", "Available namespaces (use tools.$rune.search / tools.$rune.describe to explore tools not shown):")
|
||||
let used = 0
|
||||
let previewing = true
|
||||
for (const [server, entries] of [...groups].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
@@ -456,11 +461,14 @@ export function define(
|
||||
})
|
||||
|
||||
// The Rune host-tool tree: per-server namespaces (`tools.<server>.<tool>`)
|
||||
// plus the top-level discovery helpers. The interpreter resolves and invokes
|
||||
// these; approving `execute` does not approve any child call.
|
||||
// plus the runtime's own discovery capabilities under `tools.$rune.*`. The
|
||||
// interpreter resolves and invokes these; approving `execute` does not
|
||||
// approve any child call.
|
||||
const tools: HostTools = {
|
||||
[SEARCH]: (query: unknown, options: unknown) => Effect.succeed(search(query, options)),
|
||||
[DESCRIBE]: (path: unknown) => Effect.succeed(describeTool(path)),
|
||||
[RUNE_NS]: {
|
||||
[SEARCH]: (query: unknown, options: unknown) => Effect.succeed(search(query, options)),
|
||||
[DESCRIBE]: (path: unknown) => Effect.succeed(describeTool(path)),
|
||||
},
|
||||
}
|
||||
for (const entry of catalog) {
|
||||
if (!entry.tool.execute) continue
|
||||
@@ -487,11 +495,10 @@ export function define(
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
} satisfies Tool.ExecuteResult<Metadata>
|
||||
}
|
||||
// Rune's built-in unknown-capability hint points at `$rune.search`; redirect
|
||||
// the model to this integration's actual discovery entrypoint instead.
|
||||
// Point the model at discovery when it references a tool that does not exist.
|
||||
const hint =
|
||||
result.error.kind === "UnknownCapability"
|
||||
? "\nUse tools.search(query) to discover available tools."
|
||||
? "\nUse tools.$rune.search(query) to discover available tools."
|
||||
: ""
|
||||
return {
|
||||
title: "Code mode",
|
||||
|
||||
@@ -27,8 +27,6 @@ export type ToolDescription = {
|
||||
|
||||
export type SafeObject = Record<string, unknown>
|
||||
|
||||
const reservedNamespace = "$rune"
|
||||
|
||||
export class ToolReference {
|
||||
constructor(readonly path: ReadonlyArray<string>) {}
|
||||
}
|
||||
@@ -149,11 +147,9 @@ const visibleDefinitions = <R>(tools: HostTools<R>) =>
|
||||
export const catalog = <R>(tools: HostTools<R>): ReadonlyArray<ToolDescription> =>
|
||||
visibleDefinitions(tools).map(({ description }) => description)
|
||||
|
||||
export const assertValidTools = <R>(tools: HostTools<R>): void => {
|
||||
if (Object.hasOwn(tools, reservedNamespace)) {
|
||||
throw new Error(`Tool namespace '${reservedNamespace}' is reserved for Rune discovery capabilities.`)
|
||||
}
|
||||
}
|
||||
// Discovery is provided by the embedder as ordinary host tools (e.g. under a
|
||||
// `$rune` namespace), not by the runtime, so there are no reserved namespaces.
|
||||
export const assertValidTools = <R>(_tools: HostTools<R>): void => {}
|
||||
|
||||
export const instructions = <R>(tools: HostTools<R>): string => {
|
||||
const described = catalog(tools)
|
||||
@@ -214,7 +210,6 @@ export const make = <R>(
|
||||
): ToolRuntime<R> => {
|
||||
const calls: Array<ToolCall> = []
|
||||
let auditBytes = 0
|
||||
const visibleCatalog = visibleDefinitions(tools)
|
||||
|
||||
const checkedCopyIn = (value: unknown, label: string): unknown => {
|
||||
const copied = copyIn(value, label, dataLimits)
|
||||
@@ -248,40 +243,6 @@ export const make = <R>(
|
||||
throw new ToolRuntimeError("InvalidDataValue", `Arguments for tool '${name}' exceed ${dataLimits.maxDataBytes} bytes.`)
|
||||
}
|
||||
const call = { name }
|
||||
if (name === "$rune.search") {
|
||||
const input = externalArgs[0]
|
||||
if (externalArgs.length !== 1 || input === null || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new ToolRuntimeError("InvalidToolInput", "tools.$rune.search expects { query?: string; limit?: number }.")
|
||||
}
|
||||
const request = input as { query?: unknown; limit?: unknown }
|
||||
if (request.query !== undefined && typeof request.query !== "string") {
|
||||
throw new ToolRuntimeError("InvalidToolInput", "tools.$rune.search query must be a string when provided.")
|
||||
}
|
||||
if (request.limit !== undefined && (typeof request.limit !== "number" || !Number.isFinite(request.limit) || request.limit <= 0)) {
|
||||
throw new ToolRuntimeError("InvalidToolInput", "tools.$rune.search limit must be a positive number when provided.")
|
||||
}
|
||||
const query = typeof request.query === "string" ? request.query.toLowerCase() : ""
|
||||
recordCall(call)
|
||||
const matched = visibleCatalog
|
||||
.filter((item) => `${item.path} ${item.definition.description}`.toLowerCase().includes(query))
|
||||
.map((item) => ({ path: item.path, description: item.definition.description }))
|
||||
const limit = typeof request.limit === "number" ? Math.floor(request.limit) : 12
|
||||
return checkedCopyIn({ items: matched.slice(0, limit), total: matched.length }, "Result from tool '$rune.search'")
|
||||
}
|
||||
if (name === "$rune.describe") {
|
||||
const input = externalArgs[0]
|
||||
const requested = input !== null && typeof input === "object" && !Array.isArray(input)
|
||||
? (input as { path?: unknown }).path
|
||||
: undefined
|
||||
if (externalArgs.length !== 1 || typeof requested !== "string") {
|
||||
throw new ToolRuntimeError("InvalidToolInput", "tools.$rune.describe expects { path: string }.")
|
||||
}
|
||||
recordCall(call)
|
||||
const found = visibleCatalog.find((item) => item.path === requested)
|
||||
if (!found) throw new ToolRuntimeError("UnknownCapability", `Unknown tool '${String(requested)}'.`)
|
||||
return checkedCopyIn(found.description, "Result from tool '$rune.describe'")
|
||||
}
|
||||
|
||||
const tool = resolve(tools, path)
|
||||
let describedInput: unknown
|
||||
if (isDefinition(tool)) {
|
||||
|
||||
@@ -114,7 +114,7 @@ beforeAll(async () => {
|
||||
|
||||
describe("code mode integration (real MCP server)", () => {
|
||||
test("describe exposes the typed return signature from the tool's outputSchema", async () => {
|
||||
const out = await run("return await tools.describe('fixtures.add')")
|
||||
const out = await run("return await tools.$rune.describe('fixtures.add')")
|
||||
const desc = JSON.parse(out.output)
|
||||
expect(desc.path).toBe("fixtures.add")
|
||||
expect(desc.signature).toBe(
|
||||
@@ -124,13 +124,13 @@ describe("code mode integration (real MCP server)", () => {
|
||||
})
|
||||
|
||||
test("describe falls back to result: unknown when no outputSchema is declared", async () => {
|
||||
const out = await run("return await tools.describe('fixtures.get_text')")
|
||||
const out = await run("return await tools.$rune.describe('fixtures.get_text')")
|
||||
const desc = JSON.parse(out.output)
|
||||
expect(desc.signature).toContain("Promise<{ result: unknown; attachments?: Attachment[] }>")
|
||||
})
|
||||
|
||||
test("search finds a tool by keyword", async () => {
|
||||
const out = await run("return await tools.search('screenshot')")
|
||||
const out = await run("return await tools.$rune.search('screenshot')")
|
||||
const result = JSON.parse(out.output)
|
||||
expect(result.items.map((i: any) => i.path)).toContain("fixtures.screenshot")
|
||||
})
|
||||
@@ -196,8 +196,8 @@ describe("code mode integration (real MCP server)", () => {
|
||||
tool.execute(
|
||||
{
|
||||
code: `
|
||||
await tools.search('add')
|
||||
await tools.describe('fixtures.add')
|
||||
await tools.$rune.search('add')
|
||||
await tools.$rune.describe('fixtures.add')
|
||||
await tools.fixtures.add({ a: 1, b: 1 })
|
||||
return 'done'
|
||||
`,
|
||||
|
||||
@@ -76,14 +76,14 @@ describe("code mode execute", () => {
|
||||
)
|
||||
const description = describeTools(groups)
|
||||
|
||||
expect(description).toContain("tools.search(query")
|
||||
expect(description).toContain("tools.describe(path)")
|
||||
expect(description).toContain("tools.$rune.search(query")
|
||||
expect(description).toContain("tools.$rune.describe(path)")
|
||||
expect(description).toContain("- github (2 tools)")
|
||||
expect(description).toContain("- linear (1 tool)")
|
||||
// Small catalog: individual tools are previewed inline as `<server>.<tool>`.
|
||||
expect(description).toContain("github.create_issue")
|
||||
expect(description).toContain("linear.search")
|
||||
// ...but never full signatures (those come from tools.describe).
|
||||
// ...but never full signatures (those come from tools.$rune.describe).
|
||||
expect(description).not.toContain("): Promise<")
|
||||
})
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("code mode execute", () => {
|
||||
expect(description).toContain("alpha.op_0")
|
||||
})
|
||||
|
||||
test("tools.search and tools.describe expose the catalog on demand", async () => {
|
||||
test("tools.$rune.search and tools.$rune.describe expose the catalog on demand", async () => {
|
||||
const tool = await build({
|
||||
github_create_issue: mcpTool("create_issue", () => "", {
|
||||
type: "object",
|
||||
@@ -119,14 +119,14 @@ describe("code mode execute", () => {
|
||||
})
|
||||
|
||||
const searched = await Effect.runPromise(
|
||||
tool.execute({ code: "return await tools.search('issue', { namespace: 'github' })" }, ctx),
|
||||
tool.execute({ code: "return await tools.$rune.search('issue', { namespace: 'github' })" }, ctx),
|
||||
)
|
||||
const search = JSON.parse(searched.output)
|
||||
expect(search.total).toBe(2)
|
||||
expect(search.items.map((i: any) => i.path).sort()).toEqual(["github.create_issue", "github.list_issues"])
|
||||
|
||||
const described = await Effect.runPromise(
|
||||
tool.execute({ code: "return await tools.describe('github.create_issue')" }, ctx),
|
||||
tool.execute({ code: "return await tools.$rune.describe('github.create_issue')" }, ctx),
|
||||
)
|
||||
const desc = JSON.parse(described.output)
|
||||
expect(desc.path).toBe("github.create_issue")
|
||||
@@ -134,7 +134,7 @@ describe("code mode execute", () => {
|
||||
"tools.github.create_issue(input: { title: string; body?: string }): Promise<{ result: unknown; attachments?: Attachment[] }>",
|
||||
)
|
||||
|
||||
const missing = await Effect.runPromise(tool.execute({ code: "return await tools.describe('github.nope')" }, ctx))
|
||||
const missing = await Effect.runPromise(tool.execute({ code: "return await tools.$rune.describe('github.nope')" }, ctx))
|
||||
expect(JSON.parse(missing.output).error.code).toBe("tool_not_found")
|
||||
})
|
||||
|
||||
@@ -223,7 +223,7 @@ describe("code mode execute", () => {
|
||||
const output = await Effect.runPromise(tool.execute({ code: "return await tools.known.missing({})" }, ctx))
|
||||
expect(output.metadata.error).toBe(true)
|
||||
expect(output.output).toContain("Unknown tool 'known.missing'")
|
||||
expect(output.output).toContain("tools.search")
|
||||
expect(output.output).toContain("tools.$rune.search")
|
||||
})
|
||||
|
||||
test("propagates an MCP tool error into the program", async () => {
|
||||
@@ -306,7 +306,7 @@ describe("code mode execute", () => {
|
||||
} as any,
|
||||
}
|
||||
const tool = await build(tools, defs)
|
||||
const described = await Effect.runPromise(tool.execute({ code: "return await tools.describe('weather.current')" }, ctx))
|
||||
const described = await Effect.runPromise(tool.execute({ code: "return await tools.$rune.describe('weather.current')" }, ctx))
|
||||
const desc = JSON.parse(described.output)
|
||||
expect(desc.signature).toBe(
|
||||
"tools.weather.current(input: { city?: string }): Promise<{ result: { tempC: number; summary?: string }; attachments?: Attachment[] }>",
|
||||
@@ -342,7 +342,7 @@ describe("code mode execute", () => {
|
||||
}),
|
||||
other_noop: mcpTool("noop", () => ""),
|
||||
})
|
||||
const out = await Effect.runPromise(tool.execute({ code: "return await tools.search('trace_id')" }, ctx))
|
||||
const out = await Effect.runPromise(tool.execute({ code: "return await tools.$rune.search('trace_id')" }, ctx))
|
||||
const result = JSON.parse(out.output)
|
||||
expect(result.items.map((i: any) => i.path)).toEqual(["traces.lookup"])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user