diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 8816f5666e..8289d90964 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -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 { 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..(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 { "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..`) - // 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 } - // 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", diff --git a/packages/opencode/src/session/rune/tool-runtime.ts b/packages/opencode/src/session/rune/tool-runtime.ts index 4501493374..77e74f6e6a 100644 --- a/packages/opencode/src/session/rune/tool-runtime.ts +++ b/packages/opencode/src/session/rune/tool-runtime.ts @@ -27,8 +27,6 @@ export type ToolDescription = { export type SafeObject = Record -const reservedNamespace = "$rune" - export class ToolReference { constructor(readonly path: ReadonlyArray) {} } @@ -149,11 +147,9 @@ const visibleDefinitions = (tools: HostTools) => export const catalog = (tools: HostTools): ReadonlyArray => visibleDefinitions(tools).map(({ description }) => description) -export const assertValidTools = (tools: HostTools): 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 = (_tools: HostTools): void => {} export const instructions = (tools: HostTools): string => { const described = catalog(tools) @@ -214,7 +210,6 @@ export const make = ( ): ToolRuntime => { const calls: Array = [] 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 = ( 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)) { diff --git a/packages/opencode/test/session/code-mode-integration.test.ts b/packages/opencode/test/session/code-mode-integration.test.ts index 6cb0fd86de..35497544b4 100644 --- a/packages/opencode/test/session/code-mode-integration.test.ts +++ b/packages/opencode/test/session/code-mode-integration.test.ts @@ -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' `, diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 21ca486c63..38c7ba1687 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -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 `.`. 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"]) })