From 49f20b6a30b7baa0b428adeb1d59428b108b6813 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 29 Jun 2026 18:18:25 -0500 Subject: [PATCH 01/41] feat(opencode): add experimental code mode execute tool Add an experimental, off-by-default `execute` tool that runs LLM-authored JavaScript with a `tools.(args)` proxy over connected MCP tools. When OPENCODE_EXPERIMENTAL_CODE_MODE is enabled and MCP tools are present, the session exposes the single code-mode tool instead of registering each MCP tool directly; child calls route through the native permission path. Code mode is defined via the standard Tool.define/Tool.init machinery so it inherits arg decoding and output truncation from the shared wrapper. Tool results are reduced to structured content or text, and the program's return value is coerced to text without failing on shape. Note: execution currently uses an in-process AsyncFunction with no isolation or timeout; sandboxing is tracked as follow-up work. --- packages/opencode/src/effect/runtime-flags.ts | 1 + packages/opencode/src/session/code-mode.ts | 143 ++++++++++++++ packages/opencode/src/session/prompt.ts | 2 + packages/opencode/src/session/tools.ts | 21 ++- .../opencode/test/session/code-mode.test.ts | 175 ++++++++++++++++++ 5 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/session/code-mode.ts create mode 100644 packages/opencode/test/session/code-mode.test.ts diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 1f8e59011192..ef0d0ec610e0 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -43,6 +43,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"), experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"), experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"), + experimentalCodeMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"), experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"), experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"), experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts new file mode 100644 index 000000000000..74a1a376323d --- /dev/null +++ b/packages/opencode/src/session/code-mode.ts @@ -0,0 +1,143 @@ +import { Tool } from "@/tool/tool" +import { EffectBridge } from "@/effect/bridge" +import type { Tool as AITool } from "ai" +import { Effect, Schema } from "effect" + +export const CODE_MODE_TOOL = "execute" + +export const Parameters = Schema.Struct({ + code: Schema.String.annotate({ + description: "JavaScript to run. Call tools as `await tools.(args)` and `return` the final value.", + }), +}) + +type Metadata = { + toolCalls: string[] + error?: boolean +} + +// `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 { + new (...args: string[]): (...args: unknown[]) => Promise +} + +function describe(mcpTools: Record) { + const names = Object.keys(mcpTools).sort((a, b) => a.localeCompare(b)) + return [ + "Execute JavaScript with access to connected MCP tools.", + "Each tool is callable as `await tools.(args)`; `return` the final value.", + names.length > 0 ? `Available tools: ${names.join(", ")}` : "No MCP tools are currently connected.", + ].join("\n") +} + +/** + * Reduce an MCP tool result to the value the program should see: structured + * content when present, otherwise the joined text blocks, otherwise the raw + * result. Mirrors how the model-facing output is derived elsewhere. + */ +export function toolResultValue(result: unknown): unknown { + if (result === null || typeof result !== "object") return result + const record = result as { structuredContent?: unknown; content?: unknown } + if (record.structuredContent !== undefined && record.structuredContent !== null) return record.structuredContent + if (Array.isArray(record.content)) { + const text = record.content + .filter((item): item is { type: "text"; text: string } => item?.type === "text" && typeof item.text === "string") + .map((item) => item.text) + .join("\n") + if (text.length > 0) return text + return record.content + } + return result +} + +/** Coerce the program's return value to model-facing text without ever failing on shape. */ +export function formatValue(value: unknown): string { + if (typeof value === "string") return value + if (value === undefined) return "undefined" + try { + return JSON.stringify(value, null, 2) ?? String(value) + } catch { + return String(value) + } +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message + if (typeof error === "string") return error + try { + return JSON.stringify(error) ?? String(error) + } catch { + return String(error) + } +} + +export function define(mcpTools: Record) { + return Tool.define( + CODE_MODE_TOOL, + Effect.succeed>({ + description: describe(mcpTools), + parameters: Parameters, + execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { + const run = yield* EffectBridge.make() + const calls: string[] = [] + + // Each `tools.(args)` call runs the native MCP tool through the + // permission gate, so approving `execute` does not approve every child call. + const invoke = (name: string, tool: AITool, args: unknown) => + Effect.gen(function* () { + yield* ctx.ask({ permission: name, metadata: {}, patterns: ["*"], always: ["*"] }) + const result = yield* Effect.promise(() => + Promise.resolve( + tool.execute!(args ?? {}, { + toolCallId: ctx.callID ?? name, + abortSignal: ctx.abort, + messages: [], + }), + ), + ) + return toolResultValue(result) + }) + + const tools = new Proxy(Object.create(null) as Record, { + get(_target, prop) { + if (typeof prop !== "string" || prop === "then") return undefined + const tool = mcpTools[prop] + if (!tool || !tool.execute) { + return () => { + throw new Error( + `Unknown tool '${prop}'. Available tools: ${Object.keys(mcpTools).join(", ") || "(none)"}`, + ) + } + } + return (args: unknown) => { + calls.push(prop) + return run.promise(invoke(prop, tool, args)) + } + }, + }) + + return yield* Effect.tryPromise({ + try: () => new AsyncFunction("tools", params.code)(tools), + catch: (error) => error, + }).pipe( + Effect.map( + (value) => + ({ + title: "Code mode", + metadata: { toolCalls: calls }, + output: formatValue(value), + }) satisfies Tool.ExecuteResult, + ), + Effect.catch((error) => + Effect.succeed({ + title: "Code mode", + metadata: { toolCalls: calls, error: true }, + output: errorMessage(error), + } satisfies Tool.ExecuteResult), + ), + ) + }), + }), + ) +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 60937588120f..3786de46b518 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1237,6 +1237,8 @@ export const layer = Layer.effect( Effect.provideService(ToolRegistry.Service, registry), Effect.provideService(MCP.Service, mcp), Effect.provideService(Truncate.Service, truncate), + Effect.provideService(Agent.Service, agents), + Effect.provideService(RuntimeFlags.Service, flags), ) if (lastUser.format?.type === "json_schema") { diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 376ba8f2b861..e7eb1ff9b0ce 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -21,6 +21,8 @@ import { EffectBridge } from "@/effect/bridge" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { isRecord } from "@/util/record" +import { RuntimeFlags } from "@/effect/runtime-flags" +import * as CodeModeTool from "./code-mode" const MCP_RESOURCE_TOOLS = { list: "list_mcp_resources", @@ -52,6 +54,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const registry = yield* ToolRegistry.Service const mcp = yield* MCP.Service const truncate = yield* Truncate.Service + const flags = yield* RuntimeFlags.Service const context = (args: Record, options: ToolExecutionOptions): Tool.Context => ({ sessionID: input.session.id, @@ -86,11 +89,21 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { .pipe(Effect.orDie), }) - for (const item of yield* registry.tools({ + const mcpTools = yield* mcp.tools() + // When code mode is enabled and MCP tools are present, expose them through the + // single code-mode `execute` tool instead of registering each MCP tool directly + // (see the early return below). Code mode is experimental and off by default. + const codeModeTool = + flags.experimentalCodeMode && Object.keys(mcpTools).length > 0 + ? yield* Tool.init(yield* CodeModeTool.define(mcpTools)) + : undefined + const registryTools = yield* registry.tools({ modelID: ModelV2.ID.make(input.model.api.id), providerID: input.model.providerID, agent: input.agent, - })) { + }) + + for (const item of codeModeTool ? [...registryTools, codeModeTool] : registryTools) { const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item)) tools[item.id] = tool({ description: item.description, @@ -381,7 +394,9 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }) } - for (const [key, item] of Object.entries(yield* mcp.tools())) { + if (codeModeTool) return tools + + for (const [key, item] of Object.entries(mcpTools)) { const execute = item.execute if (!execute) continue diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts new file mode 100644 index 000000000000..623c2881e8ba --- /dev/null +++ b/packages/opencode/test/session/code-mode.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test" +import { Parameters, define, formatValue, toolResultValue } from "@/session/code-mode" +import { Agent } from "@/agent/agent" +import { Tool } from "@/tool/tool" +import * as Truncate from "@/tool/truncate" +import { McpCatalog } from "@/mcp/catalog" +import { MessageID, SessionID } from "@/session/schema" +import type { Tool as AITool } from "ai" +import { Effect, Layer, Schema } from "effect" + +const ctx: Tool.Context = { + sessionID: SessionID.make("ses_code-mode"), + messageID: MessageID.make("msg_code-mode"), + agent: "build", + abort: new AbortController().signal, + callID: "call_code_mode", + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +// Build a real MCP-derived AI SDK tool over a fake transport, so the proxy exercises +// the same `convertTool` execution path that `mcp.tools()` produces at runtime. +function mcpTool(name: string, handler: (args: Record) => unknown): AITool { + const client = { + callTool: async (params: { arguments?: Record }) => handler(params.arguments ?? {}), + } + return McpCatalog.convertTool( + { name, description: name, inputSchema: { type: "object", properties: {} } } as any, + client as any, + ) +} + +// Truncate echoes its input so assertions read the exact program output. Agent.get is +// only consulted by the shared wrapper during truncation. +const layer = Layer.mergeAll( + Layer.mock(Truncate.Service, { + output: (text: string) => Effect.succeed({ content: text, truncated: false as const }), + }), + Layer.succeed(Agent.Service, Agent.Service.of({ get: () => Effect.succeed({ name: "build" } as any) } as any)), +) + +function build(mcpTools: Record) { + return Effect.runPromise(define(mcpTools).pipe(Effect.flatMap(Tool.init), Effect.provide(layer))) +} + +describe("code mode execute", () => { + test("defines execute input with an Effect schema", async () => { + const decode = Schema.decodeUnknownEffect(Parameters) + await expect(Effect.runPromise(decode({ code: "return 1" }))).resolves.toEqual({ code: "return 1" }) + await expect(Effect.runPromise(decode({}))).rejects.toThrow() + }) + + test("lists available tools in the description", async () => { + const tool = await build({ beta_b: mcpTool("b", () => "b"), alpha_a: mcpTool("a", () => "a") }) + expect(tool.description).toContain("Available tools: alpha_a, beta_b") + }) + + test("runs plain JavaScript and returns the value as text", async () => { + const tool = await build({}) + const output = await Effect.runPromise(tool.execute({ code: "return 1 + 2" }, ctx)) + expect(output.output).toBe("3") + expect(output.metadata.toolCalls).toEqual([]) + }) + + test("calls an MCP tool and flows its text result back into the program", async () => { + const seen: Record[] = [] + const tool = await build({ + greeter_hello: mcpTool("hello", (args) => { + seen.push(args) + return { content: [{ type: "text", text: `hello ${args.name}` }] } + }), + }) + + const output = await Effect.runPromise( + tool.execute({ code: "const r = await tools.greeter_hello({ name: 'world' }); return r.toUpperCase()" }, ctx), + ) + + expect(seen).toEqual([{ name: "world" }]) + expect(output.output).toBe("HELLO WORLD") + expect(output.metadata.toolCalls).toEqual(["greeter_hello"]) + }) + + test("exposes structured content as data and composes multiple calls", async () => { + const tool = await build({ + math_add: mcpTool("add", (args) => ({ + content: [], + structuredContent: { sum: (args.a as number) + (args.b as number) }, + })), + }) + + const output = await Effect.runPromise( + tool.execute( + { + code: ` + const first = await tools.math_add({ a: 1, b: 2 }) + const second = await tools.math_add({ a: first.sum, b: 10 }) + return { total: second.sum } + `, + }, + ctx, + ), + ) + + expect(JSON.parse(output.output)).toEqual({ total: 13 }) + expect(output.metadata.toolCalls).toEqual(["math_add", "math_add"]) + }) + + test("runs tool calls in parallel with Promise.all", async () => { + const tool = await build({ + echo_one: mcpTool("one", () => ({ content: [{ type: "text", text: "1" }] })), + echo_two: mcpTool("two", () => ({ content: [{ type: "text", text: "2" }] })), + }) + + const output = await Effect.runPromise( + tool.execute( + { code: "const [a, b] = await Promise.all([tools.echo_one({}), tools.echo_two({})]); return a + b" }, + ctx, + ), + ) + + expect(output.output).toBe("12") + expect(output.metadata.toolCalls.sort()).toEqual(["echo_one", "echo_two"]) + }) + + test("returns a readable error when the program throws", async () => { + const tool = await build({}) + const output = await Effect.runPromise(tool.execute({ code: "throw new Error('boom')" }, ctx)) + expect(output.output).toBe("boom") + expect(output.metadata.error).toBe(true) + }) + + test("reports an unknown tool with the available names", async () => { + const tool = await build({ known_tool: mcpTool("tool", () => "ok") }) + const output = await Effect.runPromise(tool.execute({ code: "return await tools.missing({})" }, ctx)) + expect(output.metadata.error).toBe(true) + expect(output.output).toContain("Unknown tool 'missing'") + expect(output.output).toContain("known_tool") + }) + + test("propagates an MCP tool error into the program", async () => { + const tool = await build({ + bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "server exploded" }] })), + }) + const output = await Effect.runPromise( + tool.execute( + { code: "try { await tools.bad_tool({}) } catch (e) { return 'caught: ' + e.message }" }, + ctx, + ), + ) + expect(output.output).toBe("caught: server exploded") + }) + + test("asks permission before each child tool call", async () => { + const asked: unknown[] = [] + const permissionCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req)) } + const ok = () => ({ content: [{ type: "text", text: "ok" }] }) + const tool = await build({ a_tool: mcpTool("a", ok), b_tool: mcpTool("b", ok) }) + + await Effect.runPromise( + tool.execute({ code: "await tools.a_tool({}); await tools.b_tool({}); return 'done'" }, permissionCtx), + ) + + expect(asked.map((req: any) => req.permission)).toEqual(["a_tool", "b_tool"]) + }) + + test("unit: toolResultValue and formatValue", () => { + expect(toolResultValue({ structuredContent: { x: 1 }, content: [] })).toEqual({ x: 1 }) + expect(toolResultValue({ content: [{ type: "text", text: "hi" }] })).toBe("hi") + expect(toolResultValue("raw")).toBe("raw") + expect(formatValue("text")).toBe("text") + expect(formatValue({ a: 1 })).toBe(JSON.stringify({ a: 1 }, null, 2)) + expect(formatValue(undefined)).toBe("undefined") + }) +}) From b0aa6bfb61f134a53d19ecbee127dc84199935dc Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 29 Jun 2026 18:33:08 -0500 Subject: [PATCH 02/41] feat(opencode): namespace code mode tools by MCP server Expose connected MCP tools to code mode as per-server namespaces (`tools..(args)`) and generate the execute tool description from the catalog: one namespace block per server, each tool rendered with a TypeScript-style input signature derived from its JSON schema plus its description, and the calling convention stated up front. The server/tool split is cosmetic; child-call routing re-joins the segments into the existing flat catalog key, so it stays exact regardless of underscores in server or tool names. --- packages/opencode/src/session/code-mode.ts | 137 ++++++++++++++---- packages/opencode/src/session/tools.ts | 10 +- .../opencode/test/session/code-mode.test.ts | 67 ++++++--- 3 files changed, 165 insertions(+), 49 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 74a1a376323d..f4894c13618d 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -1,13 +1,13 @@ import { Tool } from "@/tool/tool" import { EffectBridge } from "@/effect/bridge" -import type { Tool as AITool } from "ai" +import { asSchema, type Tool as AITool, type JSONSchema7 } from "ai" import { Effect, Schema } from "effect" export const CODE_MODE_TOOL = "execute" export const Parameters = Schema.Struct({ code: Schema.String.annotate({ - description: "JavaScript to run. Call tools as `await tools.(args)` and `return` the final value.", + description: "JavaScript to run. Call tools as `await tools..(input)` and `return` the final value.", }), }) @@ -22,19 +22,90 @@ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as new (...args: string[]): (...args: unknown[]) => Promise } -function describe(mcpTools: Record) { - const names = Object.keys(mcpTools).sort((a, b) => a.localeCompare(b)) - return [ +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ + +type NamespacedTool = { local: string; key: string; tool: AITool } + +/** + * 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. + */ +export function groupByServer(mcpTools: Record, servers: readonly string[]): Map { + const byLongest = [...servers].sort((a, b) => b.length - a.length) + const groups = new Map() + 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) + } + return groups +} + +const access = (segment: string) => (IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`) + +function jsonType(def: JSONSchema7 | boolean | undefined): string { + if (!def || typeof def === "boolean") return "any" + if (Array.isArray(def.enum)) return def.enum.map((value) => JSON.stringify(value)).join(" | ") + const type = Array.isArray(def.type) ? def.type[0] : def.type + switch (type) { + case "integer": + return "number" + case "array": + return "any[]" + case undefined: + return "any" + default: + return type + } +} + +function inputHint(tool: AITool): string { + try { + const schema = asSchema(tool.inputSchema).jsonSchema as JSONSchema7 | undefined + const props = schema?.properties + if (!props || typeof props !== "object") return "input" + const required = new Set(Array.isArray(schema?.required) ? schema.required : []) + const fields = Object.entries(props).map( + ([name, def]) => `${name}${required.has(name) ? "" : "?"}: ${jsonType(def as JSONSchema7)}`, + ) + return fields.length > 0 ? `{ ${fields.join("; ")} }` : "{}" + } catch { + return "input" + } +} + +const firstLine = (text: string | undefined) => (text ?? "").split("\n", 1)[0]!.trim() + +export function describe(groups: Map): string { + const lines = [ "Execute JavaScript with access to connected MCP tools.", - "Each tool is callable as `await tools.(args)`; `return` the final value.", - names.length > 0 ? `Available tools: ${names.join(", ")}` : "No MCP tools are currently connected.", - ].join("\n") + "Every connected MCP server is a namespace on `tools`. Call a tool with `await tools..(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.", + ] + 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) + } + } + return lines.join("\n") } /** * Reduce an MCP tool result to the value the program should see: structured * content when present, otherwise the joined text blocks, otherwise the raw - * result. Mirrors how the model-facing output is derived elsewhere. + * result. */ export function toolResultValue(result: unknown): unknown { if (result === null || typeof result !== "object") return result @@ -72,25 +143,26 @@ function errorMessage(error: unknown): string { } } -export function define(mcpTools: Record) { +export function define(mcpTools: Record, servers: readonly string[]) { + const groups = groupByServer(mcpTools, servers) return Tool.define( CODE_MODE_TOOL, Effect.succeed>({ - description: describe(mcpTools), + description: describe(groups), parameters: Parameters, execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { const run = yield* EffectBridge.make() const calls: string[] = [] - // Each `tools.(args)` call runs the native MCP tool through the - // permission gate, so approving `execute` does not approve every child call. - const invoke = (name: string, tool: AITool, args: unknown) => + // Each tool call runs the native MCP tool through the permission gate, so + // approving `execute` does not approve every child call. + const invoke = (key: string, tool: AITool, args: unknown) => Effect.gen(function* () { - yield* ctx.ask({ permission: name, metadata: {}, patterns: ["*"], always: ["*"] }) + yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) const result = yield* Effect.promise(() => Promise.resolve( tool.execute!(args ?? {}, { - toolCallId: ctx.callID ?? name, + toolCallId: ctx.callID ?? key, abortSignal: ctx.abort, messages: [], }), @@ -99,21 +171,30 @@ export function define(mcpTools: Record) { return toolResultValue(result) }) + // `tools..(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, { + get(_target, prop) { + if (typeof prop !== "string" || prop === "then") return undefined + const key = `${server}_${prop}` + 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)) + } + }, + }) + const tools = new Proxy(Object.create(null) as Record, { get(_target, prop) { if (typeof prop !== "string" || prop === "then") return undefined - const tool = mcpTools[prop] - if (!tool || !tool.execute) { - return () => { - throw new Error( - `Unknown tool '${prop}'. Available tools: ${Object.keys(mcpTools).join(", ") || "(none)"}`, - ) - } - } - return (args: unknown) => { - calls.push(prop) - return run.promise(invoke(prop, tool, args)) - } + return namespace(prop) }, }) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index e7eb1ff9b0ce..115c1bd3146a 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -22,6 +22,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { isRecord } from "@/util/record" import { RuntimeFlags } from "@/effect/runtime-flags" +import { McpCatalog } from "@/mcp/catalog" import * as CodeModeTool from "./code-mode" const MCP_RESOURCE_TOOLS = { @@ -93,9 +94,16 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { // When code mode is enabled and MCP tools are present, expose them through the // single code-mode `execute` tool instead of registering each MCP tool directly // (see the early return below). Code mode is experimental and off by default. + // Sanitized client names give code mode the per-server namespaces; tool keys are + // `sanitize(server)_sanitize(tool)`, so the names match the catalog key prefixes. const codeModeTool = flags.experimentalCodeMode && Object.keys(mcpTools).length > 0 - ? yield* Tool.init(yield* CodeModeTool.define(mcpTools)) + ? yield* Tool.init( + yield* CodeModeTool.define( + mcpTools, + Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize), + ), + ) : undefined const registryTools = yield* registry.tools({ modelID: ModelV2.ID.make(input.model.api.id), diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 623c2881e8ba..02b8524971ee 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Parameters, define, formatValue, toolResultValue } from "@/session/code-mode" +import { Parameters, define, describe as describeTools, formatValue, groupByServer, toolResultValue } from "@/session/code-mode" import { Agent } from "@/agent/agent" import { Tool } from "@/tool/tool" import * as Truncate from "@/tool/truncate" @@ -21,14 +21,15 @@ const ctx: Tool.Context = { // Build a real MCP-derived AI SDK tool over a fake transport, so the proxy exercises // the same `convertTool` execution path that `mcp.tools()` produces at runtime. -function mcpTool(name: string, handler: (args: Record) => unknown): AITool { +function mcpTool( + name: string, + handler: (args: Record) => unknown, + inputSchema: Record = { type: "object", properties: {} }, +): AITool { const client = { callTool: async (params: { arguments?: Record }) => handler(params.arguments ?? {}), } - return McpCatalog.convertTool( - { name, description: name, inputSchema: { type: "object", properties: {} } } as any, - client as any, - ) + return McpCatalog.convertTool({ name, description: name, inputSchema } as any, client as any) } // Truncate echoes its input so assertions read the exact program output. Agent.get is @@ -40,8 +41,11 @@ const layer = Layer.mergeAll( Layer.succeed(Agent.Service, Agent.Service.of({ get: () => Effect.succeed({ name: "build" } as any) } as any)), ) -function build(mcpTools: Record) { - return Effect.runPromise(define(mcpTools).pipe(Effect.flatMap(Tool.init), Effect.provide(layer))) +// Derive sanitized server namespaces from the catalog keys, mirroring how +// session/tools.ts passes `Object.keys(mcp.clients()).map(sanitize)`. +function build(mcpTools: Record, servers?: string[]) { + const names = servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))] + return Effect.runPromise(define(mcpTools, names).pipe(Effect.flatMap(Tool.init), Effect.provide(layer))) } describe("code mode execute", () => { @@ -51,9 +55,32 @@ describe("code mode execute", () => { await expect(Effect.runPromise(decode({}))).rejects.toThrow() }) - test("lists available tools in the description", async () => { - const tool = await build({ beta_b: mcpTool("b", () => "b"), alpha_a: mcpTool("a", () => "a") }) - expect(tool.description).toContain("Available tools: alpha_a, beta_b") + test("describes tools grouped into per-server namespaces with signatures", () => { + const description = describeTools( + groupByServer( + { + github_create_issue: mcpTool("create_issue", () => "", { + type: "object", + properties: { title: { type: "string" }, body: { type: "string" } }, + required: ["title"], + }), + linear_search: mcpTool("search", () => ""), + }, + ["github", "linear"], + ), + ) + + expect(description).toContain("await tools..(input)") + expect(description).toContain("// github") + expect(description).toContain("tools.github.create_issue({ title: string; body?: string })") + expect(description).toContain("// linear") + expect(description).toContain("tools.linear.search") + }) + + test("groups multi-underscore server names by longest matching prefix", () => { + const groups = groupByServer({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"]) + expect([...groups.keys()]).toEqual(["my_server"]) + expect(groups.get("my_server")![0]).toMatchObject({ local: "do_thing", key: "my_server_do_thing" }) }) test("runs plain JavaScript and returns the value as text", async () => { @@ -63,7 +90,7 @@ describe("code mode execute", () => { expect(output.metadata.toolCalls).toEqual([]) }) - test("calls an MCP tool and flows its text result back into the program", async () => { + test("calls a namespaced MCP tool and flows its text result back into the program", async () => { const seen: Record[] = [] const tool = await build({ greeter_hello: mcpTool("hello", (args) => { @@ -73,7 +100,7 @@ describe("code mode execute", () => { }) const output = await Effect.runPromise( - tool.execute({ code: "const r = await tools.greeter_hello({ name: 'world' }); return r.toUpperCase()" }, ctx), + tool.execute({ code: "const r = await tools.greeter.hello({ name: 'world' }); return r.toUpperCase()" }, ctx), ) expect(seen).toEqual([{ name: "world" }]) @@ -93,8 +120,8 @@ describe("code mode execute", () => { tool.execute( { code: ` - const first = await tools.math_add({ a: 1, b: 2 }) - const second = await tools.math_add({ a: first.sum, b: 10 }) + const first = await tools.math.add({ a: 1, b: 2 }) + const second = await tools.math.add({ a: first.sum, b: 10 }) return { total: second.sum } `, }, @@ -114,7 +141,7 @@ describe("code mode execute", () => { const output = await Effect.runPromise( tool.execute( - { code: "const [a, b] = await Promise.all([tools.echo_one({}), tools.echo_two({})]); return a + b" }, + { code: "const [a, b] = await Promise.all([tools.echo.one({}), tools.echo.two({})]); return a + b" }, ctx, ), ) @@ -132,9 +159,9 @@ describe("code mode execute", () => { test("reports an unknown tool with the available names", async () => { const tool = await build({ known_tool: mcpTool("tool", () => "ok") }) - const output = await Effect.runPromise(tool.execute({ code: "return await tools.missing({})" }, ctx)) + 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 'missing'") + expect(output.output).toContain("Unknown tool 'tools.known.missing'") expect(output.output).toContain("known_tool") }) @@ -144,7 +171,7 @@ describe("code mode execute", () => { }) const output = await Effect.runPromise( tool.execute( - { code: "try { await tools.bad_tool({}) } catch (e) { return 'caught: ' + e.message }" }, + { code: "try { await tools.bad.tool({}) } catch (e) { return 'caught: ' + e.message }" }, ctx, ), ) @@ -158,7 +185,7 @@ describe("code mode execute", () => { const tool = await build({ a_tool: mcpTool("a", ok), b_tool: mcpTool("b", ok) }) await Effect.runPromise( - tool.execute({ code: "await tools.a_tool({}); await tools.b_tool({}); return 'done'" }, permissionCtx), + tool.execute({ code: "await tools.a.tool({}); await tools.b.tool({}); return 'done'" }, permissionCtx), ) expect(asked.map((req: any) => req.permission)).toEqual(["a_tool", "b_tool"]) From cad83ab53e6313d0d19d6a5e68c604355dc28514 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 29 Jun 2026 18:39:55 -0500 Subject: [PATCH 03/41] 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..(args)` and the dotted `tools[path](args)` returned by search resolve to the catalog key. --- packages/opencode/src/session/code-mode.ts | 155 +++++++++++++----- packages/opencode/src/session/tools.ts | 23 +-- .../opencode/test/session/code-mode.test.ts | 76 ++++++--- 3 files changed, 177 insertions(+), 77 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index f4894c13618d..d211e8353a28 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -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..(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 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 +} -type NamespacedTool = { local: string; key: string; tool: AITool } +/** 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, servers: readonly string[]): Map { +export function groupByServer(mcpTools: Record, servers: readonly string[]): Map { const byLongest = [...servers].sort((a, b) => b.length - a.length) - const groups = new Map() + const groups = new Map() 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 { +/** + * 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, descriptions?: Map): 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..(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..(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, servers: readonly string[]) { - const groups = groupByServer(mcpTools, servers) +export function define(mcpTools: Record, namespaces: ReadonlyArray) { + 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>({ - 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, servers: readonly strin return toolResultValue(result) }) - // `tools..(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, { + // Recursive path-accumulating proxy: `tools..(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, { - 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), diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 115c1bd3146a..59df662117d6 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -94,17 +94,18 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { // When code mode is enabled and MCP tools are present, expose them through the // single code-mode `execute` tool instead of registering each MCP tool directly // (see the early return below). Code mode is experimental and off by default. - // Sanitized client names give code mode the per-server namespaces; tool keys are - // `sanitize(server)_sanitize(tool)`, so the names match the catalog key prefixes. - const codeModeTool = - flags.experimentalCodeMode && Object.keys(mcpTools).length > 0 - ? yield* Tool.init( - yield* CodeModeTool.define( - mcpTools, - Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize), - ), - ) - : undefined + let codeModeTool: Tool.Def | undefined + if (flags.experimentalCodeMode && Object.keys(mcpTools).length > 0) { + // Namespaces are sanitized client names (tool keys are `sanitize(server)_sanitize(tool)`, + // so they match the catalog key prefixes). The brief per-namespace note reuses the + // server's MCP instructions, whose full text is already in the system prompt. + const instructions = new Map((yield* mcp.instructions()).map((item) => [item.name, item.instructions] as const)) + const namespaces = Object.keys(yield* mcp.clients()).map((name) => ({ + name: McpCatalog.sanitize(name), + description: instructions.get(name), + })) + codeModeTool = yield* Tool.init(yield* CodeModeTool.define(mcpTools, namespaces)) + } const registryTools = yield* registry.tools({ modelID: ModelV2.ID.make(input.model.api.id), providerID: input.model.providerID, diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 02b8524971ee..629aa1d6c2ec 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -42,9 +42,9 @@ const layer = Layer.mergeAll( ) // Derive sanitized server namespaces from the catalog keys, mirroring how -// session/tools.ts passes `Object.keys(mcp.clients()).map(sanitize)`. -function build(mcpTools: Record, servers?: string[]) { - const names = servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))] +// session/tools.ts passes namespaces built from `mcp.clients()` + `mcp.instructions()`. +function build(mcpTools: Record, namespaces?: Array<{ name: string; description?: string }>) { + const names = namespaces ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))].map((name) => ({ name })) return Effect.runPromise(define(mcpTools, names).pipe(Effect.flatMap(Tool.init), Effect.provide(layer))) } @@ -55,26 +55,58 @@ describe("code mode execute", () => { await expect(Effect.runPromise(decode({}))).rejects.toThrow() }) - test("describes tools grouped into per-server namespaces with signatures", () => { + test("describes namespaces only (not per-tool signatures) and the discovery API", () => { + const groups = groupByServer( + { + github_create_issue: mcpTool("create_issue", () => ""), + github_list_issues: mcpTool("list_issues", () => ""), + linear_search: mcpTool("search", () => ""), + }, + ["github", "linear"], + ) const description = describeTools( - groupByServer( - { - github_create_issue: mcpTool("create_issue", () => "", { - type: "object", - properties: { title: { type: "string" }, body: { type: "string" } }, - required: ["title"], - }), - linear_search: mcpTool("search", () => ""), - }, - ["github", "linear"], - ), + groups, + new Map([ + ["github", "GitHub repository automation.\nmore detail here"], + ["linear", undefined], + ]), + ) + + expect(description).toContain("tools.search(query") + expect(description).toContain("tools.describe(path)") + expect(description).toContain("- github (2 tools) — GitHub repository automation.") + expect(description).toContain("- linear (1 tool)") + // The full catalog must NOT be inlined in the prompt. + expect(description).not.toContain("create_issue") + }) + + test("tools.search and tools.describe expose the catalog on demand", async () => { + const tool = await build({ + github_create_issue: mcpTool("create_issue", () => "", { + type: "object", + properties: { title: { type: "string" }, body: { type: "string" } }, + required: ["title"], + }), + github_list_issues: mcpTool("list_issues", () => ""), + linear_search: mcpTool("search", () => ""), + }) + + const searched = await Effect.runPromise( + tool.execute({ code: "return await tools.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), ) + const desc = JSON.parse(described.output) + expect(desc.path).toBe("github.create_issue") + expect(desc.signature).toBe("tools.github.create_issue({ title: string; body?: string })") - expect(description).toContain("await tools..(input)") - expect(description).toContain("// github") - expect(description).toContain("tools.github.create_issue({ title: string; body?: string })") - expect(description).toContain("// linear") - expect(description).toContain("tools.linear.search") + const missing = await Effect.runPromise(tool.execute({ code: "return await tools.describe('github.nope')" }, ctx)) + expect(JSON.parse(missing.output).error.code).toBe("tool_not_found") }) test("groups multi-underscore server names by longest matching prefix", () => { @@ -157,12 +189,12 @@ describe("code mode execute", () => { expect(output.metadata.error).toBe(true) }) - test("reports an unknown tool with the available names", async () => { + test("reports an unknown tool and points to discovery", async () => { const tool = await build({ known_tool: mcpTool("tool", () => "ok") }) 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 'tools.known.missing'") - expect(output.output).toContain("known_tool") + expect(output.output).toContain("tools.search") }) test("propagates an MCP tool error into the program", async () => { From caa5f28cc9e39975355e5c9b8117ca36b4868fd8 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 29 Jun 2026 18:46:28 -0500 Subject: [PATCH 04/41] refactor(opencode): simplify code mode namespace listing Drop the MCP-instructions blending from the execute tool description; just list namespace names and tool counts on the tool definition. The full server instructions already live in the system prompt's , and per-tool detail is fetched on demand via tools.search/tools.describe. --- packages/opencode/src/session/code-mode.ts | 18 +++++------------ packages/opencode/src/session/tools.ts | 20 ++++++++----------- .../opencode/test/session/code-mode.test.ts | 16 +++++---------- 3 files changed, 18 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index d211e8353a28..7389b324a604 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -16,8 +16,6 @@ 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 { @@ -105,7 +103,7 @@ const signatureFor = (entry: CatalogEntry) => * 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, descriptions?: Map): string { +export function describe(groups: Map): string { const lines = [ "Execute JavaScript with access to connected MCP tools, grouped into namespaces (one per MCP server).", "", @@ -122,9 +120,7 @@ export function describe(groups: Map, descriptions?: Map } lines.push("", "Available namespaces:") 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}` : ""}`) + lines.push(`- ${server} (${entries.length} tool${entries.length === 1 ? "" : "s"})`) } return lines.join("\n") } @@ -170,12 +166,8 @@ function errorMessage(error: unknown): string { } } -export function define(mcpTools: Record, namespaces: ReadonlyArray) { - const groups = groupByServer( - mcpTools, - namespaces.map((n) => n.name), - ) - const descriptions = new Map(namespaces.map((n) => [n.name, n.description] as const)) +export function define(mcpTools: Record, servers: readonly string[]) { + const groups = groupByServer(mcpTools, servers) const catalog: CatalogEntry[] = [...groups.values()].flat() const byKey = new Map(catalog.map((entry) => [entry.key, entry] as const)) @@ -216,7 +208,7 @@ export function define(mcpTools: Record, namespaces: ReadonlyArr return Tool.define( CODE_MODE_TOOL, Effect.succeed>({ - description: describe(groups, descriptions), + description: describe(groups), parameters: Parameters, execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { const run = yield* EffectBridge.make() diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 59df662117d6..7f65c6430149 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -94,18 +94,14 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { // When code mode is enabled and MCP tools are present, expose them through the // single code-mode `execute` tool instead of registering each MCP tool directly // (see the early return below). Code mode is experimental and off by default. - let codeModeTool: Tool.Def | undefined - if (flags.experimentalCodeMode && Object.keys(mcpTools).length > 0) { - // Namespaces are sanitized client names (tool keys are `sanitize(server)_sanitize(tool)`, - // so they match the catalog key prefixes). The brief per-namespace note reuses the - // server's MCP instructions, whose full text is already in the system prompt. - const instructions = new Map((yield* mcp.instructions()).map((item) => [item.name, item.instructions] as const)) - const namespaces = Object.keys(yield* mcp.clients()).map((name) => ({ - name: McpCatalog.sanitize(name), - description: instructions.get(name), - })) - codeModeTool = yield* Tool.init(yield* CodeModeTool.define(mcpTools, namespaces)) - } + // Namespaces are sanitized client names; tool keys are `sanitize(server)_sanitize(tool)`, + // so the names match the catalog key prefixes. + const codeModeTool = + flags.experimentalCodeMode && Object.keys(mcpTools).length > 0 + ? yield* Tool.init( + yield* CodeModeTool.define(mcpTools, Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)), + ) + : undefined const registryTools = yield* registry.tools({ modelID: ModelV2.ID.make(input.model.api.id), providerID: input.model.providerID, diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 629aa1d6c2ec..2433e47a3ad1 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -42,9 +42,9 @@ const layer = Layer.mergeAll( ) // Derive sanitized server namespaces from the catalog keys, mirroring how -// session/tools.ts passes namespaces built from `mcp.clients()` + `mcp.instructions()`. -function build(mcpTools: Record, namespaces?: Array<{ name: string; description?: string }>) { - const names = namespaces ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))].map((name) => ({ name })) +// session/tools.ts passes `Object.keys(mcp.clients()).map(sanitize)`. +function build(mcpTools: Record, servers?: string[]) { + const names = servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))] return Effect.runPromise(define(mcpTools, names).pipe(Effect.flatMap(Tool.init), Effect.provide(layer))) } @@ -64,17 +64,11 @@ describe("code mode execute", () => { }, ["github", "linear"], ) - const description = describeTools( - groups, - new Map([ - ["github", "GitHub repository automation.\nmore detail here"], - ["linear", undefined], - ]), - ) + const description = describeTools(groups) expect(description).toContain("tools.search(query") expect(description).toContain("tools.describe(path)") - expect(description).toContain("- github (2 tools) — GitHub repository automation.") + expect(description).toContain("- github (2 tools)") expect(description).toContain("- linear (1 tool)") // The full catalog must NOT be inlined in the prompt. expect(description).not.toContain("create_issue") From 3a6621c5fc18e737599381075ae5bb0d3aed67a9 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 00:05:58 -0500 Subject: [PATCH 05/41] chore(opencode): vendor rune interpreter for code mode Copy the Rune TS AST interpreter into the session package as the foundation for code-mode execution. Add acorn as a dependency and promote typescript to a runtime dependency (both required by Rune's parser/transpile path). Fix effect beta.83 API drift (Schema.Defect is now a function). Interpreter logic is unchanged; nothing imports it yet. --- bun.lock | 23 +- packages/opencode/package.json | 3 +- .../src/session/rune/capability-error.ts | 10 + packages/opencode/src/session/rune/rune.ts | 2767 +++++++++++++++++ .../opencode/src/session/rune/tool-runtime.ts | 309 ++ packages/opencode/src/session/rune/tool.ts | 100 + 6 files changed, 3209 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/session/rune/capability-error.ts create mode 100644 packages/opencode/src/session/rune/rune.ts create mode 100644 packages/opencode/src/session/rune/tool-runtime.ts create mode 100644 packages/opencode/src/session/rune/tool.ts diff --git a/bun.lock b/bun.lock index ab6d6929ae69..6d60e7848e91 100644 --- a/bun.lock +++ b/bun.lock @@ -603,6 +603,7 @@ "@standard-schema/spec": "1.0.0", "@types/ws": "8.18.1", "@zip.js/zip.js": "2.7.62", + "acorn": "8.15.0", "ai": "catalog:", "ai-gateway-provider": "3.1.2", "bonjour-service": "1.3.0", @@ -636,6 +637,7 @@ "tree-sitter-bash": "0.25.0", "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", + "typescript": "catalog:", "ulid": "catalog:", "venice-ai-sdk-provider": "2.0.2", "vscode-jsonrpc": "8.2.1", @@ -664,7 +666,6 @@ "@typescript/native-preview": "catalog:", "drizzle-orm": "catalog:", "prettier": "3.6.2", - "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", }, @@ -3009,7 +3010,7 @@ "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -5691,6 +5692,8 @@ "@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], + "@astrojs/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@astrojs/sitemap/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -5901,6 +5904,8 @@ "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], @@ -6147,6 +6152,8 @@ "astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], + "astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], "astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], @@ -6237,6 +6244,8 @@ "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -6301,6 +6310,8 @@ "md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="], + "micromark-extension-mdxjs/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], @@ -6425,6 +6436,8 @@ "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], @@ -6443,6 +6456,8 @@ "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], + "unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], "unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], @@ -6457,6 +6472,8 @@ "verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], + "vite-plugin-dynamic-import/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "vitest/@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="], @@ -6875,6 +6892,8 @@ "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index fba8d9b5bee4..e90732cc1a6a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -47,7 +47,6 @@ "@typescript/native-preview": "catalog:", "drizzle-orm": "catalog:", "prettier": "3.6.2", - "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2" }, @@ -109,6 +108,7 @@ "@standard-schema/spec": "1.0.0", "@types/ws": "8.18.1", "@zip.js/zip.js": "2.7.62", + "acorn": "8.15.0", "ai": "catalog:", "ai-gateway-provider": "3.1.2", "bonjour-service": "1.3.0", @@ -142,6 +142,7 @@ "tree-sitter-bash": "0.25.0", "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", + "typescript": "catalog:", "ulid": "catalog:", "venice-ai-sdk-provider": "2.0.2", "vscode-jsonrpc": "8.2.1", diff --git a/packages/opencode/src/session/rune/capability-error.ts b/packages/opencode/src/session/rune/capability-error.ts new file mode 100644 index 000000000000..2951dcef400b --- /dev/null +++ b/packages/opencode/src/session/rune/capability-error.ts @@ -0,0 +1,10 @@ +import { Schema } from "effect" + +/** Safe operational refusal from a standard capability pack, reported as `CapabilityFailure`. */ +export class CapabilityError extends Schema.TaggedErrorClass()("CapabilityError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + +export const capabilityError = (message: string, cause?: unknown): CapabilityError => + new CapabilityError({ message, ...(cause === undefined ? {} : { cause }) }) diff --git a/packages/opencode/src/session/rune/rune.ts b/packages/opencode/src/session/rune/rune.ts new file mode 100644 index 000000000000..530fa9e49a7d --- /dev/null +++ b/packages/opencode/src/session/rune/rune.ts @@ -0,0 +1,2767 @@ +import { parse } from "acorn" +import { Cause, Effect, Schema } from "effect" +import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" +import { + copyIn, + copyOut, + dataByteLength, + isBlockedMember, + ToolReference, + ToolRuntime, + ToolRuntimeError, + type HostTools, + type SafeObject, + type ToolCall, + type ToolDescription, + type Services, +} from "./tool-runtime.js" +import type { Definition } from "./tool.js" +import { CapabilityError } from "./capability-error.js" + +export type { ToolCall, ToolDescription } from "./tool-runtime.js" +export { Tool } from "./tool.js" +export { CapabilityError } from "./capability-error.js" + +/** Resource budgets enforced during each Rune Program execution. */ +export type ExecutionLimits = { + readonly maxOperations?: number + readonly maxToolCalls?: number + readonly maxConcurrency?: number + readonly maxSourceBytes?: number + readonly maxDataBytes?: number + readonly maxAuditBytes?: number + readonly maxValueDepth?: number + readonly maxCollectionLength?: number + readonly timeoutMs?: number +} + +type CapabilityTree = { + readonly [name: string]: Definition | CapabilityTree +} + +type ResolvedExecutionLimits = { + readonly maxOperations: number + readonly maxToolCalls: number + readonly maxConcurrency: number + readonly maxSourceBytes: number + readonly maxDataBytes: number + readonly maxAuditBytes: number + readonly maxValueDepth: number + readonly maxCollectionLength: number + readonly timeoutMs: number +} + +export type ExecuteOptions = {}> = { + code: string + tools?: Tools & CapabilityTree + limits?: ExecutionLimits +} + +export type ExecuteResult = + | { + ok: true + value: unknown + toolCalls: ReadonlyArray + } + | { + ok: false + error: { + kind: DiagnosticKind + message: string + location?: { readonly line: number; readonly column: number } + suggestions?: ReadonlyArray + } + toolCalls: ReadonlyArray + } + +export type RuneOptions = {}> = Omit, "code"> + +/** Input schema for the single agent-facing tool produced by `rune.asTool()`. */ +export const CodeInput = Schema.Struct({ code: Schema.String }) + +const DiagnosticKindSchema = Schema.Literals([ + "ParseError", "UnsupportedSyntax", "UnknownCapability", "InvalidToolInput", "InvalidToolOutput", "InvalidDataValue", + "OperationLimitExceeded", "ToolCallLimitExceeded", "AuditLimitExceeded", "ConcurrencyLimitExceeded", "TimeoutExceeded", + "CapabilityFailure", "ExecutionFailure", +]) + +/** Structured success or diagnostic result schema returned by Rune execution. */ +export const ExecuteResultSchema = Schema.Union([ + Schema.Struct({ + ok: Schema.Literal(true), + value: Schema.Unknown, + toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })), + }), + Schema.Struct({ + ok: Schema.Literal(false), + error: Schema.Struct({ + kind: DiagnosticKindSchema, + message: Schema.String, + location: Schema.optional(Schema.Struct({ line: Schema.Number, column: Schema.Number })), + suggestions: Schema.optional(Schema.Array(Schema.String)), + }), + toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })), + }), +]) + +export type CodeTool = { + readonly name: "code" + readonly description: string + readonly input: typeof CodeInput + readonly execute: (input: { readonly code: string }) => Effect.Effect +} + +export type Rune = { + /** Lists schema-described capability paths provided by the host. */ + readonly catalog: () => ReadonlyArray + /** Builds model-facing syntax guidance and visible capability signatures. */ + readonly instructions: () => string + /** Projects the configured runtime as one agent-facing `code` tool. */ + readonly asTool: () => CodeTool + /** Executes a program using this runtime's configured host tools. */ + readonly run: (code: string) => Effect.Effect +} + +type SourcePosition = { + line: number + column: number +} + +type SourceLocation = { + start: SourcePosition + end: SourcePosition +} + +type AstNode = { + type: string + loc?: SourceLocation + [key: string]: unknown +} + +type ProgramNode = AstNode & { + type: "Program" + body: Array +} + +type Binding = { + mutable: boolean + value: unknown + // Absent means initialized. `false` marks a parameter binding seeded into its scope but not + // yet bound, so a default that forward-references a later parameter sees a TDZ error (as in JS) + // rather than silently resolving to an outer binding of the same name. + initialized?: boolean +} + +type StatementResult = + | { kind: "none" } + | { kind: "value"; value: unknown } + | { kind: "return"; value: unknown } + | { kind: "break" } + | { kind: "continue" } + +type MemberReference = { + target: SafeObject | Array + key: string | number +} + +class RuneFunction { + constructor( + readonly parameters: ReadonlyArray, + readonly body: AstNode, + readonly capturedScopes: ReadonlyArray>, + ) {} +} + +class IntrinsicReference { + constructor( + readonly receiver: unknown, + readonly name: string, + ) {} +} + +// A read-only computed member (e.g. `str.length`, a character index) — not assignable. +class ComputedValue { + constructor(readonly value: unknown) {} +} + +class PromiseNamespace {} + +class PromiseAllReference {} + +// A built-in global namespace (`Object`, `Math`, `JSON`, `Array`); members resolve to a +// GlobalMethodReference, except known constants (e.g. `Math.PI`) which resolve to a value. +class GlobalNamespace { + constructor(readonly name: "Object" | "Math" | "JSON" | "Array") {} +} + +class GlobalMethodReference { + constructor(readonly namespace: "Object" | "Math" | "JSON" | "Array" | "Number" | "String", readonly name: string) {} +} + +// A built-in callable global (`Number`, `String`, `Boolean`, `parseInt`, `parseFloat`). +class CoercionFunction { + constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {} +} + +class ProgramThrow { + constructor(readonly value: unknown) {} +} + +export type DiagnosticKind = + | "ParseError" + | "UnsupportedSyntax" + | "UnknownCapability" + | "InvalidToolInput" + | "InvalidToolOutput" + | "InvalidDataValue" + | "OperationLimitExceeded" + | "ToolCallLimitExceeded" + | "AuditLimitExceeded" + | "ConcurrencyLimitExceeded" + | "TimeoutExceeded" + | "CapabilityFailure" + | "ExecutionFailure" + +const arrayMethods = new Set([ + "map", "filter", "find", "findIndex", "findLast", "findLastIndex", "some", "every", "includes", "join", + "reduce", "reduceRight", "flatMap", "forEach", "sort", "toSorted", "slice", "concat", "indexOf", "lastIndexOf", + "at", "flat", "reverse", "toReversed", "with", "push", "pop", "shift", "unshift", +]) +const retryableArrayMethods = new Set(["splice", "fill", "copyWithin", "keys", "values", "entries"]) + +/** + * Array methods whose cost is O(1) (or bounded by the argument count), so they must + * NOT be charged the receiver's length. Charging `push` per element would make an + * accumulation loop quadratic in the operation budget and trip it on legitimate code. + */ +const cheapArrayMethods = new Set(["push", "pop", "at"]) + +const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"]) + +const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"]) + +const stringMethods = new Set([ + "toLowerCase", "toUpperCase", "trim", "trimStart", "trimEnd", "split", "slice", "substring", "substr", + "includes", "startsWith", "endsWith", "indexOf", "lastIndexOf", "replace", "replaceAll", + "repeat", "padStart", "padEnd", "charAt", "charCodeAt", "codePointAt", "at", "concat", "toString", +]) + +const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"]) + +const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"]) + +const stringStatics = new Set(["fromCharCode", "fromCodePoint"]) + +const errorConstructors = new Set(["Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError", "EvalError", "URIError"]) + +const OptionalShortCircuit: unique symbol = Symbol("rune.optional-short-circuit") + +const supportedSyntaxMessage = + "Supported orchestration syntax: tools.* calls, data literals, destructuring, optional chaining, template literals, conditionals, switch, loops, arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods, Object/Math/JSON helpers, and Promise.all([tool calls]) or Promise.all(items.map((item) => tool call)) for parallel tool calls." + +const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError => + new InterpreterRuntimeError(`Syntax '${kind}' is not supported in Rune. ${supportedSyntaxMessage}`, node, "UnsupportedSyntax", [supportedSyntaxMessage]) + +export const defaultExecutionLimits = (): ResolvedExecutionLimits => ({ + maxOperations: 100_000, + maxToolCalls: 100, + maxConcurrency: 8, + maxSourceBytes: 32_000, + maxDataBytes: 256_000, + maxAuditBytes: 1_000_000, + maxValueDepth: 32, + maxCollectionLength: 10_000, + timeoutMs: 10_000, +}) + +export const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimits => ({ + ...defaultExecutionLimits(), + ...limits, +}) + +class InterpreterRuntimeError extends Error { + readonly node?: AstNode + + constructor(message: string, node?: AstNode, readonly kind: DiagnosticKind = "ExecutionFailure", readonly suggestions?: ReadonlyArray) { + super(message) + this.name = "InterpreterRuntimeError" + + if (node) { + this.node = node + } + } +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null + +const asNode = (value: unknown, context: string): AstNode => { + if (!isRecord(value) || typeof value.type !== "string") { + throw new InterpreterRuntimeError(`Invalid AST node while reading ${context}.`) + } + + return value as AstNode +} + +const getArray = (node: AstNode, key: string): Array => { + const value = node[key] + if (!Array.isArray(value)) { + throw new InterpreterRuntimeError(`Expected '${key}' to be an array.`, node) + } + + return value +} + +const getString = (node: AstNode, key: string): string => { + const value = node[key] + if (typeof value !== "string") { + throw new InterpreterRuntimeError(`Expected '${key}' to be a string.`, node) + } + + return value +} + +const getBoolean = (node: AstNode, key: string): boolean => { + const value = node[key] + if (typeof value !== "boolean") { + throw new InterpreterRuntimeError(`Expected '${key}' to be a boolean.`, node) + } + + return value +} + +const getOptionalNode = (node: AstNode, key: string): AstNode | undefined => { + const value = node[key] + if (value === undefined || value === null) { + return undefined + } + + return asNode(value, key) +} + +const getNode = (node: AstNode, key: string): AstNode => { + const value = node[key] + return asNode(value, key) +} + +const parseProgram = (code: string): ProgramNode => { + const transpiled = transpileModule(`async function __rune__() {\n${code}\n}`, { + reportDiagnostics: true, + compilerOptions: { + target: ScriptTarget.ESNext, + module: ModuleKind.ESNext, + }, + }) + const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error) + + if (diagnostic) { + throw new InterpreterRuntimeError( + `Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`, + undefined, + "ParseError", + ) + } + + const bodyStart = transpiled.outputText.indexOf("{") + 1 + const bodyEnd = transpiled.outputText.lastIndexOf("}") + const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd) + const parsed = parse(executableCode, { + ecmaVersion: "latest", + sourceType: "script", + allowReturnOutsideFunction: true, + allowAwaitOutsideFunction: true, + locations: true, + }) as unknown + + if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) { + throw new InterpreterRuntimeError("Failed to parse script as a Program node.") + } + + return parsed as ProgramNode +} + +const formatLocation = (node?: AstNode): string => { + if (!node || !node.loc) { + return "" + } + + const location = sourceLocation(node) + return ` (line ${location.line}, col ${location.column})` +} + +const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({ + line: Math.max(1, (node.loc?.start.line ?? 2) - 1), + column: Math.max(1, (node.loc?.start.column ?? 4) - 3), +}) + +type Diagnostic = Extract['error'] + +const publicErrorMessage = (message: string): string => + message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "") + +const normalizeError = (error: unknown): Diagnostic => { + if (error instanceof InterpreterRuntimeError) { + return { + kind: error.kind, + message: `${error.message}${formatLocation(error.node)}`, + ...(error.node?.loc ? { location: sourceLocation(error.node) } : {}), + ...(error.suggestions ? { suggestions: error.suggestions } : {}), + } + } + + if (error instanceof ToolRuntimeError) { + return { + kind: error.kind, + message: error.message, + ...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}), + } + } + + if (error instanceof CapabilityError) { + return { kind: "CapabilityFailure", message: publicErrorMessage(error.message) } + } + + if (error instanceof ProgramThrow) { + const value = error.value + let message: string + if (containsRuntimeReference(value)) { + // A thrown capability/function reference must not leak its internal structure. + message = "a non-data value" + } else if (typeof value === "string") { + message = value + } else if (value !== null && typeof value === "object" && typeof (value as { message?: unknown }).message === "string") { + message = (value as { message: string }).message + } else { + try { + message = JSON.stringify(copyOut(value)) ?? String(value) + } catch { + message = String(value) + } + } + return { kind: "ExecutionFailure", message: `Uncaught: ${message}` } + } + + if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) { + return { + kind: "ExecutionFailure", + message: "Execution exceeded the maximum nesting depth.", + } + } + + if (error instanceof Error) { + return { + kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure", + message: publicErrorMessage(error.message), + } + } + + // A non-Error thrown by a host tool (raw string / number / Symbol) still routes through + // path redaction so filesystem paths can never leak through the catch-all branch. + return { + kind: "ExecutionFailure", + message: publicErrorMessage(String(error)), + } +} + +// ── Built-in method/global implementations ─────────────────────────────────── +// These mirror the corresponding JavaScript operations over Data Values. They are +// pure (string/Object/Math/JSON/coercion) and so live as free functions; array +// methods that run Rune callbacks live on the interpreter (they need invokeFunction). + +const boundedData = (value: unknown, label: string, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + const copied = copyIn(value, label, limits) + if (dataByteLength(copied) > limits.maxDataBytes) { + throw new InterpreterRuntimeError(`${label} exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, node, "InvalidDataValue") + } + return copied +} + +const isRuntimeReference = (value: unknown): boolean => + value instanceof RuneFunction || value instanceof ToolReference || value instanceof IntrinsicReference || + value instanceof GlobalNamespace || value instanceof GlobalMethodReference || value instanceof PromiseNamespace || + value instanceof PromiseAllReference || value instanceof CoercionFunction + +const containsRuntimeReference = (value: unknown, seen = new Set()): boolean => { + if (isRuntimeReference(value)) return true + if (value === null || typeof value !== "object") return false + if (seen.has(value)) return false + seen.add(value) + const contains = Array.isArray(value) + ? value.some((item) => containsRuntimeReference(item, seen)) + : Object.values(value).some((item) => containsRuntimeReference(item, seen)) + seen.delete(value) + return contains +} + +const runtimeValueBytes = ( + value: unknown, + label: string, + node: AstNode, + limits: ResolvedExecutionLimits, + depth = 0, + seen = new Set(), +): number => { + if (depth > limits.maxValueDepth) { + throw new InterpreterRuntimeError(`${label} exceeds the maximum value depth of ${limits.maxValueDepth}.`, node, "InvalidDataValue") + } + if (isRuntimeReference(value)) return 0 + if (value === null || value === undefined || typeof value === "string" || typeof value === "boolean" || typeof value === "number") { + return dataByteLength(value) + } + if (typeof value !== "object") { + throw new InterpreterRuntimeError(`${label} must contain data or Rune references only.`, node, "InvalidDataValue") + } + if (seen.has(value)) throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") + seen.add(value) + let bytes = 2 + if (Array.isArray(value)) { + if (value.length > limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + for (const item of value) bytes += runtimeValueBytes(item, label, node, limits, depth + 1, seen) + 1 + } else { + const entries = Object.entries(value) + if (entries.length > limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + for (const [key, item] of entries) { + if (isBlockedMember(key)) throw new InterpreterRuntimeError(`${label} contains blocked property '${key}'.`, node, "InvalidDataValue") + bytes += dataByteLength(key) + runtimeValueBytes(item, label, node, limits, depth + 1, seen) + 1 + } + } + seen.delete(value) + return bytes +} + +const boundedProgramValue = (value: unknown, label: string, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + if (runtimeValueBytes(value, label, node, limits) > limits.maxDataBytes) { + throw new InterpreterRuntimeError(`${label} exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, node, "InvalidDataValue") + } + return value +} + +// A cheap proxy for the work an O(n) built-in performed, used to charge the operation budget. +const workUnits = (value: unknown): number => { + if (typeof value === "string" || Array.isArray(value)) return value.length + if (value !== null && typeof value === "object") return Object.keys(value).length + return 1 +} + +const invokeStringMethod = (value: string, name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + const str = (index: number): string => { + const arg = args[index] + if (typeof arg !== "string") throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node) + return arg + } + const num = (index: number): number => { + const arg = args[index] + if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node) + return arg + } + const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index)) + const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index)) + const byteLength = (text: string): number => new TextEncoder().encode(text).byteLength + const limitString = (bytes: number): void => { + if (bytes > limits.maxDataBytes) { + throw new InterpreterRuntimeError(`String.${name} exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, node, "InvalidDataValue") + } + } + const replacementCount = (search: string): number => { + if (search === "") return value.length + 1 + let count = 0 + let offset = 0 + while ((offset = value.indexOf(search, offset)) !== -1) { + count += 1 + offset += search.length + } + return count + } + + let result: unknown + switch (name) { + case "toLowerCase": result = value.toLowerCase(); break + case "toUpperCase": result = value.toUpperCase(); break + case "trim": result = value.trim(); break + case "trimStart": result = value.trimStart(); break + case "trimEnd": result = value.trimEnd(); break + case "split": { + if (args.length === 0) { + result = [value] + break + } + const separator = str(0) + const requestedLimit = optNum(1) + const effectiveLimit = requestedLimit === undefined ? undefined : requestedLimit >>> 0 + const maximumParts = separator === "" ? value.length : replacementCount(separator) + 1 + const parts = effectiveLimit === undefined ? maximumParts : Math.min(maximumParts, effectiveLimit) + if (parts > limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`String.split exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + result = value.split(separator, effectiveLimit) + break + } + case "slice": result = value.slice(optNum(0), optNum(1)); break + case "includes": result = value.includes(str(0), optNum(1)); break + case "startsWith": result = value.startsWith(str(0), optNum(1)); break + case "endsWith": result = value.endsWith(str(0), optNum(1)); break + case "indexOf": result = value.indexOf(str(0), optNum(1)); break + case "lastIndexOf": result = value.lastIndexOf(str(0), optNum(1)); break + case "replace": result = value.replace(str(0), str(1)); break + case "replaceAll": { + const search = str(0) + const replacement = str(1) + const growth = Math.max(0, byteLength(replacement) - byteLength(search)) + limitString(byteLength(value) + replacementCount(search) * growth) + result = value.replaceAll(search, replacement) + break + } + case "repeat": { + const count = num(0) + if (!Number.isFinite(count) || count < 0) throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node) + limitString(byteLength(value) * Math.floor(count)) + result = value.repeat(count) + break + } + case "padStart": { + const length = num(0) + limitString(Math.max(0, length)) + result = value.padStart(length, optStr(1)) + break + } + case "padEnd": { + const length = num(0) + limitString(Math.max(0, length)) + result = value.padEnd(length, optStr(1)) + break + } + case "charAt": result = value.charAt(optNum(0) ?? 0); break + case "at": result = value.at(optNum(0) ?? 0); break + case "substring": result = value.substring(optNum(0) ?? 0, optNum(1)); break + case "substr": result = value.substr(optNum(0) ?? 0, optNum(1)); break + // JS charCodeAt returns NaN out of range, but Rune forbids NaN as a Data Value; + // yield undefined instead, matching codePointAt and `at` (the other absent-slot sentinels). + case "charCodeAt": { const code = value.charCodeAt(optNum(0) ?? 0); result = Number.isNaN(code) ? undefined : code; break } + case "codePointAt": result = value.codePointAt(optNum(0) ?? 0); break + case "toString": result = value; break + case "concat": { + const pieces = args.map((_, index) => str(index)) + limitString(byteLength(value) + pieces.reduce((size, piece) => size + byteLength(piece), 0)) + result = value.concat(...pieces) + break + } + default: throw new InterpreterRuntimeError(`String method '${name}' is not available in Rune.`, node) + } + return boundedData(result, `String.${name} result`, node, limits) +} + +const invokeNumberMethod = (value: number, name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + const optNum = (index: number): number | undefined => { + const arg = args[index] + if (arg === undefined) return undefined + if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node) + return arg + } + let result: unknown + switch (name) { + case "toFixed": result = value.toFixed(optNum(0)); break + case "toExponential": result = value.toExponential(optNum(0)); break + case "toPrecision": { + const digits = optNum(0) + result = digits === undefined ? value.toString() : value.toPrecision(digits) + break + } + case "toString": { + const radix = optNum(0) + if (radix !== undefined && (radix < 2 || radix > 36)) { + throw new InterpreterRuntimeError("Number.toString radix must be between 2 and 36.", node) + } + result = value.toString(radix) + break + } + default: throw new InterpreterRuntimeError(`Number method '${name}' is not available in Rune.`, node) + } + return boundedData(result, `Number.${name} result`, node, limits) +} + +// JavaScript's String(...) without tripping over Rune's null-prototype data objects. +const coerceToString = (value: unknown): string => { + if (value === null) return "null" + if (value === undefined) return "undefined" + if (typeof value === "object") { + return Array.isArray(value) + ? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",") + : "[object Object]" + } + return String(value) +} + +const coerceToNumber = (value: unknown): number => + value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value) + +const invokeCoercion = (ref: CoercionFunction, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + const value = boundedData(args[0], `${ref.name} input`, node, limits) + if (ref.name === "Number") return coerceToNumber(value) + if (ref.name === "Boolean") return Boolean(value) + if (ref.name === "parseInt") { + const radix = args[1] + if (radix !== undefined && typeof radix !== "number") throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node) + return parseInt(coerceToString(value), radix) + } + if (ref.name === "parseFloat") return parseFloat(coerceToString(value)) + return coerceToString(value) +} + +const invokeObjectMethod = (name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + const requireObject = (): Record => { + const value = boundedData(args[0], `Object.${name} input`, node, limits) + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node) + } + return value as Record + } + const guardedSet = (out: Record, key: string, item: unknown): void => { + if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in Rune.`, node) + out[key] = item + } + switch (name) { + case "keys": return Object.keys(requireObject()) + case "values": return Object.values(requireObject()) + case "entries": return Object.entries(requireObject()).map(([key, item]) => [key, item]) + case "hasOwn": return Object.hasOwn(requireObject(), String(args[1])) + case "assign": { + const out: Record = Object.create(null) + for (const source of args) { + if (source === null || source === undefined) continue + const value = boundedData(source, "Object.assign input", node, limits) + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new InterpreterRuntimeError("Object.assign expects data objects.", node) + for (const [key, item] of Object.entries(value)) guardedSet(out, key, item) + } + return out + } + case "fromEntries": { + const pairs = boundedData(args[0], "Object.fromEntries input", node, limits) + if (!Array.isArray(pairs)) throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) + const out: Record = Object.create(null) + for (const pair of pairs) { + if (!Array.isArray(pair)) throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node) + guardedSet(out, String(pair[0]), pair[1]) + } + return out + } + default: throw new InterpreterRuntimeError(`Object.${name} is not available in Rune.`, node) + } +} + +const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { + const nums = args.map((arg) => { + if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node) + return arg + }) + const [a = Number.NaN, b = Number.NaN] = nums + switch (name) { + case "max": return Math.max(...nums) + case "min": return Math.min(...nums) + case "abs": return Math.abs(a) + case "floor": return Math.floor(a) + case "ceil": return Math.ceil(a) + case "round": return Math.round(a) + case "trunc": return Math.trunc(a) + case "sign": return Math.sign(a) + case "sqrt": return Math.sqrt(a) + case "cbrt": return Math.cbrt(a) + case "pow": return Math.pow(a, b) + case "hypot": return Math.hypot(...nums) + case "log": return Math.log(a) + case "log2": return Math.log2(a) + case "log10": return Math.log10(a) + case "exp": return Math.exp(a) + default: throw new InterpreterRuntimeError(`Math.${name} is not available in Rune.`, node) + } +} + +const invokeJsonMethod = (name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + switch (name) { + case "stringify": { + const replacer = args[1] + if (Array.isArray(replacer) || replacer instanceof RuneFunction) { + throw new InterpreterRuntimeError("JSON.stringify replacers are not supported in Rune.", node, "UnsupportedSyntax", [supportedSyntaxMessage]) + } + const space = args[2] + const indent = typeof space === "number" || typeof space === "string" ? space : undefined + // copyIn first so only Data Values serialize — never a RuneFunction/ToolReference. + return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value", limits)), null, indent) + } + case "parse": { + const text = args[0] + if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node) + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + throw new InterpreterRuntimeError("JSON.parse received invalid JSON.", node) + } + return copyIn(parsed, "JSON.parse result", limits) + } + default: throw new InterpreterRuntimeError(`JSON.${name} is not available in Rune.`, node) + } +} + +const invokeArrayStatic = (name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + switch (name) { + case "isArray": + return Array.isArray(args[0]) + case "of": + return [...args] + case "from": { + if (args.length > 1) { + throw new InterpreterRuntimeError( + "Array.from(...) does not support a map function in Rune; call .map() on the result instead.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + const source = boundedData(args[0], "Array.from input", node, limits) + if (typeof source === "string") return Array.from(source) + if (Array.isArray(source)) return [...source] + if (source !== null && typeof source === "object" && typeof (source as { length?: unknown }).length === "number") { + return Array.from(source as ArrayLike) + } + throw new InterpreterRuntimeError("Array.from expects an array, string, or array-like value.", node) + } + default: + throw new InterpreterRuntimeError(`Array.${name} is not available in Rune.`, node) + } +} + +const invokeNumberStatic = (name: string, args: Array, node: AstNode): unknown => { + const value = args[0] + switch (name) { + case "isInteger": return Number.isInteger(value) + case "isFinite": return Number.isFinite(value) + case "isNaN": return Number.isNaN(value) + case "isSafeInteger": return Number.isSafeInteger(value) + case "parseInt": { + const radix = args[1] + if (radix !== undefined && typeof radix !== "number") throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node) + return parseInt(coerceToString(value), radix) + } + case "parseFloat": return parseFloat(coerceToString(value)) + default: throw new InterpreterRuntimeError(`Number.${name} is not available in Rune.`, node) + } +} + +const invokeStringStatic = (name: string, args: Array, node: AstNode): unknown => { + const codes = args.map((arg) => { + if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node) + return arg + }) + switch (name) { + case "fromCharCode": return String.fromCharCode(...codes) + case "fromCodePoint": return String.fromCodePoint(...codes) + default: throw new InterpreterRuntimeError(`String.${name} is not available in Rune.`, node) + } +} + +const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node, limits) + if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node) + if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node, limits) + if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node) + if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node) + return invokeJsonMethod(ref.name, args, node, limits) +} + +// Every identifier a parameter pattern binds, used to seed TDZ slots before defaults run. +const collectPatternNames = (pattern: AstNode, out: Array = []): Array => { + switch (pattern.type) { + case "Identifier": + out.push(getString(pattern, "name")) + break + case "AssignmentPattern": + collectPatternNames(getNode(pattern, "left"), out) + break + case "RestElement": + collectPatternNames(getNode(pattern, "argument"), out) + break + case "ArrayPattern": + for (const element of getArray(pattern, "elements")) { + if (element !== null) collectPatternNames(asNode(element, "elements"), out) + } + break + case "ObjectPattern": + for (const property of getArray(pattern, "properties")) { + const prop = asNode(property, "properties") + collectPatternNames(prop.type === "RestElement" ? getNode(prop, "argument") : getNode(prop, "value"), out) + } + break + } + return out +} + +class Interpreter { + private scopes: Array> + private readonly limits: ResolvedExecutionLimits + private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect + private readonly budget: { operations: number } + private lastValue: unknown + // Cached byte size (and, for objects, key count) of each live container, maintained incrementally + // by the mutation helpers so appending in a loop is O(1)/op rather than re-walking the whole + // container each time (which made push/index-assign/key-assign loops O(n^2) — a CPU DoS). These + // are a fast path under the authoritative copyIn/copyOut boundary checks, never a replacement. + private readonly containerSizes = new WeakMap() + private readonly objectCounts = new WeakMap() + + constructor( + limits: ResolvedExecutionLimits, + invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, + budget: { operations: number } = { operations: 0 }, + ) { + const globalScope = new Map() + this.scopes = [globalScope] + this.limits = limits + this.invokeTool = invokeTool + this.budget = budget + this.lastValue = undefined + globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) + globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) + globalScope.set("undefined", { mutable: false, value: undefined }) + globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") }) + globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") }) + globalScope.set("JSON", { mutable: false, value: new GlobalNamespace("JSON") }) + globalScope.set("Number", { mutable: false, value: new CoercionFunction("Number") }) + globalScope.set("String", { mutable: false, value: new CoercionFunction("String") }) + globalScope.set("Boolean", { mutable: false, value: new CoercionFunction("Boolean") }) + globalScope.set("Array", { mutable: false, value: new GlobalNamespace("Array") }) + globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") }) + globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") }) + } + + run(program: ProgramNode): Effect.Effect { + const self = this + // Run the program body in its own module scope on top of the builtin global scope, so + // top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like + // JS module scope, instead of colliding with the seeded globals. + this.pushScope() + return Effect.gen(function*() { + self.hoistFunctions(program.body) + for (const statement of program.body) { + const result = yield* self.evaluateStatement(statement) + + if (result.kind === "return") { + return result.value + } + + if (result.kind === "break" || result.kind === "continue") { + throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement) + } + + if (result.kind === "value") { + self.lastValue = result.value + } + } + + return self.lastValue + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private evaluateStatement(node: AstNode): Effect.Effect { + this.recordOperation(node) + + switch (node.type) { + case "ExpressionStatement": + return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value })) + case "VariableDeclaration": + return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" })) + case "ReturnStatement": { + const argumentNode = getOptionalNode(node, "argument") + return argumentNode + ? Effect.map(this.evaluateExpression(argumentNode), (value) => ({ kind: "return", value })) + : Effect.succeed({ kind: "return", value: undefined }) + } + case "BlockStatement": + return this.evaluateBlock(node) + case "IfStatement": + return this.evaluateIfStatement(node) + case "SwitchStatement": + return this.evaluateSwitchStatement(node) + case "WhileStatement": + return this.evaluateWhileStatement(node) + case "DoWhileStatement": + return this.evaluateDoWhileStatement(node) + case "ForStatement": + return this.evaluateForStatement(node) + case "ForOfStatement": + return this.evaluateForOfStatement(node) + case "BreakStatement": + return Effect.succeed(this.evaluateBreakStatement(node)) + case "ContinueStatement": + return Effect.succeed(this.evaluateContinueStatement(node)) + case "ThrowStatement": + return this.evaluateThrowStatement(node) + case "TryStatement": + return this.evaluateTryStatement(node) + case "EmptyStatement": + return Effect.succeed({ kind: "none" }) + case "FunctionDeclaration": + return Effect.succeed({ kind: "none" }) // bound ahead of time by hoistFunctions + default: + throw unsupportedSyntax(node.type, node) + } + } + + private evaluateBlock(node: AstNode): Effect.Effect { + this.pushScope() + const self = this + return Effect.gen(function*() { + const body = getArray(node, "body") + self.hoistFunctions(body) + + for (const statementValue of body) { + const statement = asNode(statementValue, "body") + const result = yield* self.evaluateStatement(statement) + + if (result.kind === "value") { + self.lastValue = result.value + continue + } + + if (result.kind !== "none") { + return result + } + } + + return { kind: "none" } satisfies StatementResult + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private createFunction(node: AstNode): RuneFunction { + if (node.generator === true) { + throw new InterpreterRuntimeError("Generator functions are not supported in Rune.", node, "UnsupportedSyntax", [supportedSyntaxMessage]) + } + return new RuneFunction( + getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)), + getNode(node, "body"), + this.scopes.slice(), + ) + } + + // Function declarations are hoisted: bound in their scope before the body runs, so a + // program can call a helper defined further down (matching JavaScript). + private hoistFunctions(statements: Array): void { + for (const statementValue of statements) { + if (!isRecord(statementValue) || statementValue.type !== "FunctionDeclaration") continue + const node = statementValue as AstNode + this.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node) + } + } + + private evaluateIfStatement(node: AstNode): Effect.Effect { + const testNode = getNode(node, "test") + const consequentNode = getNode(node, "consequent") + const alternateNode = getOptionalNode(node, "alternate") + + return Effect.flatMap(this.evaluateExpression(testNode), (test) => + test ? this.evaluateStatement(consequentNode) : alternateNode ? this.evaluateStatement(alternateNode) : Effect.succeed({ kind: "none" })) + } + + private evaluateSwitchStatement(node: AstNode): Effect.Effect { + const self = this + this.pushScope() + return Effect.gen(function*() { + const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant")) + if (containsRuntimeReference(discriminant)) { + throw new InterpreterRuntimeError("Switch discriminants must be data values in Rune.", node, "InvalidDataValue") + } + const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`)) + let defaultIndex: number | undefined + let selected: number | undefined + for (const [index, branch] of cases.entries()) { + const test = getOptionalNode(branch, "test") + if (!test) { + defaultIndex = index + continue + } + const candidate = yield* self.evaluateExpression(test) + if (containsRuntimeReference(candidate)) { + throw new InterpreterRuntimeError("Switch case values must be data values in Rune.", test, "InvalidDataValue") + } + if (candidate === discriminant) { + selected = index + break + } + } + const start = selected ?? defaultIndex + if (start === undefined) return { kind: "none" } satisfies StatementResult + for (let index = start; index < cases.length; index += 1) { + for (const statementValue of getArray(cases[index]!, "consequent")) { + const result = yield* self.evaluateStatement(asNode(statementValue, "consequent")) + if (result.kind === "break") return { kind: "none" } satisfies StatementResult + if (result.kind === "return" || result.kind === "continue") return result + if (result.kind === "value") self.lastValue = result.value + } + } + return { kind: "none" } satisfies StatementResult + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private evaluateWhileStatement(node: AstNode): Effect.Effect { + const testNode = getNode(node, "test") + const bodyNode = getNode(node, "body") + + const self = this + return Effect.gen(function*() { + while (yield* self.evaluateExpression(testNode)) { + const result = yield* self.evaluateStatement(bodyNode) + + if (result.kind === "continue") { + continue + } + + if (result.kind === "break") { + return { kind: "none" } satisfies StatementResult + } + + if (result.kind === "return") { + return result + } + + if (result.kind === "value") { + self.lastValue = result.value + } + } + + return { kind: "none" } satisfies StatementResult + }) + } + + private evaluateDoWhileStatement(node: AstNode): Effect.Effect { + const bodyNode = getNode(node, "body") + const testNode = getNode(node, "test") + + const self = this + return Effect.gen(function*() { + do { + const result = yield* self.evaluateStatement(bodyNode) + + if (result.kind === "continue") { + continue + } + + if (result.kind === "break") { + return { kind: "none" } satisfies StatementResult + } + + if (result.kind === "return") { + return result + } + + if (result.kind === "value") { + self.lastValue = result.value + } + } while (yield* self.evaluateExpression(testNode)) + + return { kind: "none" } satisfies StatementResult + }) + } + + private evaluateForStatement(node: AstNode): Effect.Effect { + this.pushScope() + const self = this + return Effect.gen(function*() { + const initNode = getOptionalNode(node, "init") + const testNode = getOptionalNode(node, "test") + const updateNode = getOptionalNode(node, "update") + const bodyNode = getNode(node, "body") + + if (initNode) { + if (initNode.type === "VariableDeclaration") { + yield* self.evaluateVariableDeclaration(initNode) + } else { + yield* self.evaluateExpression(initNode) + } + } + + const perIterationBindings = initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var" + ? Array.from(self.currentScope().keys()) + : [] + + while (testNode ? yield* self.evaluateExpression(testNode) : true) { + let iterationScope: Map | undefined + if (perIterationBindings.length > 0) { + iterationScope = new Map(perIterationBindings.map((name) => { + const binding = self.currentScope().get(name)! + return [name, { ...binding }] + })) + self.scopes.push(iterationScope) + } + const result = yield* self.evaluateStatement(bodyNode).pipe( + Effect.ensuring(Effect.sync(() => { + if (iterationScope) self.popScope() + })), + ) + + if (result.kind === "return") { + return result + } + + if (result.kind === "break") { + return { kind: "none" } satisfies StatementResult + } + + if (result.kind === "value") { + self.lastValue = result.value + } + + if (iterationScope) { + const loopScope = self.currentScope() + for (const name of perIterationBindings) { + loopScope.set(name, { ...iterationScope.get(name)! }) + } + } + + if (updateNode) { + yield* self.evaluateExpression(updateNode) + } + + if (result.kind === "continue") { + continue + } + } + + return { kind: "none" } satisfies StatementResult + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private evaluateForOfStatement(node: AstNode): Effect.Effect { + if (getBoolean(node, "await")) { + throw new InterpreterRuntimeError("for await...of is not supported.", node) + } + + const self = this + return Effect.gen(function*() { + const left = getNode(node, "left") + const right = yield* self.evaluateExpression(getNode(node, "right")) + const body = getNode(node, "body") + + if (!Array.isArray(right)) { + throw new InterpreterRuntimeError("for...of requires an array value in Rune.", node) + } + + let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined + let assignmentName: string | undefined + + if (left.type === "VariableDeclaration") { + const declarations = getArray(left, "declarations") + if (declarations.length !== 1) { + throw new InterpreterRuntimeError("for...of supports one declared binding.", left) + } + + const declarator = asNode(declarations[0], "declarations[0]") + declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" } + } else if (left.type === "Identifier") { + assignmentName = getString(left, "name") + } else { + throw new InterpreterRuntimeError("Unsupported for...of binding.", left) + } + + for (const value of right) { + if (declaration) { + self.pushScope() + yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left) + } else if (assignmentName) { + self.setIdentifierValue(assignmentName, value, left) + } + + const result = yield* self.evaluateStatement(body).pipe( + Effect.ensuring(Effect.sync(() => { + if (declaration) self.popScope() + })), + ) + + if (result.kind === "return") { + return result + } + + if (result.kind === "break") { + return { kind: "none" } + } + + if (result.kind === "value") { + self.lastValue = result.value + } + + if (result.kind === "continue") { + continue + } + } + + return { kind: "none" } + }) + } + + private evaluateBreakStatement(node: AstNode): StatementResult { + const labelNode = getOptionalNode(node, "label") + + if (labelNode) { + throw new InterpreterRuntimeError("Labeled break is not supported in v1.", node) + } + + return { kind: "break" } + } + + private evaluateContinueStatement(node: AstNode): StatementResult { + const labelNode = getOptionalNode(node, "label") + + if (labelNode) { + throw new InterpreterRuntimeError("Labeled continue is not supported in v1.", node) + } + + return { kind: "continue" } + } + + private evaluateThrowStatement(node: AstNode): Effect.Effect { + const argument = getNode(node, "argument") + return Effect.flatMap(this.evaluateExpression(argument), (value) => Effect.fail(new ProgramThrow(value))) + } + + private evaluateTryStatement(node: AstNode): Effect.Effect { + const body = getNode(node, "block") + const handler = getOptionalNode(node, "handler") + const finalizer = getOptionalNode(node, "finalizer") + const self = this + + const attempted = Effect.matchCauseEffect(this.evaluateStatement(body), { + onFailure: (cause) => { + if (cause.reasons.some(Cause.isInterruptReason) || !handler) { + return Effect.failCause(cause) + } + + const thrown = Cause.squash(cause) + // The program sees a plain { message } error. Use the interpreter error's raw message so + // the program never sees the transpiled-source "(line N, col N)" coordinates that + // normalizeError appends — without disturbing a host/tool message that legitimately ends + // that way. Other error kinds carry no appended location, so normalizeError is used as-is. + const caught = thrown instanceof ProgramThrow + ? thrown.value + : Object.assign(Object.create(null) as SafeObject, { + message: thrown instanceof InterpreterRuntimeError ? thrown.message : normalizeError(thrown).message, + }) + const parameter = getOptionalNode(handler, "param") + self.pushScope() + return Effect.gen(function*() { + if (parameter) yield* self.declarePattern(parameter, caught, true, handler) + return yield* self.evaluateStatement(getNode(handler, "body")) + }).pipe( + Effect.ensuring(Effect.sync(() => self.popScope())), + ) + }, + onSuccess: Effect.succeed, + }) + + if (!finalizer) return attempted + + const isAbrupt = (result: StatementResult): boolean => + result.kind === "return" || result.kind === "break" || result.kind === "continue" + + return Effect.matchCauseEffect(attempted, { + onFailure: (cause) => + cause.reasons.some(Cause.isInterruptReason) + ? Effect.failCause(cause) + : Effect.flatMap(this.evaluateStatement(finalizer), (final) => + isAbrupt(final) ? Effect.succeed(final) : Effect.failCause(cause)), + onSuccess: (result) => + Effect.flatMap(this.evaluateStatement(finalizer), (final) => + isAbrupt(final) ? Effect.succeed(final) : Effect.succeed(result)), + }) + } + + private evaluateVariableDeclaration(node: AstNode): Effect.Effect { + const kind = getString(node, "kind") + const declarations = getArray(node, "declarations") + const self = this + return Effect.gen(function*() { + for (const declarationValue of declarations) { + const declaration = asNode(declarationValue, "declarations") + + if (declaration.type !== "VariableDeclarator") { + throw new InterpreterRuntimeError("Unsupported variable declaration shape.", declaration) + } + + const init = getOptionalNode(declaration, "init") + const value = init ? yield* self.evaluateExpression(init) : undefined + yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration) + } + }) + } + + private declarePattern(pattern: AstNode, value: unknown, mutable: boolean, node: AstNode): Effect.Effect { + const self = this + return Effect.gen(function*() { + if (pattern.type === "Identifier") { + self.declare(getString(pattern, "name"), value, mutable, node) + return + } + + // Default values: `x = expr` / `{ a = 1 }` — the default is evaluated only when the value is undefined. + if (pattern.type === "AssignmentPattern") { + const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value + yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node) + return + } + + if (pattern.type === "ObjectPattern") { + if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) { + throw new InterpreterRuntimeError("Object destructuring requires a data object value.", pattern, "InvalidDataValue") + } + + const consumed = new Set() + for (const propertyValue of getArray(pattern, "properties")) { + const property = asNode(propertyValue, "properties") + + // Object rest: `{ a, ...others }` — gather the not-yet-consumed own keys. + if (property.type === "RestElement") { + const rest: SafeObject = Object.create(null) as SafeObject + for (const [key, item] of Object.entries(value as SafeObject)) { + if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item + } + yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property) + continue + } + + if (property.type !== "Property" || getBoolean(property, "computed") || getString(property, "kind") !== "init") { + throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property) + } + + const keyNode = getNode(property, "key") + const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value) + if (isBlockedMember(key)) { + throw new InterpreterRuntimeError(`Property '${key}' is not available in Rune.`, keyNode) + } + consumed.add(key) + yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property) + } + return + } + + if (pattern.type === "ArrayPattern") { + if (!Array.isArray(value)) { + throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern) + } + + for (const [index, item] of getArray(pattern, "elements").entries()) { + if (item === null) continue + const element = asNode(item, `elements[${index}]`) + // Array rest: `[head, ...tail]` — binds the remaining elements (must be last). + if (element.type === "RestElement") { + yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element) + break + } + yield* self.declarePattern(element, value[index], mutable, pattern) + } + return + } + + throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern) + }) + } + + private evaluateExpression(node: AstNode): Effect.Effect { + this.recordOperation(node) + + switch (node.type) { + case "Literal": + return Effect.sync(() => boundedData(node.value, "Literal", node, this.limits)) + case "Identifier": + return Effect.sync(() => this.getIdentifierValue(getString(node, "name"), node)) + case "BinaryExpression": + return this.evaluateBinaryExpression(node) + case "LogicalExpression": + return this.evaluateLogicalExpression(node) + case "UnaryExpression": + return this.evaluateUnaryExpression(node) + case "AssignmentExpression": + return this.evaluateAssignmentExpression(node) + case "CallExpression": + return this.evaluateCallExpression(node) + case "ArrowFunctionExpression": + case "FunctionExpression": + return Effect.sync(() => this.createFunction(node)) + case "MemberExpression": + return this.readMember(node) + case "ChainExpression": + return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => + value === OptionalShortCircuit ? undefined : value) + case "ObjectExpression": + return this.evaluateObjectExpression(node) + case "ArrayExpression": + return this.evaluateArrayExpression(node) + case "TemplateLiteral": + return this.evaluateTemplateLiteral(node) + case "ConditionalExpression": + return this.evaluateConditionalExpression(node) + case "UpdateExpression": + return this.evaluateUpdateExpression(node) + case "AwaitExpression": { + return this.evaluateExpression(getNode(node, "argument")) + } + case "NewExpression": + return this.evaluateNewExpression(node) + default: + throw unsupportedSyntax(node.type, node) + } + } + + private evaluateNewExpression(node: AstNode): Effect.Effect { + const callee = getNode(node, "callee") + if (callee.type !== "Identifier" || !errorConstructors.has(getString(callee, "name"))) { + throw unsupportedSyntax("NewExpression", node) + } + const name = getString(callee, "name") + const argNodes = getArray(node, "arguments") + const self = this + return Effect.gen(function*() { + const arg = argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined + const message = arg === undefined ? "" : coerceToString(arg) + const errorValue: SafeObject = Object.create(null) as SafeObject + errorValue.name = name + errorValue.message = message + return errorValue + }) + } + + private evaluateBinaryExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + const self = this + return Effect.gen(function*() { + const lhs = (yield* self.evaluateExpression(getNode(node, "left"))) as any + const rhs = (yield* self.evaluateExpression(getNode(node, "right"))) as any + if (containsRuntimeReference(lhs) || containsRuntimeReference(rhs)) { + throw new InterpreterRuntimeError("Binary operators require data values in Rune.", node, "InvalidDataValue") + } + // Data objects/arrays are null-prototype, so JS's ToPrimitive throws an opaque host + // "No default value" TypeError when an operator coerces them. Coerce to their JS string + // form first (as String(x) / template literals do) so operators behave like JavaScript. + // Identity (=== / !==) and the right operand of `in` keep their raw object value. + const coerceOperand = (operand: unknown): unknown => + operand !== null && typeof operand === "object" ? coerceToString(operand) : operand + const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object" + const l = coerceOperand(lhs) as any + const r = coerceOperand(rhs) as any + let result: unknown + switch (operator) { + case "+": result = l + r; break + case "-": result = l - r; break + case "*": result = l * r; break + case "/": result = l / r; break + case "%": result = l % r; break + case "**": result = l ** r; break + // Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces. + case "==": result = bothObjects ? lhs === rhs : l == r; break + case "===": result = lhs === rhs; break + case "!=": result = bothObjects ? lhs !== rhs : l != r; break + case "!==": result = lhs !== rhs; break + case "<": result = l < r; break + case "<=": result = l <= r; break + case ">": result = l > r; break + case ">=": result = l >= r; break + case "&": result = l & r; break + case "|": result = l | r; break + case "^": result = l ^ r; break + case "<<": result = l << r; break + case ">>": result = l >> r; break + case ">>>": result = l >>> r; break + case "in": + if (rhs === null || typeof rhs !== "object") { + throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node) + } + // Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...). + result = Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey); break + default: throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node) + } + return boundedData(result, "Binary expression result", node, self.limits) + }) + } + + private evaluateLogicalExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + return Effect.flatMap(this.evaluateExpression(getNode(node, "left")), (left) => { + if (operator === "&&") return left ? this.evaluateExpression(getNode(node, "right")) : Effect.succeed(left) + if (operator === "||") return left ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right")) + if (operator === "??") return left !== null && left !== undefined ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right")) + throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node) + }) + } + + private evaluateUnaryExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + return Effect.map(this.evaluateExpression(getNode(node, "argument")), (value) => { + if (containsRuntimeReference(value)) { + throw new InterpreterRuntimeError("Unary operators require data values in Rune.", node, "InvalidDataValue") + } + const rhs = value as any + // Numeric/bitwise unary operators ToPrimitive their operand; coerce null-prototype + // data objects/arrays to their JS string form first (see evaluateBinaryExpression). + // `!` and `typeof` operate on the raw value (no ToPrimitive, no crash). + const operand = + (operator === "+" || operator === "-" || operator === "~") && rhs !== null && typeof rhs === "object" + ? (coerceToString(rhs) as any) + : rhs + let result: unknown + switch (operator) { + case "+": result = +operand; break + case "-": result = -operand; break + case "!": result = !rhs; break + case "typeof": result = typeof rhs; break + case "~": result = ~operand; break + default: throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node) + } + return boundedData(result, "Unary expression result", node, this.limits) + }) + } + + private evaluateAssignmentExpression(node: AstNode): Effect.Effect { + const left = getNode(node, "left") + const operator = getString(node, "operator") + const self = this + return Effect.gen(function*() { + if (operator === "??=" || operator === "||=" || operator === "&&=") { + return yield* self.evaluateLogicalAssignment(node, left, operator) + } + const rightValue = yield* self.evaluateExpression(getNode(node, "right")) + if (left.type === "Identifier") { + const name = getString(left, "name") + if (operator === "=") return self.setIdentifierValue(name, rightValue, left) + const next = boundedData(self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node), "Assignment result", node, self.limits) + return self.setIdentifierValue(name, next, left) + } + if (left.type === "MemberExpression") { + if (operator === "=") return yield* self.writeMember(left, rightValue) + return yield* self.modifyMember(left, (current) => { + const next = boundedData(self.applyCompoundAssignment(operator, current, rightValue, node), "Assignment result", node, self.limits) + return Effect.succeed({ write: true, next, result: next }) + }) + } + throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left) + }) + } + + private evaluateLogicalAssignment(node: AstNode, left: AstNode, operator: string): Effect.Effect { + const self = this + const shouldAssign = (current: unknown): boolean => + operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current) + if (left.type === "Identifier") { + const name = getString(left, "name") + return Effect.gen(function*() { + const current = self.getIdentifierValue(name, left) + if (!shouldAssign(current)) return current + const rightValue = yield* self.evaluateExpression(getNode(node, "right")) + return self.setIdentifierValue(name, rightValue, left) + }) + } + if (left.type === "MemberExpression") { + // Resolve the member exactly once; evaluate the RHS only if we actually assign. + return self.modifyMember(left, (current) => + shouldAssign(current) + ? Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => ({ write: true, next: rightValue, result: rightValue })) + : Effect.succeed({ write: false, next: current, result: current })) + } + throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left) + } + + private evaluateUpdateExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + const argument = getNode(node, "argument") + const prefix = getBoolean(node, "prefix") + + const increment = operator === "++" ? 1 : operator === "--" ? -1 : undefined + + if (increment === undefined) { + throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node) + } + + if (argument.type === "Identifier") { + return Effect.sync(() => { + const name = getString(argument, "name") + const current = Number(this.getIdentifierValue(name, argument)) + const next = boundedData(current + increment, "Update result", node, this.limits) as number + this.setIdentifierValue(name, next, argument) + return prefix ? next : current + }) + } + + if (argument.type === "MemberExpression") { + return this.modifyMember(argument, (current) => { + const value = Number(current) + const next = boundedData(value + increment, "Update result", node, this.limits) as number + return Effect.succeed({ write: true, next, result: prefix ? next : value }) + }) + } + + throw new InterpreterRuntimeError("Update target must be an Identifier or MemberExpression.", argument) + } + + private evaluateCallExpression(node: AstNode): Effect.Effect { + const callee = getNode(node, "callee") + const argNodes = getArray(node, "arguments") + + const self = this + return Effect.gen(function*() { + const callable = yield* self.evaluateExpression(callee) + if (callable === OptionalShortCircuit) return OptionalShortCircuit + if ((callable === null || callable === undefined) && node.optional === true) return OptionalShortCircuit + if (callable instanceof PromiseAllReference) { + if (argNodes.length !== 1) { + throw new InterpreterRuntimeError(`Promise.all expects exactly one collection expression. ${supportedSyntaxMessage}`, node) + } + const argument = asNode(argNodes[0], "arguments[0]") + return yield* self.evaluatePromiseAll(argument, node) + } + + const args = yield* self.evaluateCallArguments(argNodes) + + if (callable instanceof ToolReference) { + if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee) + return yield* self.invokeTool(callable.path, args) + } + if (callable instanceof RuneFunction) { + return yield* self.invokeFunction(callable, args) + } + if (callable instanceof IntrinsicReference) { + return yield* self.invokeIntrinsic(callable, args, node) + } + if (callable instanceof GlobalMethodReference) { + const globalResult = invokeGlobalMethod(callable, args, node, self.limits) + self.recordWork(workUnits(globalResult), node) + return boundedData(globalResult, `${callable.namespace}.${callable.name} result`, node, self.limits) + } + if (callable instanceof CoercionFunction) { + const coercionResult = invokeCoercion(callable, args, node, self.limits) + self.recordWork(workUnits(coercionResult), node) + return boundedData(coercionResult, `${callable.name} result`, node, self.limits) + } + throw new InterpreterRuntimeError("Only tool capabilities are callable in Rune.", callee) + }) + } + + private evaluateCallArguments(argNodes: Array): Effect.Effect, unknown, R> { + const self = this + return Effect.gen(function*() { + const args: Array = [] + for (const [index, arg] of argNodes.entries()) { + const argNode = asNode(arg, `arguments[${index}]`) + if (argNode.type === "SpreadElement") { + const spread = yield* self.evaluateExpression(getNode(argNode, "argument")) + const items = Array.isArray(spread) ? spread : typeof spread === "string" ? Array.from(spread) : undefined + if (items === undefined) throw new InterpreterRuntimeError("Spread arguments require an array or string in Rune.", argNode) + if (args.length + items.length > self.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Call arguments exceed the maximum collection length of ${self.limits.maxCollectionLength}.`, argNode, "InvalidDataValue") + } + args.push(...items) + self.recordWork(items.length, argNode) + } else { + args.push(yield* self.evaluateExpression(argNode)) + if (args.length > self.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Call arguments exceed the maximum collection length of ${self.limits.maxCollectionLength}.`, argNode, "InvalidDataValue") + } + } + } + return args + }) + } + + private evaluatePromiseAll(argument: AstNode, node: AstNode): Effect.Effect, unknown, R> { + if (argument.type === "ArrayExpression") { + const elements = getArray(argument, "elements") + if (elements.length > this.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Promise.all exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "ConcurrencyLimitExceeded") + } + const calls = elements.map((value, index) => { + if (value === null) { + throw new InterpreterRuntimeError(`Promise.all array elements must be direct Tool Capability calls. ${supportedSyntaxMessage}`, argument) + } + const element = asNode(value, `elements[${index}]`) + if (element.type === "SpreadElement") { + throw new InterpreterRuntimeError(`Promise.all does not support spread elements yet. ${supportedSyntaxMessage}`, element) + } + if (!this.isToolCallExpression(element)) { + throw new InterpreterRuntimeError(`Promise.all array elements must be direct Tool Capability calls. ${supportedSyntaxMessage}`, element) + } + return element.type === "AwaitExpression" ? getNode(element, "argument") : element + }) + const self = this + return Effect.gen(function*() { + const prepared: Array<{ readonly path: ReadonlyArray; readonly args: Array }> = [] + for (const call of calls) { + const callable = yield* self.evaluateExpression(getNode(call, "callee")) + if (!(callable instanceof ToolReference) || callable.path.length === 0) { + throw new InterpreterRuntimeError("Promise.all expects direct Tool Capability calls.", call) + } + const args = yield* self.evaluateCallArguments(getArray(call, "arguments")) + prepared.push({ path: callable.path, args }) + } + const values = yield* Effect.all(prepared.map(({ path, args }) => self.invokeTool(path, args)), { concurrency: self.limits.maxConcurrency }) + return boundedProgramValue(values, "Promise.all result", node, self.limits) as Array + }) + } + + if (argument.type === "CallExpression") { + return this.evaluateParallelMap(argument, node) + } + + throw new InterpreterRuntimeError(`Promise.all supports an array literal or a direct .map(...) expression. ${supportedSyntaxMessage}`, node) + } + + private evaluateParallelMap(call: AstNode, node: AstNode): Effect.Effect, unknown, R> { + const callee = getNode(call, "callee") + const args = getArray(call, "arguments") + if (callee.type !== "MemberExpression" || args.length !== 1) { + throw new InterpreterRuntimeError(`Promise.all supports direct items.map((item) => tools.path(item)) expressions. ${supportedSyntaxMessage}`, node) + } + + const self = this + return Effect.gen(function*() { + const method = yield* self.evaluateExpression(callee) + const callback = yield* self.evaluateExpression(asNode(args[0], "arguments[0]")) + if (!(method instanceof IntrinsicReference) || method.name !== "map" || !(callback instanceof RuneFunction)) { + throw new InterpreterRuntimeError(`Promise.all supports direct items.map((item) => tools.path(item)) expressions. ${supportedSyntaxMessage}`, node) + } + if (!self.isToolCallExpression(callback.body)) { + throw new InterpreterRuntimeError(`Promise.all mapped callbacks must directly call a Tool Capability. ${supportedSyntaxMessage}`, node) + } + const items = method.receiver as Array + if (items.length > self.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Promise.all exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, node, "ConcurrencyLimitExceeded") + } + + const values = yield* Effect.all( + items.map((item, index) => Effect.suspend(() => self.forkForParallelCallback().invokeFunction(callback, [item, index]))), + { concurrency: self.limits.maxConcurrency }, + ) + return boundedProgramValue(values, "Promise.all result", node, self.limits) as Array + }) + } + + private isToolCallExpression(node: AstNode): boolean { + const expression = node.type === "AwaitExpression" ? getNode(node, "argument") : node + return expression.type === "CallExpression" && this.isToolPath(getNode(expression, "callee")) + } + + private isToolPath(node: AstNode): boolean { + if (node.type === "Identifier") return getString(node, "name") === "tools" + return node.type === "MemberExpression" && this.isToolPath(getNode(node, "object")) + } + + private invokeFunction(fn: RuneFunction, args: Array): Effect.Effect { + const self = this + return Effect.suspend(() => { + const savedScopes = self.scopes + self.scopes = [...fn.capturedScopes, new Map()] + const run = Effect.gen(function*() { + // Seed every parameter name into the scope as a TDZ slot first, so a default that + // references another parameter resolves to that (uninitialized) param rather than + // silently falling through to an outer binding of the same name — matching JS. + const paramScope = self.currentScope() + for (const parameter of fn.parameters) { + for (const name of collectPatternNames(parameter)) { + paramScope.set(name, { mutable: true, value: undefined, initialized: false }) + } + } + for (const [index, parameter] of fn.parameters.entries()) { + if (parameter.type === "RestElement") { + yield* self.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter) + break + } + yield* self.declarePattern(parameter, args[index], true, parameter) + } + + if (fn.body.type === "BlockStatement") { + const result = yield* self.evaluateStatement(fn.body) + return result.kind === "return" || result.kind === "value" ? result.value : undefined + } + + return yield* self.evaluateExpression(fn.body) + }) + return run.pipe(Effect.ensuring(Effect.sync(() => { self.scopes = savedScopes }))) + }) + } + + private invokeIntrinsic(ref: IntrinsicReference, args: Array, node: AstNode): Effect.Effect { + if (typeof ref.receiver === "string") { + this.recordWork(ref.receiver.length, node) + const result = invokeStringMethod(ref.receiver, ref.name, args, node, this.limits) + if (typeof result === "string") this.recordWork(result.length, node) + return Effect.succeed(result) + } + if (typeof ref.receiver === "number") { + return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node, this.limits)) + } + if (Array.isArray(ref.receiver)) { + if (!cheapArrayMethods.has(ref.name)) this.recordWork(ref.receiver.length, node) + const self = this + return Effect.map(this.invokeArrayMethod(ref.receiver, ref.name, args, node), (result) => { + if (Array.isArray(result)) self.recordWork(result.length, node) + return result + }) + } + throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in Rune.`, node) + } + + private invokeArrayMethod(target: Array, name: string, args: Array, node: AstNode): Effect.Effect { + const boundedCollection = (items: Array): Array => { + if (items.length > this.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Array.${name} exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + return boundedProgramValue(items, `Array.${name} result`, node, this.limits) as Array + } + const optNumber = (value: unknown, label: string): number | undefined => { + if (value === undefined) return undefined + if (typeof value !== "number") throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node) + return value + } + switch (name) { + case "join": { + if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) { + throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node) + } + const input = boundedData(target, "Array.join input", node, this.limits) as Array + return Effect.succeed(boundedData(input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : args[0] as string), "Array.join result", node, this.limits)) + } + case "includes": + if (args.length === 0 || args.length > 2) throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node) + return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index"))) + case "indexOf": + return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index"))) + case "lastIndexOf": + return Effect.succeed(args[1] === undefined ? target.lastIndexOf(args[0]) : target.lastIndexOf(args[0], optNumber(args[1], "start index"))) + case "at": + return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0)) + case "slice": + return Effect.succeed(boundedCollection(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end")))) + case "concat": + return Effect.succeed(boundedCollection(target.concat(...args))) + case "flat": + return Effect.succeed(boundedCollection(target.flat(optNumber(args[0], "depth") ?? 1))) + case "reverse": + return Effect.succeed(boundedCollection([...target].reverse())) + case "sort": + case "toSorted": + return this.sortArray(target, args[0], node) + case "toReversed": + return Effect.succeed(boundedCollection([...target].reverse())) + case "with": { + const index = optNumber(args[0], "index") ?? 0 + const resolved = index < 0 ? target.length + index : index + if (resolved < 0 || resolved >= target.length) { + throw new InterpreterRuntimeError("Array.with index is out of range.", node) + } + const copied = [...target] + copied[resolved] = args[1] + return Effect.succeed(boundedCollection(copied)) + } + case "push": { + if (target.length + args.length > this.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Array.push exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + // Validate before mutating (so no rollback is needed) and charge only the new elements, + // keeping a push loop O(1)/element instead of re-walking the whole array each call. + let added = 0 + for (const item of args) { + this.rejectCircularInsertion(target, item, "Array.push result", node) + added += this.nestedValueBytes(item, "Array.push result", node) + 1 + } + this.growContainerBytes(target, added, node, "Array.push result") + target.push(...args) + return Effect.succeed(target.length) + } + case "unshift": { + if (target.length + args.length > this.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Array.unshift exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + let added = 0 + for (const item of args) { + this.rejectCircularInsertion(target, item, "Array.unshift result", node) + added += this.nestedValueBytes(item, "Array.unshift result", node) + 1 + } + this.growContainerBytes(target, added, node, "Array.unshift result") + target.unshift(...args) + return Effect.succeed(target.length) + } + // Removals only shrink the array; drop the cached size so the next growth recomputes it. + case "pop": + this.containerSizes.delete(target) + return Effect.succeed(target.pop()) + case "shift": + this.containerSizes.delete(target) + return Effect.succeed(target.shift()) + } + + const callback = args[0] + if (!(callback instanceof RuneFunction)) { + throw new InterpreterRuntimeError(`Array.${name} expects an arrow function callback.`, node) + } + const self = this + return Effect.gen(function*() { + // Iterate a snapshot taken at call time so a callback that mutates the array can't + // self-extend the loop — matching JS, where elements appended during iteration are not visited. + const items = target.slice() + switch (name) { + case "map": { + const values: Array = [] + for (const [index, item] of items.entries()) values.push(yield* self.invokeFunction(callback, [item, index, items])) + return boundedCollection(values) + } + case "flatMap": { + const values: Array = [] + for (const [index, item] of items.entries()) { + const mapped = yield* self.invokeFunction(callback, [item, index, items]) + if (Array.isArray(mapped)) values.push(...mapped) + else values.push(mapped) + boundedCollection(values) + } + return boundedCollection(values) + } + case "filter": { + const values: Array = [] + for (const [index, item] of items.entries()) { + if (yield* self.invokeFunction(callback, [item, index, items])) values.push(item) + } + return boundedCollection(values) + } + case "find": + for (const [index, item] of items.entries()) { + if (yield* self.invokeFunction(callback, [item, index, items])) return item + } + return undefined + case "findIndex": + for (const [index, item] of items.entries()) { + if (yield* self.invokeFunction(callback, [item, index, items])) return index + } + return -1 + case "some": + for (const [index, item] of items.entries()) { + if (yield* self.invokeFunction(callback, [item, index, items])) return true + } + return false + case "every": + for (const [index, item] of items.entries()) { + if (!(yield* self.invokeFunction(callback, [item, index, items]))) return false + } + return true + case "forEach": + for (const [index, item] of items.entries()) yield* self.invokeFunction(callback, [item, index, items]) + return undefined + case "reduce": { + let accumulator: unknown + let start: number + if (args.length >= 2) { + accumulator = args[1] + start = 0 + } else { + if (items.length === 0) throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) + accumulator = items[0] + start = 1 + } + for (let index = start; index < items.length; index += 1) { + accumulator = yield* self.invokeFunction(callback, [accumulator, items[index], index, items]) + } + return accumulator + } + case "reduceRight": { + let accumulator: unknown + let start: number + if (args.length >= 2) { + accumulator = args[1] + start = items.length - 1 + } else { + if (items.length === 0) throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node) + accumulator = items[items.length - 1] + start = items.length - 2 + } + for (let index = start; index >= 0; index -= 1) { + accumulator = yield* self.invokeFunction(callback, [accumulator, items[index], index, items]) + } + return accumulator + } + case "findLast": + for (let index = items.length - 1; index >= 0; index -= 1) { + if (yield* self.invokeFunction(callback, [items[index], index, items])) return items[index] + } + return undefined + case "findLastIndex": + for (let index = items.length - 1; index >= 0; index -= 1) { + if (yield* self.invokeFunction(callback, [items[index], index, items])) return index + } + return -1 + } + throw new InterpreterRuntimeError(`Array method '${name}' is not available in Rune.`, node) + }) + } + + private sortArray(target: Array, comparator: unknown, node: AstNode): Effect.Effect, unknown, R> { + if (comparator !== undefined && !(comparator instanceof RuneFunction)) { + throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node) + } + if (!(comparator instanceof RuneFunction)) { + return Effect.sync(() => boundedProgramValue( + [...target].sort((a, b) => { + const left = coerceToString(a) + const right = coerceToString(b) + return left < right ? -1 : left > right ? 1 : 0 + }), + "Array.sort result", + node, + this.limits, + ) as Array) + } + const self = this + const mergeSort = (items: Array): Effect.Effect, unknown, R> => { + if (items.length <= 1) return Effect.succeed(items) + const midpoint = Math.floor(items.length / 2) + return Effect.gen(function*() { + const left = yield* mergeSort(items.slice(0, midpoint)) + const right = yield* mergeSort(items.slice(midpoint)) + const merged: Array = [] + let leftIndex = 0 + let rightIndex = 0 + while (leftIndex < left.length && rightIndex < right.length) { + // Coerce the comparator's result like JS ToNumber (data objects -> NaN, never a host + // crash) and treat NaN as 0 — the spec's "no consistent order" → keep the left element. + const order = coerceToNumber(yield* self.invokeFunction(comparator, [left[leftIndex], right[rightIndex]])) + if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++]) + else merged.push(right[rightIndex++]) + } + return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)] + }) + } + // Per spec, undefined elements sort to the end and the comparator is never called on them. + const defined = target.filter((item) => item !== undefined) + const undefinedCount = target.length - defined.length + return Effect.map(mergeSort(defined), (items) => + boundedProgramValue([...items, ...Array(undefinedCount).fill(undefined)], "Array.sort result", node, this.limits) as Array) + } + + private evaluateObjectExpression(node: AstNode): Effect.Effect, unknown, R> { + const objectValue: Record = Object.create(null) as Record + const keys = new Set() + const properties = getArray(node, "properties") + const self = this + return Effect.gen(function*() { + for (const propertyValue of properties) { + const property = asNode(propertyValue, "properties") + + if (property.type === "SpreadElement") { + const spread = yield* self.evaluateExpression(getNode(property, "argument")) + if (spread === null || typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) { + throw new InterpreterRuntimeError("Object spread requires a data object in Rune.", property, "InvalidDataValue") + } + for (const [key, value] of Object.entries(spread)) { + if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in Rune.`, property) + objectValue[key] = value + keys.add(key) + if (keys.size > self.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Object expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, property, "InvalidDataValue") + } + } + continue + } + + if (property.type !== "Property") { + throw new InterpreterRuntimeError("Only standard object properties are supported.", property) + } + + if (getString(property, "kind") !== "init") { + throw new InterpreterRuntimeError("Only init object properties are supported.", property) + } + + const keyNode = getNode(property, "key") + const valueNode = getNode(property, "value") + const computed = getBoolean(property, "computed") + + let key: PropertyKey + + if (computed) { + key = self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode) + } else if (keyNode.type === "Identifier") { + key = getString(keyNode, "name") + } else if (keyNode.type === "Literal") { + key = self.toPropertyKey(keyNode.value, keyNode) + } else { + throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode) + } + + if (isBlockedMember(String(key))) { + throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in Rune.`, keyNode) + } + objectValue[String(key)] = yield* self.evaluateExpression(valueNode) + keys.add(String(key)) + if (keys.size > self.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Object expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, property, "InvalidDataValue") + } + } + + return boundedProgramValue(objectValue, "Object expression result", node, self.limits) as Record + }) + } + + private evaluateArrayExpression(node: AstNode): Effect.Effect, unknown, R> { + const elements = getArray(node, "elements") + const values: Array = [] + + const self = this + return Effect.gen(function*() { + for (const elementValue of elements) { + if (elementValue === null) { + values.push(undefined) + if (values.length > self.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Array expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + continue + } + const element = asNode(elementValue, "elements") + if (element.type === "SpreadElement") { + const spread = yield* self.evaluateExpression(getNode(element, "argument")) + const items = Array.isArray(spread) ? spread : typeof spread === "string" ? Array.from(spread) : undefined + if (items === undefined) throw new InterpreterRuntimeError("Array spread requires an array or string in Rune.", element) + values.push(...items) + self.recordWork(items.length, element) + } else { + values.push(yield* self.evaluateExpression(element)) + } + if (values.length > self.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Array expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + } + return boundedProgramValue(values, "Array expression result", node, self.limits) as Array + }) + } + + private evaluateTemplateLiteral(node: AstNode): Effect.Effect { + const quasis = getArray(node, "quasis") + const expressions = getArray(node, "expressions") + + let output = "" + + const self = this + return Effect.gen(function*() { + for (let index = 0; index < quasis.length; index += 1) { + const quasi = asNode(quasis[index], "quasis") + const rawValue = quasi.value + + if (!isRecord(rawValue) || typeof rawValue.cooked !== "string") { + throw new InterpreterRuntimeError("Invalid template literal quasi.", quasi) + } + + output += rawValue.cooked + boundedData(output, "Template literal result", node, self.limits) + + if (index < expressions.length) { + const value = boundedData(yield* self.evaluateExpression(asNode(expressions[index], "expressions")), "Template interpolation", node, self.limits) + output += coerceToString(value) + boundedData(output, "Template literal result", node, self.limits) + } + } + + return output + }) + } + + private evaluateConditionalExpression(node: AstNode): Effect.Effect { + return Effect.flatMap(this.evaluateExpression(getNode(node, "test")), (test) => + this.evaluateExpression(getNode(node, test ? "consequent" : "alternate"))) + } + + private applyCompoundAssignment( + operator: string, + current: unknown, + incoming: unknown, + node: AstNode, + ): unknown { + const lhs = current as any + const rhs = incoming as any + + switch (operator) { + case "+=": + return lhs + rhs + case "-=": + return lhs - rhs + case "*=": + return lhs * rhs + case "/=": + return lhs / rhs + case "%=": + return lhs % rhs + case "**=": + return lhs ** rhs + case "&=": + return lhs & rhs + case "|=": + return lhs | rhs + case "^=": + return lhs ^ rhs + case "<<=": + return lhs << rhs + case ">>=": + return lhs >> rhs + case ">>>=": + return lhs >>> rhs + default: + throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node) + } + } + + private getMemberReference(node: AstNode): Effect.Effect { + const objectNode = getNode(node, "object") + const propertyNode = getNode(node, "property") + const computed = getBoolean(node, "computed") + const optional = node.optional === true + const self = this + return Effect.gen(function*() { + const objectValue = yield* self.evaluateExpression(objectNode) + if (objectValue === OptionalShortCircuit) return OptionalShortCircuit + if ((objectValue === null || objectValue === undefined) && optional) return OptionalShortCircuit + + const key = computed + ? self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode) + : propertyNode.type === "Identifier" + ? getString(propertyNode, "name") + : self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode) + + if (objectValue instanceof ToolReference) { + if (typeof key !== "string" || isBlockedMember(key)) { + throw new InterpreterRuntimeError("Tool paths must use safe string property names.", propertyNode) + } + return new ToolReference([...objectValue.path, key]) + } + + if (objectValue instanceof PromiseNamespace) { + if (key === "all") return new PromiseAllReference() + throw new InterpreterRuntimeError(`Promise.${String(key)} is not available in Rune. Use Promise.all(...) for parallel Tool Capabilities.`, propertyNode) + } + + if (objectValue instanceof GlobalNamespace) { + if (typeof key !== "string" || isBlockedMember(key)) { + throw new InterpreterRuntimeError(`${objectValue.name}.${String(key)} is not available in Rune.`, propertyNode) + } + if (objectValue.name === "Math" && mathConstants.has(key)) { + return new ComputedValue((Math as unknown as Record)[key]) + } + return new GlobalMethodReference(objectValue.name, key) + } + + if (typeof objectValue === "string") { + if (key === "length") return new ComputedValue(objectValue.length) + if (typeof key === "number") return new ComputedValue(objectValue[key]) + if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)]) + if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key) + throw new InterpreterRuntimeError(`String property '${String(key)}' is not available in Rune.`, propertyNode) + } + + if (typeof objectValue === "number") { + if (typeof key === "string" && numberMethods.has(key)) return new IntrinsicReference(objectValue, key) + throw new InterpreterRuntimeError(`Number property '${String(key)}' is not available in Rune.`, propertyNode) + } + + // Number / String expose a small allowlist of statics; everything else stays opaque. + if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) { + if (objectValue.name === "Number" && numberConstants.has(key)) { + return new ComputedValue((Number as unknown as Record)[key]) + } + if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key) + if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key) + } + + if (isRuntimeReference(objectValue)) { + throw new InterpreterRuntimeError("Rune runtime references are opaque and do not expose properties.", objectNode, "InvalidDataValue") + } + + if (typeof objectValue !== "object" || objectValue === null) { + throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode) + } + + if (typeof key === "string" && isBlockedMember(key)) { + throw new InterpreterRuntimeError(`Property '${key}' is not available in Rune.`, propertyNode) + } + + if (Array.isArray(objectValue)) { + if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && (typeof key !== "number" && !/^\d+$/.test(key))) { + if (typeof key === "string" && retryableArrayMethods.has(key)) { + throw new InterpreterRuntimeError( + `Array.${key}(...) is not supported in Rune. Rewrite using map/filter/find/some/every/includes/join or a for...of loop.`, + propertyNode, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + throw new InterpreterRuntimeError(`Array property '${String(key)}' is not available in Rune.`, propertyNode) + } + return { target: objectValue, key } + } + + return { target: objectValue as SafeObject, key } + }) + } + + private readMember(node: AstNode): Effect.Effect { + return Effect.map(this.getMemberReference(node), (reference) => { + if (reference === OptionalShortCircuit) return OptionalShortCircuit + if (reference instanceof ComputedValue) return reference.value + if ( + reference === undefined || + reference instanceof ToolReference || + reference instanceof PromiseAllReference || + reference instanceof IntrinsicReference || + reference instanceof GlobalMethodReference + ) return reference + if (Array.isArray(reference.target)) { + if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { + return new IntrinsicReference(reference.target, reference.key) + } + return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)] + } + return reference.target[String(reference.key)] + }) + } + + private writeMember(node: AstNode, value: unknown): Effect.Effect { + return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value })) + } + + // Resolves the member reference EXACTLY ONCE (so a side-effecting object/key expression + // runs once), then lets `compute` decide whether to write — enabling compound assignment, + // updates, plain writes, and short-circuiting logical assignment to share one safe path. + private modifyMember( + node: AstNode, + compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>, + ): Effect.Effect { + const self = this + return Effect.gen(function*() { + const reference = yield* self.getMemberReference(node) + if ( + reference === OptionalShortCircuit || + reference instanceof ComputedValue || + reference === undefined || + reference instanceof ToolReference || + reference instanceof PromiseAllReference || + reference instanceof IntrinsicReference || + reference instanceof GlobalMethodReference + ) { + throw new InterpreterRuntimeError("Only data fields may be assigned in Rune.", node) + } + if (Array.isArray(reference.target)) { + if (reference.key === "length") throw new InterpreterRuntimeError("Array length cannot be assigned in Rune.", node) + if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { + throw new InterpreterRuntimeError("Array methods cannot be assigned in Rune.", node) + } + } + const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key) + const current = (reference.target as Record)[key] + const { write, next, result } = yield* compute(current) + if (write) self.assignToReference(reference, key, next, node) + return result + }) + } + + // Writes `next` to a resolved member, enforcing index/capacity/byte limits and rolling + // back the mutation if the bound is exceeded (so a caught error can't leave it grown). + // Byte size of a container, cached after the first walk and maintained incrementally by the + // mutation helpers. O(1) on a cache hit; O(container) once on the first touch. + private cachedContainerBytes(container: object, node: AstNode): number { + const cached = this.containerSizes.get(container) + if (cached !== undefined) return cached + const bytes = runtimeValueBytes(container, "value", node, this.limits) + this.recordWork(workUnits(container), node) + this.containerSizes.set(container, bytes) + return bytes + } + + // Bytes a value contributes when nested one level inside a container; also enforces that the + // nested value's depth stays within maxValueDepth. O(value), independent of the container size. + private nestedValueBytes(value: unknown, label: string, node: AstNode): number { + return runtimeValueBytes(value, label, node, this.limits, 1) + } + + private rejectCircularInsertion(container: object, value: unknown, label: string, node: AstNode, seen = new Set()): void { + if (value === container) throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") + if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return + seen.add(value) + const items = Array.isArray(value) ? value : Object.values(value) + for (const item of items) this.rejectCircularInsertion(container, item, label, node, seen) + seen.delete(value) + } + + // Add `addedBytes` of new entries to a container, rejecting (before any mutation) if that would + // exceed maxDataBytes, then record the container's new cached size. + private growContainerBytes(container: object, addedBytes: number, node: AstNode, label: string): void { + const next = this.cachedContainerBytes(container, node) + addedBytes + if (next > this.limits.maxDataBytes) { + throw new InterpreterRuntimeError(`${label} exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") + } + this.containerSizes.set(container, next) + } + + private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void { + if (Array.isArray(reference.target)) { + const target = reference.target + const index = key as number + if (!Number.isInteger(index) || index < 0 || index >= this.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Array assignment index must be between 0 and ${this.limits.maxCollectionLength - 1}.`, node, "InvalidDataValue") + } + this.rejectCircularInsertion(target, next, "Array assignment result", node) + const addedBytes = this.nestedValueBytes(next, "Array assignment result", node) + if (index === target.length) { + // Append — the hot path; O(1) incremental size update (this is the O(n^2)-loop fix). + this.growContainerBytes(target, addedBytes + 1, node, "Array assignment result") + target[index] = next + } else if (index < target.length) { + // Replace an existing slot (value or hole): adjust by the byte delta. + const oldBytes = this.nestedValueBytes(target[index], "Array assignment result", node) + const nextSize = this.cachedContainerBytes(target, node) + addedBytes - oldBytes + if (nextSize > this.limits.maxDataBytes) { + throw new InterpreterRuntimeError(`Array assignment result exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") + } + this.containerSizes.set(target, nextSize) + target[index] = next + } else { + // index > length introduces holes; fall back to a full revalidation and reset the cache. + const previousLength = target.length + target[index] = next + try { + boundedProgramValue(target, "Array assignment result", node, this.limits) + this.containerSizes.set(target, runtimeValueBytes(target, "value", node, this.limits)) + } catch (error) { + delete target[index] + target.length = previousLength + throw error + } + } + return + } + const target = reference.target as SafeObject + const objectKey = key as string + this.rejectCircularInsertion(target, next, "Object assignment result", node) + const addedBytes = this.nestedValueBytes(next, "Object assignment result", node) + if (Object.hasOwn(target, objectKey)) { + const oldBytes = this.nestedValueBytes(target[objectKey], "Object assignment result", node) + const nextSize = this.cachedContainerBytes(target, node) + addedBytes - oldBytes + if (nextSize > this.limits.maxDataBytes) { + throw new InterpreterRuntimeError(`Object assignment result exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") + } + this.containerSizes.set(target, nextSize) + target[objectKey] = next + return + } + const count = (this.objectCounts.get(target) ?? Object.keys(target).length) + 1 + if (count > this.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Object assignment exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + this.growContainerBytes(target, dataByteLength(objectKey) + addedBytes + 1, node, "Object assignment result") + this.objectCounts.set(target, count) + target[objectKey] = next + } + + private toPropertyKey(value: unknown, node: AstNode): string | number { + if (typeof value === "string" || typeof value === "number") { + return value + } + + throw new InterpreterRuntimeError("Property key must be a string or number.", node) + } + + private declare(name: string, value: unknown, mutable: boolean, node: AstNode): void { + const scope = this.currentScope() + + // A pre-seeded parameter slot (initialized === false) is being bound for the first time; + // anything else already present is a genuine duplicate declaration. + const existing = scope.get(name) + if (existing && existing.initialized !== false) { + throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node) + } + + scope.set(name, { mutable, value, initialized: true }) + } + + private getIdentifierValue(name: string, node: AstNode): unknown { + const binding = this.resolveBinding(name) + + if (!binding) { + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node) + } + + // A parameter default that forward-references a later (not-yet-bound) parameter — JS TDZ. + if (binding.initialized === false) { + throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node) + } + + return binding.value + } + + private setIdentifierValue(name: string, value: unknown, node: AstNode): unknown { + const binding = this.resolveBinding(name) + + if (!binding) { + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node) + } + + if (!binding.mutable) { + throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node) + } + + binding.value = value + return value + } + + private resolveBinding(name: string): Binding | undefined { + for (let index = this.scopes.length - 1; index >= 0; index -= 1) { + const scope = this.scopes[index] + const binding = scope?.get(name) + + if (binding) { + return binding + } + } + + return undefined + } + + private currentScope(): Map { + const scope = this.scopes[this.scopes.length - 1] + + if (!scope) { + throw new InterpreterRuntimeError("Interpreter scope stack is empty.") + } + + return scope + } + + private pushScope(): void { + this.scopes.push(new Map()) + } + + private popScope(): void { + this.scopes.pop() + } + + private forkForParallelCallback(): Interpreter { + const fork = new Interpreter(this.limits, this.invokeTool, this.budget) + fork.scopes.splice( + 0, + fork.scopes.length, + ...this.scopes.map((scope) => new Map(Array.from(scope, ([name, binding]) => [name, { ...binding }]))), + ) + return fork + } + + private recordOperation(node: AstNode): void { + this.recordWork(1, node) + } + + // Charge `units` of work to the operation budget so O(n) built-ins (collection/string + // walks and spreads) are bounded by maxOperations, not only by the wall-clock timeout. + private recordWork(units: number, node?: AstNode): void { + this.budget.operations += Math.max(1, Math.ceil(units)) + + if (this.budget.operations > this.limits.maxOperations) { + throw new InterpreterRuntimeError(`Execution exceeded its operation limit of ${this.limits.maxOperations}.`, node, "OperationLimitExceeded") + } + } +} + +/** + * Executes one Effect-native Rune Program without constructing a reusable runtime. + * + * @example + * ```ts + * const result = yield* Rune.execute({ + * tools: { lookup }, + * code: `return await tools.lookup({ id: "order_42" })`, + * }) + * ``` + */ +export const execute = >(options: ExecuteOptions): Effect.Effect> => { + const limits = resolveExecutionLimits(options.limits) + ToolRuntime.assertValidTools((options.tools ?? {}) as HostTools>) + const tools = ToolRuntime.make((options.tools ?? {}) as HostTools>, limits.maxToolCalls, limits) + + if (new TextEncoder().encode(options.code).byteLength > limits.maxSourceBytes) { + return Effect.succeed({ + ok: false, + error: { kind: "InvalidDataValue", message: `Code exceeds the maximum source size of ${limits.maxSourceBytes} bytes.` }, + toolCalls: tools.calls, + }) + } + + if (options.code.trim().length === 0) { + return Effect.succeed({ + ok: false, + error: { kind: "ParseError", message: "Code cannot be empty." }, + toolCalls: tools.calls, + }) + } + + const operation = Effect.gen(function*() { + const program = parseProgram(options.code) + const interpreter = new Interpreter>(limits, tools.invoke) + const value = yield* interpreter.run(program) + const copied = copyIn(value, "Execution result", limits) + if (dataByteLength(copied) > limits.maxDataBytes) { + throw new InterpreterRuntimeError(`Execution result exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, undefined, "InvalidDataValue") + } + return { + ok: true, + value: copyOut(copied), + toolCalls: tools.calls, + } satisfies ExecuteResult + }).pipe( + Effect.timeoutOrElse({ + duration: limits.timeoutMs, + orElse: () => Effect.succeed({ + ok: false, + error: { kind: "TimeoutExceeded", message: `Execution timed out after ${limits.timeoutMs}ms.` }, + toolCalls: tools.calls, + } satisfies ExecuteResult), + }), + ) + + return operation.pipe( + Effect.matchCause({ + onFailure: (cause): ExecuteResult => ({ + ok: false, + error: normalizeError(Cause.squash(cause)), + toolCalls: tools.calls, + }), + onSuccess: (result): ExecuteResult => result, + }), + ) +} + +/** + * Creates an Effect-native runtime over explicit, schema-described capabilities. + * + * Use `run` for host-driven execution or `asTool` to expose one confined code tool to an + * agent framework. Capability requirements remain in the returned Effect environment. + * + * @example + * ```ts + * const rune = Rune.make({ tools: { orders: { lookup } } }) + * const code = rune.asTool() + * ``` + */ +export const make = = {}>(options: RuneOptions = {} as RuneOptions): Rune> => { + ToolRuntime.assertValidTools((options.tools ?? {}) as HostTools>) + const run = (code: string) => execute({ ...options, code }) + + return { + catalog: () => ToolRuntime.catalog((options.tools ?? {}) as HostTools>), + instructions: () => ToolRuntime.instructions((options.tools ?? {}) as HostTools>), + asTool: () => ({ + name: "code", + description: ToolRuntime.instructions((options.tools ?? {}) as HostTools>), + input: CodeInput, + execute: ({ code }) => run(code), + }), + run, + } +} + +export const Rune = { make, execute } diff --git a/packages/opencode/src/session/rune/tool-runtime.ts b/packages/opencode/src/session/rune/tool-runtime.ts new file mode 100644 index 000000000000..4501493374e6 --- /dev/null +++ b/packages/opencode/src/session/rune/tool-runtime.ts @@ -0,0 +1,309 @@ +import { Effect, Schema } from "effect" +import { isDefinition as isToolDefinition, toTypeScript, type Definition } from "./tool.js" + +export type HostTool = (...args: Array) => Effect.Effect + +export type HostTools = { + [name: string]: HostTool | Definition | HostTools +} + +export type Services = Tools extends (...args: Array) => Effect.Effect + ? R + : Tools extends { readonly _tag: "RuneTool"; readonly run: (input: unknown) => Effect.Effect } + ? R + : Tools extends object + ? string extends keyof Tools ? never : Services + : never + +export type ToolCall = { + readonly name: string +} + +export type ToolDescription = { + readonly path: string + readonly description: string + readonly signature: string +} + +export type SafeObject = Record + +const reservedNamespace = "$rune" + +export class ToolReference { + constructor(readonly path: ReadonlyArray) {} +} + +export type DataLimits = { + readonly maxValueDepth: number + readonly maxCollectionLength: number + readonly maxDataBytes: number + readonly maxAuditBytes: number +} + +export class ToolRuntimeError extends Error { + constructor( + readonly kind: "UnknownCapability" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded" | "AuditLimitExceeded", + message: string, + readonly suggestions: ReadonlyArray = [], + ) { + super(message) + this.name = "ToolRuntimeError" + } +} + +const isDefinition = (value: HostTool | Definition | HostTools): value is Definition => + isToolDefinition(value) + +const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"]) + +export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name) + +export const copyIn = (value: unknown, label: string, limits?: DataLimits, depth = 0, seen = new Set()): unknown => { + if (limits && depth > limits.maxValueDepth) { + throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${limits.maxValueDepth}.`) + } + if (value === null || value === undefined || typeof value === "string" || typeof value === "boolean") { + return value + } + + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new ToolRuntimeError("InvalidDataValue", `${label} contains a non-finite number.`) + } + return value + } + + if (typeof value !== "object") { + throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`) + } + + if (seen.has(value)) { + throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`) + } + + seen.add(value) + + if (Array.isArray(value)) { + if (limits && value.length > limits.maxCollectionLength) { + throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`) + } + const copied = value.map((item) => copyIn(item, label, limits, depth + 1, seen)) + seen.delete(value) + return copied + } + + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) { + throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`) + } + + const copied: SafeObject = Object.create(null) as SafeObject + const entries = Object.entries(value) + if (limits && entries.length > limits.maxCollectionLength) { + throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`) + } + for (const [key, item] of entries) { + if (isBlockedMember(key)) { + throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`) + } + copied[key] = copyIn(item, label, limits, depth + 1, seen) + } + seen.delete(value) + return copied +} + +export const copyOut = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(copyOut) + } + + if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyOut(item)])) + } + + return value +} + +const definitions = (tools: HostTools, path: ReadonlyArray = []): Array<{ path: string; definition: Definition }> => { + const entries: Array<{ path: string; definition: Definition }> = [] + for (const [name, value] of Object.entries(tools)) { + const next = [...path, name] + if (isDefinition(value)) entries.push({ path: next.join("."), definition: value }) + else if (typeof value !== "function") entries.push(...definitions(value, next)) + } + return entries +} + +const describeDefinition = (path: string, definition: Definition): ToolDescription => ({ + path, + description: definition.description, + signature: `tools.${path}(input: ${toTypeScript(definition.input)}): Promise<${toTypeScript(definition.output, true)}>`, + }) + +const visibleDefinitions = (tools: HostTools) => + definitions(tools).flatMap(({ path, definition }) => { + const description = describeDefinition(path, definition) + return [{ path, definition, description }] + }) + +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.`) + } +} + +export const instructions = (tools: HostTools): string => { + const described = catalog(tools) + const discovery = [ + "- tools.$rune.search({ query: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string }>; total: number }>", + "- tools.$rune.describe({ path: string }): Promise<{ path: string; description: string; signature: string }>", + ] + const lines = [ + "Write a Rune Program to answer the request. Return code only.", + "Rune Programs can call explicit tools.* capabilities and transform plain data.", + "Tool Capability calls are async; prefer explicit await unless the call is inside Promise.all(...).", + "", + "Available Tool Capabilities:", + ...described.map((tool) => `- ${tool.signature} // ${tool.description}`), + "", + ...(discovery.length > 0 ? [ + "For a large or dynamic catalog, you can discover additional capabilities in the program:", + ...discovery, + "", + ] : []), + "Common syntax: arrow functions and `function` declarations (hoisted) with closures, default/rest parameters, destructuring (incl. rest/defaults), optional chaining, template literals, conditionals, switch, loops, spread (arrays/objects/strings), try/catch, ternary, the `in` operator, logical assignment (??=/||=/&&=), and bitwise operators (& | ^ ~ << >> >>>). Signal failure with `throw` (any value) or `throw new Error(message)`.", + "Transform data with array methods (map/filter/reduce/reduceRight/flatMap/forEach/find/findIndex/findLast/findLastIndex/sort/toSorted/slice/concat/indexOf/at/flat/reverse/toReversed/with/includes/join, plus push/pop/shift/unshift for accumulation), string methods (toLowerCase/toUpperCase/trim/split/slice/substring/replace/replaceAll/includes/startsWith/endsWith/indexOf/padStart/padEnd/repeat/charCodeAt), number methods (toFixed/toString(radix)/toPrecision), Object.keys/values/entries/fromEntries/hasOwn, Math.* (incl. PI/E), JSON.parse/stringify, Array.from/isArray/of, Number.isInteger/isNaN/parseInt, String.fromCharCode, parseInt/parseFloat, and Number/String/Boolean.", + "Use Promise.all([...]) for parallel tool calls (a direct array of calls, or items.map((item) => tool call)).", + ] + return lines.join("\n") +} + +const resolve = (tools: HostTools, path: ReadonlyArray): HostTool | Definition => { + let value: HostTool | Definition | HostTools = tools + + for (const segment of path) { + if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) { + throw new ToolRuntimeError("UnknownCapability", `Unknown tool '${path.join(".")}'.`, ["Use tools.$rune.search({ query }) to find available described capabilities."]) + } + value = value[segment] as HostTool | Definition | HostTools + } + + if (typeof value !== "function" && !isDefinition(value)) { + throw new ToolRuntimeError("UnknownCapability", `Tool '${path.join(".")}' is not callable.`) + } + + return value +} + +export type ToolRuntime = { + readonly root: ToolReference + readonly calls: Array + readonly invoke: (path: ReadonlyArray, args: Array) => Effect.Effect +} + +export const dataByteLength = (value: unknown): number => + new TextEncoder().encode(JSON.stringify(value) ?? "").byteLength + +export const make = ( + tools: HostTools, + maxToolCalls: number, + dataLimits: DataLimits, +): ToolRuntime => { + const calls: Array = [] + let auditBytes = 0 + const visibleCatalog = visibleDefinitions(tools) + + const checkedCopyIn = (value: unknown, label: string): unknown => { + const copied = copyIn(value, label, dataLimits) + if (dataByteLength(copied) > dataLimits.maxDataBytes) { + throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds ${dataLimits.maxDataBytes} bytes.`) + } + return copied + } + + const recordCall = (call: ToolCall): void => { + if (calls.length >= maxToolCalls) { + throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`) + } + const auditEntryBytes = dataByteLength(call) + if (auditBytes + auditEntryBytes > dataLimits.maxAuditBytes) { + throw new ToolRuntimeError("AuditLimitExceeded", `Execution exceeds its audit-trail limit of ${dataLimits.maxAuditBytes} bytes.`) + } + auditBytes += auditEntryBytes + calls.push(call) + } + + return { + root: new ToolReference([]), + calls, + invoke: (path, args) => + Effect.gen(function*() { + const name = path.join(".") + const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`, dataLimits))) + const argumentBytes = dataByteLength(externalArgs) + if (argumentBytes > dataLimits.maxDataBytes) { + 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)) { + if (externalArgs.length !== 1) throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`) + describedInput = yield* Effect.try({ + try: () => Schema.decodeUnknownSync(tool.input)(externalArgs[0]), + catch: (cause) => new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`), + }) + } + recordCall(call) + if (isDefinition(tool)) { + const raw = yield* tool.run(describedInput) + const result = yield* Effect.try({ + try: () => Schema.decodeUnknownSync(tool.output)(raw), + catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${String(cause)}`), + }) + return checkedCopyIn(result, `Result from tool '${name}'`) + } + const result = yield* tool(...externalArgs) + return checkedCopyIn(result, `Result from tool '${name}'`) + }), + } +} + +export * as ToolRuntime from "./tool-runtime.js" diff --git a/packages/opencode/src/session/rune/tool.ts b/packages/opencode/src/session/rune/tool.ts new file mode 100644 index 000000000000..0be76b18ed02 --- /dev/null +++ b/packages/opencode/src/session/rune/tool.ts @@ -0,0 +1,100 @@ +import { Effect, Schema } from "effect" + +export type Definition = { + readonly _tag: "RuneTool" + readonly description: string + readonly input: Schema.Decoder + readonly output: Schema.Decoder + readonly run: (input: unknown) => Effect.Effect +} + +export type Options, O extends Schema.Decoder, R = never> = { + readonly description: string + readonly input: I + readonly output: O + readonly run: (input: I["Type"]) => Effect.Effect +} + +export const isDefinition = (value: unknown): value is Definition => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "RuneTool" + +type JsonSchema = { + readonly type?: string | ReadonlyArray + readonly enum?: ReadonlyArray + readonly const?: unknown + readonly anyOf?: ReadonlyArray + readonly oneOf?: ReadonlyArray + readonly properties?: Readonly> + readonly required?: ReadonlyArray + readonly items?: JsonSchema + readonly additionalProperties?: boolean | JsonSchema + readonly $ref?: string +} + +const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown" + +const renderSchema = (schema: JsonSchema, definitions: Readonly>): string => { + if (schema.$ref) { + const name = schema.$ref.split("/").pop() + return name && definitions[name] ? renderSchema(definitions[name], definitions) : name ?? "unknown" + } + if (schema.const !== undefined) return renderLiteral(schema.const) + if (schema.enum) return schema.enum.map(renderLiteral).join(" | ") + const alternatives = schema.anyOf ?? schema.oneOf + if (alternatives) { + if (alternatives.some((item) => item.type === "number")) return "number" + return alternatives.map((item) => renderSchema(item, definitions)).join(" | ") + } + if (Array.isArray(schema.type)) return schema.type.map((item) => renderSchema({ type: item }, definitions)).join(" | ") + if (schema.type === "string") return "string" + if (schema.type === "number" || schema.type === "integer") return "number" + if (schema.type === "boolean") return "boolean" + if (schema.type === "null") return "null" + if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, definitions)}>` + if (schema.type === "object" || schema.properties) { + const required = new Set(schema.required ?? []) + const fields = Object.entries(schema.properties ?? {}).map(([name, value]) => + `${name}${required.has(name) ? "" : "?"}: ${renderSchema(value, definitions)}`) + if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + fields.push(`[key: string]: ${renderSchema(schema.additionalProperties, definitions)}`) + } + return `{ ${fields.join("; ")} }` + } + return "unknown" +} + +export const toTypeScript = (schema: Schema.Top, decoded = false): string => { + const visible = decoded ? Schema.toType(schema) : schema + const document = Schema.toJsonSchemaDocument(visible) as { + readonly schema: JsonSchema + readonly definitions?: Readonly> + } + return renderSchema(document.schema, document.definitions ?? {}) +} + +/** + * Defines one schema-described capability available to a Rune Program through `tools.*`. + * + * `input` is decoded before `run` is invoked. `run` returns the encoded representation of + * `output`, which Rune decodes before returning it to the program. The host capability remains + * responsible for authorization and durable side-effect handling. + * + * @example + * ```ts + * const lookup = Tool.make({ + * description: "Look up an order", + * input: Schema.Struct({ id: Schema.String }), + * output: Schema.Struct({ status: Schema.String }), + * run: ({ id }) => Effect.succeed({ status: "open" }), + * }) + * ``` + */ +export const make = , O extends Schema.Decoder, R>(options: Options): Definition => ({ + _tag: "RuneTool", + description: options.description, + input: options.input, + output: options.output, + run: (input) => options.run(input as I["Type"]), +}) + +export * as Tool from "./tool.js" From 14527d2047419233cf64a52d874cf46ba98fdbaf Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 09:27:39 -0500 Subject: [PATCH 06/41] feat(opencode): code mode result+attachments envelope and typed describe Expose mcp.defs() so code mode can read MCP outputSchema. Tool calls and the final return now use one { result, attachments? } envelope; media blocks become FilePart attachments. describe renders typed signatures with the structured return type when an outputSchema is present. --- packages/opencode/src/mcp/index.ts | 19 ++ packages/opencode/src/session/code-mode.ts | 231 ++++++++++++++---- packages/opencode/src/session/tools.ts | 6 +- .../opencode/test/session/code-mode.test.ts | 86 ++++++- packages/opencode/test/session/prompt.test.ts | 1 + .../test/session/snapshot-tool-race.test.ts | 1 + 6 files changed, 284 insertions(+), 60 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index c72fb47b20f7..98e8205ed97a 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -159,6 +159,12 @@ export interface Interface { readonly clients: () => Effect.Effect> readonly instructions: () => Effect.Effect readonly tools: () => Effect.Effect> + /** + * Raw MCP tool definitions keyed identically to {@link tools} (`toolName(client, name)`). + * Unlike {@link tools}, these retain the original `inputSchema`/`outputSchema`, which code + * mode uses to render tool signatures (including return types) to the model. + */ + readonly defs: () => Effect.Effect> readonly prompts: () => Effect.Effect> readonly resources: (clientName?: string) => Effect.Effect> readonly resourceTemplates: ( @@ -680,6 +686,18 @@ export const layer = Layer.effect( return result }) + const defs = Effect.fn("MCP.defs")(function* () { + const result: Record = {} + const s = yield* InstanceState.get(state) + for (const [clientName, listed] of Object.entries(s.defs)) { + if (s.status[clientName]?.status !== "connected") continue + for (const mcpTool of listed) { + result[McpCatalog.toolName(clientName, mcpTool.name)] = mcpTool + } + } + return result + }) + function collectFromConnected( s: State, listFn: (c: Client, timeout?: number) => Promise, @@ -982,6 +1000,7 @@ export const layer = Layer.effect( clients, instructions, tools, + defs, prompts, resources, resourceTemplates, diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 7389b324a604..3623ecb9cd38 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -1,6 +1,7 @@ import { Tool } from "@/tool/tool" import { EffectBridge } from "@/effect/bridge" import { asSchema, type Tool as AITool, type JSONSchema7 } from "ai" +import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import { Effect, Schema } from "effect" export const CODE_MODE_TOOL = "execute" @@ -16,6 +17,16 @@ type Metadata = { error?: boolean } +/** + * A model-facing attachment: the same shape used for both child tool results and + * the program's final `return`, and identical to a session `FilePart` (minus the + * ids), so it lowers 1:1 into `Tool.ExecuteResult.attachments`. + */ +export type Attachment = NonNullable[number] + +/** The envelope every tool call resolves to, and the shape a program should `return`. */ +export type Envelope = { result: unknown; attachments?: Attachment[] } + // `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 { @@ -26,7 +37,15 @@ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ const SEARCH = "search" const DESCRIBE = "describe" -type CatalogEntry = { path: string; key: string; server: string; local: string; description: string; tool: AITool } +type CatalogEntry = { + path: string + key: string + server: string + local: string + description: string + tool: AITool + outputSchema?: JSONSchema7 +} const firstLine = (text: string | undefined) => (text ?? "").split("\n", 1)[0]!.trim() const brief = (text: string | undefined, max = 120) => { @@ -41,14 +60,20 @@ const toKey = (segments: readonly string[]) => segments.join("_").replaceAll("." /** * 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`. + * named `a_b` beats `a` for the key `a_b_tool`. `mcpDefs` carries the raw MCP + * definitions (keyed identically) so each entry retains its `outputSchema`. */ -export function groupByServer(mcpTools: Record, servers: readonly string[]): Map { +export function groupByServer( + mcpTools: Record, + servers: readonly string[], + mcpDefs: Record = {}, +): Map { const byLongest = [...servers].sort((a, b) => b.length - a.length) const groups = new Map() 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 output = mcpDefs[key]?.outputSchema as JSONSchema7 | undefined const entry: CatalogEntry = { path: `${server}.${local}`, key, @@ -56,6 +81,7 @@ export function groupByServer(mcpTools: Record, servers: readonl local, description: mcpTools[key]!.description ?? "", tool: mcpTools[key]!, + outputSchema: output, } groups.set(server, [...(groups.get(server) ?? []), entry]) } @@ -64,39 +90,61 @@ export function groupByServer(mcpTools: Record, servers: readonl const access = (segment: string) => (IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`) -function jsonType(def: JSONSchema7 | boolean | undefined): string { +/** + * Render a JSON Schema as a compact TypeScript-ish type string for model-facing + * signatures. Depth-limited and total — never throws, falls back to `any`/`object`. + */ +export function renderType(def: JSONSchema7 | boolean | undefined, depth = 0): string { if (!def || typeof def === "boolean") return "any" if (Array.isArray(def.enum)) return def.enum.map((value) => JSON.stringify(value)).join(" | ") + if (def.const !== undefined) return JSON.stringify(def.const) + if (Array.isArray(def.anyOf ?? def.oneOf)) { + const alts = (def.anyOf ?? def.oneOf)! + return alts.map((alt) => renderType(alt as JSONSchema7, depth)).join(" | ") + } const type = Array.isArray(def.type) ? def.type[0] : def.type switch (type) { case "integer": return "number" - case "array": - return "any[]" - case undefined: - return "any" - default: + case "string": + case "number": + case "boolean": + case "null": return type + case "array": { + const items = Array.isArray(def.items) ? def.items[0] : def.items + return `${renderType(items as JSONSchema7 | undefined, depth + 1)}[]` + } } + if (type === "object" || def.properties) { + if (depth >= 3) return "object" + const props = def.properties ?? {} + const required = new Set(Array.isArray(def.required) ? def.required : []) + const fields = Object.entries(props).map( + ([name, value]) => `${name}${required.has(name) ? "" : "?"}: ${renderType(value as JSONSchema7, depth + 1)}`, + ) + return fields.length > 0 ? `{ ${fields.join("; ")} }` : "object" + } + return "any" } -function inputHint(tool: AITool): string { +function inputType(tool: AITool): string { try { const schema = asSchema(tool.inputSchema).jsonSchema as JSONSchema7 | undefined - const props = schema?.properties - if (!props || typeof props !== "object") return "input" - const required = new Set(Array.isArray(schema?.required) ? schema.required : []) - const fields = Object.entries(props).map( - ([name, def]) => `${name}${required.has(name) ? "" : "?"}: ${jsonType(def as JSONSchema7)}`, - ) - return fields.length > 0 ? `{ ${fields.join("; ")} }` : "{}" + if (!schema?.properties || typeof schema.properties !== "object") return "input" + return renderType(schema) } catch { return "input" } } +/** The return type the model sees for any tool: the structured `outputSchema` (when + * the MCP server declares one) wrapped in the result envelope, else `unknown`. */ +const returnType = (outputSchema: JSONSchema7 | undefined) => + `Promise<{ result: ${outputSchema ? renderType(outputSchema) : "unknown"}; attachments?: Attachment[] }>` + const signatureFor = (entry: CatalogEntry) => - `tools${access(entry.server)}${access(entry.local)}(${inputHint(entry.tool)})` + `tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)}): ${returnType(entry.outputSchema)}` /** * The execute tool description: the calling convention, the discovery API, and a @@ -109,10 +157,16 @@ export function describe(groups: Map): string { "", "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..(input)` or `await tools[path](input)`. Each returns a Promise.", + "- `await tools.describe(path)` -> `{ path, description, signature, inputSchema, outputSchema? }`", + "- Call a tool by its path: `await tools..(input)`. Each resolves to `{ result, attachments? }`.", "", - "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.", + "Every tool call and your final `return` use the same envelope: `{ result, attachments? }`.", + "`result` is the structured data; `attachments` carries images/files for the user. Return a whole tool", + "result to forward its attachments, or return only its `.result` to drop the media. You cannot read the", + "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.", ] if (groups.size === 0) { lines.push("", "No MCP servers are currently connected.") @@ -125,24 +179,74 @@ export function describe(groups: Map): string { return lines.join("\n") } +const lastSegment = (uri: string) => { + const trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "") + const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1) + return segment.length > 0 ? segment : undefined +} + +const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}` + /** - * Reduce an MCP tool result to the value the program should see: structured - * content when present, otherwise the joined text blocks, otherwise the raw - * result. + * Reduce an MCP tool result to the `{ result, attachments? }` envelope. `result` + * is the structured content (or joined text); media blocks (image/audio/resource) + * become attachments. Lenient — never throws on unexpected shapes. */ -export function toolResultValue(result: unknown): unknown { - if (result === null || typeof result !== "object") return result +export function toEnvelope(result: unknown): Envelope { + if (result === null || typeof result !== "object") return { result } const record = result as { structuredContent?: unknown; content?: unknown } - if (record.structuredContent !== undefined && record.structuredContent !== null) return record.structuredContent - if (Array.isArray(record.content)) { - const text = record.content - .filter((item): item is { type: "text"; text: string } => item?.type === "text" && typeof item.text === "string") - .map((item) => item.text) - .join("\n") - if (text.length > 0) return text - return record.content + const attachments: Attachment[] = [] + const text: string[] = [] + const content = Array.isArray(record.content) ? record.content : [] + for (const item of content) { + if (!item || typeof item !== "object") continue + const block = item as Record + switch (block.type) { + case "text": + if (typeof block.text === "string") text.push(block.text) + break + case "image": + case "audio": + if (typeof block.data === "string" && typeof block.mimeType === "string") { + attachments.push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) }) + } + break + case "resource": { + const res = block.resource as Record | undefined + if (res && typeof res === "object") { + const mime = typeof res.mimeType === "string" ? res.mimeType : "application/octet-stream" + const uri = typeof res.uri === "string" ? res.uri : undefined + if (typeof res.blob === "string") { + attachments.push({ type: "file", mime, url: dataUrl(mime, res.blob), filename: uri ? lastSegment(uri) : undefined }) + } else if (typeof res.text === "string") { + text.push(res.text) + } + } + break + } + case "resource_link": + if (typeof block.uri === "string") { + attachments.push({ + type: "file", + mime: typeof block.mimeType === "string" ? block.mimeType : "application/octet-stream", + url: block.uri, + filename: typeof block.name === "string" ? block.name : lastSegment(block.uri), + }) + } + break + } } - return result + + const value = + record.structuredContent !== undefined && record.structuredContent !== null + ? record.structuredContent + : text.length > 0 + ? text.join("\n") + : content.length > 0 + ? undefined // media-only result + : result + + return attachments.length > 0 ? { result: value, attachments } : { result: value } } /** Coerce the program's return value to model-facing text without ever failing on shape. */ @@ -156,6 +260,28 @@ export function formatValue(value: unknown): string { } } +const isAttachment = (value: unknown): value is Attachment => { + if (!value || typeof value !== "object") return false + const a = value as Record + return a.type === "file" && typeof a.mime === "string" && typeof a.url === "string" +} + +/** + * Lower the program's return value into model-facing output + attachments. The + * value is treated as a `{ result, attachments? }` envelope when it has a `result` + * key; otherwise the whole value is the result. Attachments are model-curated. + */ +export function fromReturn(value: unknown): { output: string; attachments?: Attachment[] } { + if (value !== null && typeof value === "object" && "result" in value) { + const env = value as { result: unknown; attachments?: unknown } + const attachments = Array.isArray(env.attachments) ? env.attachments.filter(isAttachment) : [] + return attachments.length > 0 + ? { output: formatValue(env.result), attachments } + : { output: formatValue(env.result) } + } + return { output: formatValue(value) } +} + function errorMessage(error: unknown): string { if (error instanceof Error) return error.message if (typeof error === "string") return error @@ -166,8 +292,12 @@ function errorMessage(error: unknown): string { } } -export function define(mcpTools: Record, servers: readonly string[]) { - const groups = groupByServer(mcpTools, servers) +export function define( + mcpTools: Record, + mcpDefs: Record, + servers: readonly string[], +) { + const groups = groupByServer(mcpTools, servers, mcpDefs) const catalog: CatalogEntry[] = [...groups.values()].flat() const byKey = new Map(catalog.map((entry) => [entry.key, entry] as const)) @@ -202,7 +332,13 @@ export function define(mcpTools: Record, servers: readonly strin } catch { inputSchema = undefined } - return { path: entry.path, description: entry.description, signature: signatureFor(entry), inputSchema } + return { + path: entry.path, + description: entry.description, + signature: signatureFor(entry), + inputSchema, + ...(entry.outputSchema ? { outputSchema: entry.outputSchema } : {}), + } } return Tool.define( @@ -228,7 +364,7 @@ export function define(mcpTools: Record, servers: readonly strin }), ), ) - return toolResultValue(result) + return toEnvelope(result) }) // Recursive path-accumulating proxy: `tools..(args)` and @@ -261,14 +397,15 @@ export function define(mcpTools: Record, servers: readonly strin try: () => new AsyncFunction("tools", params.code)(tools), catch: (error) => error, }).pipe( - Effect.map( - (value) => - ({ - title: "Code mode", - metadata: { toolCalls: calls }, - output: formatValue(value), - }) satisfies Tool.ExecuteResult, - ), + Effect.map((value) => { + const { output, attachments } = fromReturn(value) + return { + title: "Code mode", + metadata: { toolCalls: calls }, + output, + ...(attachments && attachments.length > 0 ? { attachments } : {}), + } satisfies Tool.ExecuteResult + }), Effect.catch((error) => Effect.succeed({ title: "Code mode", diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 7f65c6430149..9e10a9ce9795 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -99,7 +99,11 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const codeModeTool = flags.experimentalCodeMode && Object.keys(mcpTools).length > 0 ? yield* Tool.init( - yield* CodeModeTool.define(mcpTools, Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize)), + yield* CodeModeTool.define( + mcpTools, + yield* mcp.defs(), + Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize), + ), ) : undefined const registryTools = yield* registry.tools({ diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 2433e47a3ad1..8f9811b75db2 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { Parameters, define, describe as describeTools, formatValue, groupByServer, toolResultValue } from "@/session/code-mode" +import { Parameters, define, describe as describeTools, formatValue, groupByServer, toEnvelope } from "@/session/code-mode" +import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import { Agent } from "@/agent/agent" import { Tool } from "@/tool/tool" import * as Truncate from "@/tool/truncate" @@ -43,9 +44,9 @@ const layer = Layer.mergeAll( // Derive sanitized server namespaces from the catalog keys, mirroring how // session/tools.ts passes `Object.keys(mcp.clients()).map(sanitize)`. -function build(mcpTools: Record, servers?: string[]) { +function build(mcpTools: Record, defs: Record = {}, servers?: string[]) { const names = servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))] - return Effect.runPromise(define(mcpTools, names).pipe(Effect.flatMap(Tool.init), Effect.provide(layer))) + return Effect.runPromise(define(mcpTools, defs, names).pipe(Effect.flatMap(Tool.init), Effect.provide(layer))) } describe("code mode execute", () => { @@ -97,7 +98,9 @@ describe("code mode execute", () => { ) const desc = JSON.parse(described.output) expect(desc.path).toBe("github.create_issue") - expect(desc.signature).toBe("tools.github.create_issue({ title: string; body?: string })") + expect(desc.signature).toBe( + "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)) expect(JSON.parse(missing.output).error.code).toBe("tool_not_found") @@ -126,7 +129,7 @@ describe("code mode execute", () => { }) const output = await Effect.runPromise( - tool.execute({ code: "const r = await tools.greeter.hello({ name: 'world' }); return r.toUpperCase()" }, ctx), + tool.execute({ code: "const r = await tools.greeter.hello({ name: 'world' }); return r.result.toUpperCase()" }, ctx), ) expect(seen).toEqual([{ name: "world" }]) @@ -147,8 +150,8 @@ describe("code mode execute", () => { { code: ` const first = await tools.math.add({ a: 1, b: 2 }) - const second = await tools.math.add({ a: first.sum, b: 10 }) - return { total: second.sum } + const second = await tools.math.add({ a: first.result.sum, b: 10 }) + return { total: second.result.sum } `, }, ctx, @@ -167,7 +170,7 @@ describe("code mode execute", () => { const output = await Effect.runPromise( tool.execute( - { code: "const [a, b] = await Promise.all([tools.echo.one({}), tools.echo.two({})]); return a + b" }, + { code: "const [a, b] = await Promise.all([tools.echo.one({}), tools.echo.two({})]); return a.result + b.result" }, ctx, ), ) @@ -217,12 +220,71 @@ describe("code mode execute", () => { expect(asked.map((req: any) => req.permission)).toEqual(["a_tool", "b_tool"]) }) - test("unit: toolResultValue and formatValue", () => { - expect(toolResultValue({ structuredContent: { x: 1 }, content: [] })).toEqual({ x: 1 }) - expect(toolResultValue({ content: [{ type: "text", text: "hi" }] })).toBe("hi") - expect(toolResultValue("raw")).toBe("raw") + test("unit: toEnvelope wraps result and extracts media as attachments", () => { + expect(toEnvelope({ structuredContent: { x: 1 }, content: [] })).toEqual({ result: { x: 1 } }) + expect(toEnvelope({ content: [{ type: "text", text: "hi" }] })).toEqual({ result: "hi" }) + expect(toEnvelope("raw")).toEqual({ result: "raw" }) + + // image/audio blocks become data-URL file attachments; text stays in result + expect( + toEnvelope({ + content: [ + { type: "text", text: "see image" }, + { type: "image", data: "AAAA", mimeType: "image/png" }, + ], + }), + ).toEqual({ + result: "see image", + attachments: [{ type: "file", mime: "image/png", url: "data:image/png;base64,AAAA" }], + }) + + // media-only result has an undefined result but still surfaces the attachment + expect(toEnvelope({ content: [{ type: "image", data: "BBBB", mimeType: "image/jpeg" }] })).toEqual({ + result: undefined, + attachments: [{ type: "file", mime: "image/jpeg", url: "data:image/jpeg;base64,BBBB" }], + }) + }) + + test("unit: formatValue", () => { expect(formatValue("text")).toBe("text") expect(formatValue({ a: 1 })).toBe(JSON.stringify({ a: 1 }, null, 2)) expect(formatValue(undefined)).toBe("undefined") }) + + test("describe shows the structured return type when the tool declares an outputSchema", async () => { + const tools = { weather_current: mcpTool("current", () => "", { type: "object", properties: { city: { type: "string" } } }) } + const defs: Record = { + weather_current: { + name: "current", + inputSchema: { type: "object", properties: { city: { type: "string" } } }, + outputSchema: { type: "object", properties: { tempC: { type: "number" }, summary: { type: "string" } }, required: ["tempC"] }, + } as any, + } + const tool = await build(tools, defs) + const described = await Effect.runPromise(tool.execute({ code: "return await tools.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[] }>", + ) + expect(desc.outputSchema).toBeDefined() + }) + + test("forwards attachments from a returned tool result and drops them when only .result is returned", async () => { + const tool = await build({ + shot_take: mcpTool("take", () => ({ + content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }], + structuredContent: { name: "shot.png" }, + })), + }) + + const forwarded = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx)) + expect(forwarded.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }]) + expect(JSON.parse(forwarded.output)).toEqual({ name: "shot.png" }) + + const suppressed = await Effect.runPromise( + tool.execute({ code: "const r = await tools.shot.take({}); return { result: r.result }" }, ctx), + ) + expect(suppressed.attachments).toBeUndefined() + expect(JSON.parse(suppressed.output)).toEqual({ name: "shot.png" }) + }) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 491ad06aaf47..01b1af5b9957 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -117,6 +117,7 @@ function makeMcp(instructions: MCP.ServerInstructions[] = []) { clients: () => Effect.succeed({}), instructions: () => Effect.succeed(instructions), tools: () => Effect.succeed({}), + defs: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), resourceTemplates: () => Effect.succeed({}), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 1265237840f3..3bbb4b1fc5ed 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -39,6 +39,7 @@ const mcp = Layer.succeed( clients: () => Effect.succeed({}), instructions: () => Effect.succeed([]), tools: () => Effect.succeed({}), + defs: () => Effect.succeed({}), prompts: () => Effect.succeed({}), resources: () => Effect.succeed({}), resourceTemplates: () => Effect.succeed({}), From 71ffc7327231fd85717e8585a577c03b8798bce2 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 09:36:59 -0500 Subject: [PATCH 07/41] feat(opencode): run code mode on the vendored rune interpreter Replace the in-process AsyncFunction engine with Rune.execute. MCP tools are exposed as a host-tool tree (tools..) plus top-level search/describe; each call is permission-gated and coerced to the { result, attachments? } envelope. Raise data limits for base64 media. This adds real sandboxing: host globals are isolated and runaway loops terminate via the operation limit instead of hanging the event loop. --- packages/opencode/src/session/code-mode.ts | 144 +++++++++--------- .../opencode/test/session/code-mode.test.ts | 17 ++- 2 files changed, 83 insertions(+), 78 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 3623ecb9cd38..09fb15fcffec 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -1,11 +1,23 @@ import { Tool } from "@/tool/tool" -import { EffectBridge } from "@/effect/bridge" import { asSchema, type Tool as AITool, type JSONSchema7 } from "ai" import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import { Effect, Schema } from "effect" +import { Rune } from "./rune/rune" +import type { ExecutionLimits } from "./rune/rune" +import type { HostTools } from "./rune/tool-runtime" export const CODE_MODE_TOOL = "execute" +/** + * Execution limits for the Rune interpreter. `maxDataBytes` is raised well above + * the Rune default (256KB) because code mode forwards base64 media attachments, + * and the timeout matches the default MCP request timeout. + */ +const CODE_LIMITS: ExecutionLimits = { + maxDataBytes: 10_000_000, + timeoutMs: 30_000, +} + 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.", @@ -27,12 +39,6 @@ export type Attachment = NonNullable[number] /** The envelope every tool call resolves to, and the shape a program should `return`. */ export type Envelope = { result: unknown; attachments?: Attachment[] } -// `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 { - new (...args: string[]): (...args: unknown[]) => Promise -} - const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ const SEARCH = "search" const DESCRIBE = "describe" @@ -282,16 +288,6 @@ export function fromReturn(value: unknown): { output: string; attachments?: Atta return { output: formatValue(value) } } -function errorMessage(error: unknown): string { - if (error instanceof Error) return error.message - if (typeof error === "string") return error - try { - return JSON.stringify(error) ?? String(error) - } catch { - return String(error) - } -} - export function define( mcpTools: Record, mcpDefs: Record, @@ -347,73 +343,69 @@ export function define( description: describe(groups), parameters: Parameters, execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { - const run = yield* EffectBridge.make() const calls: string[] = [] - // Each tool call runs the native MCP tool through the permission gate, so - // approving `execute` does not approve every child call. - const invoke = (key: string, tool: AITool, args: unknown) => + // One host function per MCP tool: gate on permission, dispatch to the native + // MCP tool, and coerce the result into the { result, attachments? } envelope. + // A failure (e.g. an MCP isError) fails the Effect, which the interpreter + // surfaces as a catchable in-program error. + const callTool = (key: string, tool: AITool) => (input: unknown) => Effect.gen(function* () { yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) - const result = yield* Effect.promise(() => - Promise.resolve( - tool.execute!(args ?? {}, { - toolCallId: ctx.callID ?? key, - abortSignal: ctx.abort, - messages: [], - }), - ), - ) + calls.push(key) + const result = yield* Effect.tryPromise({ + try: () => + Promise.resolve( + tool.execute!(input ?? {}, { toolCallId: ctx.callID ?? key, abortSignal: ctx.abort, messages: [] }), + ), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) return toEnvelope(result) }) - // Recursive path-accumulating proxy: `tools..(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 - 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) { - 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])) - }, - }) + // 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. + const tools: HostTools = { + [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 + let namespace = tools[entry.server] as HostTools | undefined + if (!namespace) { + namespace = {} + tools[entry.server] = namespace + } + namespace[entry.local] = callTool(entry.key, entry.tool) + } - const tools = make([]) - - return yield* Effect.tryPromise({ - try: () => new AsyncFunction("tools", params.code)(tools), - catch: (error) => error, - }).pipe( - Effect.map((value) => { - const { output, attachments } = fromReturn(value) - return { - title: "Code mode", - metadata: { toolCalls: calls }, - output, - ...(attachments && attachments.length > 0 ? { attachments } : {}), - } satisfies Tool.ExecuteResult - }), - Effect.catch((error) => - Effect.succeed({ - title: "Code mode", - metadata: { toolCalls: calls, error: true }, - output: errorMessage(error), - } satisfies Tool.ExecuteResult), - ), - ) + const result = yield* Rune.execute({ + code: params.code, + tools: tools as unknown as Record, + limits: CODE_LIMITS, + }) + + if (result.ok) { + const { output, attachments } = fromReturn(result.value) + return { + title: "Code mode", + metadata: { toolCalls: calls }, + output, + ...(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. + const hint = + result.error.kind === "UnknownCapability" + ? "\nUse tools.search(query) to discover available tools." + : "" + return { + title: "Code mode", + metadata: { toolCalls: calls, error: true }, + output: result.error.message + hint, + } satisfies Tool.ExecuteResult }), }), ) diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 8f9811b75db2..4e140e1816d1 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -182,7 +182,7 @@ describe("code mode execute", () => { test("returns a readable error when the program throws", async () => { const tool = await build({}) const output = await Effect.runPromise(tool.execute({ code: "throw new Error('boom')" }, ctx)) - expect(output.output).toBe("boom") + expect(output.output).toBe("Uncaught: boom") expect(output.metadata.error).toBe(true) }) @@ -190,7 +190,7 @@ describe("code mode execute", () => { const tool = await build({ known_tool: mcpTool("tool", () => "ok") }) 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 'tools.known.missing'") + expect(output.output).toContain("Unknown tool 'known.missing'") expect(output.output).toContain("tools.search") }) @@ -251,6 +251,19 @@ describe("code mode execute", () => { expect(formatValue(undefined)).toBe("undefined") }) + test("terminates a runaway loop via the operation limit instead of hanging", async () => { + const tool = await build({}) + const output = await Effect.runPromise(tool.execute({ code: "while (true) {}" }, ctx)) + expect(output.metadata.error).toBe(true) + expect(output.output.toLowerCase()).toContain("operation") + }) + + test("isolates the sandbox from host globals", async () => { + const tool = await build({}) + const output = await Effect.runPromise(tool.execute({ code: "return process.env" }, ctx)) + expect(output.metadata.error).toBe(true) + }) + test("describe shows the structured return type when the tool declares an outputSchema", async () => { const tools = { weather_current: mcpTool("current", () => "", { type: "object", properties: { city: { type: "string" } } }) } const defs: Record = { From 1448f248fdbbd998c350942fa839de0ad9469f3b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 09:49:52 -0500 Subject: [PATCH 08/41] feat(opencode): budgeted tool preview in code mode description Always list every MCP namespace, then inline a preview of individual tools (path + brief) until a character budget is hit; remaining namespaces show counts only. Front-loads a useful slice of the catalog to cut discovery round-trips without dumping the full tool list. --- packages/opencode/src/session/code-mode.ts | 29 ++++++++++++++++--- .../opencode/test/session/code-mode.test.ts | 29 +++++++++++++++++-- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 09fb15fcffec..bce0b03d8991 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -153,9 +153,18 @@ const signatureFor = (entry: CatalogEntry) => `tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)}): ${returnType(entry.outputSchema)}` /** - * 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. + * Character budget for the inline tool preview in the tool description. All + * namespaces are always listed; individual tools are previewed (cheapest first, + * server by server) until this many characters of preview lines are used, after + * which the remaining namespaces show counts only. This front-loads a useful slice + * of the catalog — cutting discovery round-trips — without dumping every tool. + */ +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`. */ export function describe(groups: Map): string { const lines = [ @@ -178,9 +187,21 @@ export function describe(groups: Map): string { lines.push("", "No MCP servers are currently connected.") return lines.join("\n") } - lines.push("", "Available namespaces:") + lines.push("", "Available namespaces (use tools.search / tools.describe to explore tools not shown):") + let used = 0 + let previewing = true for (const [server, entries] of [...groups].sort(([a], [b]) => a.localeCompare(b))) { lines.push(`- ${server} (${entries.length} tool${entries.length === 1 ? "" : "s"})`) + if (!previewing) continue + for (const entry of entries) { + const line = ` - ${entry.path}${entry.description ? ` — ${brief(entry.description, 80)}` : ""}` + if (used + line.length > PREVIEW_BUDGET) { + previewing = false + break + } + lines.push(line) + used += line.length + } } return lines.join("\n") } diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 4e140e1816d1..eaead70a6425 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -56,7 +56,7 @@ describe("code mode execute", () => { await expect(Effect.runPromise(decode({}))).rejects.toThrow() }) - test("describes namespaces only (not per-tool signatures) and the discovery API", () => { + test("lists all namespaces, previews tools within budget, and documents discovery", () => { const groups = groupByServer( { github_create_issue: mcpTool("create_issue", () => ""), @@ -71,8 +71,31 @@ describe("code mode execute", () => { expect(description).toContain("tools.describe(path)") expect(description).toContain("- github (2 tools)") expect(description).toContain("- linear (1 tool)") - // The full catalog must NOT be inlined in the prompt. - expect(description).not.toContain("create_issue") + // 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). + expect(description).not.toContain("): Promise<") + }) + + test("falls back to namespaces-only when the catalog exceeds the preview budget", () => { + const tools: Record = {} + const longDesc = "performs a meaningful operation against the service with several options" + for (let i = 0; i < 60; i++) { + tools[`alpha_op_${i}`] = mcpTool(`op_${i}`, () => "", { type: "object", properties: {} }) + ;(tools[`alpha_op_${i}`] as any).description = longDesc + } + tools["zeta_only_tool"] = mcpTool("only_tool", () => "") + const groups = groupByServer(tools, ["alpha", "zeta"]) + const description = describeTools(groups) + + // Every namespace is always present, with counts. + expect(description).toContain("- alpha (60 tools)") + expect(description).toContain("- zeta (1 tool)") + // The preview is budget-capped, so the later namespace's tools are not all inlined. + expect(description).not.toContain("zeta.only_tool") + // Some early tools are still previewed. + expect(description).toContain("alpha.op_0") }) test("tools.search and tools.describe expose the catalog on demand", async () => { From acc17428594d29cdae0ee63a6f43c12e1f9bf370 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 09:55:25 -0500 Subject: [PATCH 09/41] test(opencode): end-to-end code mode test over a real MCP server Stand up an in-memory MCP server with text, structured (outputSchema), image, and failing tools, wire it through convertTool + define, and exercise search/describe, the result+attachments envelope, structured composition, image forwarding/suppression, parallel calls, error propagation, and per-call permission gating. --- .../session/code-mode-integration.test.ts | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 packages/opencode/test/session/code-mode-integration.test.ts diff --git a/packages/opencode/test/session/code-mode-integration.test.ts b/packages/opencode/test/session/code-mode-integration.test.ts new file mode 100644 index 000000000000..6cb0fd86dea0 --- /dev/null +++ b/packages/opencode/test/session/code-mode-integration.test.ts @@ -0,0 +1,210 @@ +import { beforeAll, describe, expect, test } from "bun:test" +import { define } from "@/session/code-mode" +import { McpCatalog } from "@/mcp/catalog" +import { Agent } from "@/agent/agent" +import { Tool } from "@/tool/tool" +import * as Truncate from "@/tool/truncate" +import { MessageID, SessionID } from "@/session/schema" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { CallToolRequestSchema, ListToolsRequestSchema, type Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" +import type { Tool as AITool } from "ai" +import { Effect, Layer } from "effect" + +// A 1x1 transparent PNG, base64-encoded, used to exercise image attachments. +const PNG = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + +const SERVER = "fixtures" + +const ctx: Tool.Context = { + sessionID: SessionID.make("ses_code-mode-int"), + messageID: MessageID.make("msg_code-mode-int"), + agent: "build", + abort: new AbortController().signal, + callID: "call_code_mode_int", + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +// Truncate echoes its input so assertions read the exact program output. +const layer = Layer.mergeAll( + Layer.mock(Truncate.Service, { + output: (text: string) => Effect.succeed({ content: text, truncated: false as const }), + }), + Layer.succeed(Agent.Service, Agent.Service.of({ get: () => Effect.succeed({ name: "build" } as any) } as any)), +) + +// A real MCP server, exposed over an in-memory transport, with a representative mix +// of tools: plain text, structured data (with an outputSchema), an image, and a +// failing tool. Tools are defined with raw JSON Schema so outputSchema is exact. +const TOOL_DEFS: MCPToolDef[] = [ + { + name: "get_text", + description: "Greet someone and return the greeting as text", + inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + }, + { + name: "add", + description: "Add two numbers and return the structured sum", + inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] }, + outputSchema: { type: "object", properties: { sum: { type: "number" } }, required: ["sum"] }, + }, + { + name: "screenshot", + description: "Capture a screenshot and return it as an image", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "boom", + description: "A tool that always fails", + inputSchema: { type: "object", properties: {} }, + }, +] as MCPToolDef[] + +function handleCall(name: string, args: Record) { + switch (name) { + case "get_text": + return { content: [{ type: "text", text: `hello ${args.name}` }] } + case "add": { + const sum = (args.a as number) + (args.b as number) + return { content: [{ type: "text", text: String(sum) }], structuredContent: { sum } } + } + case "screenshot": + return { content: [{ type: "image", data: PNG, mimeType: "image/png" }] } + case "boom": + return { content: [{ type: "text", text: "kaboom" }], isError: true } + default: + return { content: [{ type: "text", text: `unknown tool ${name}` }], isError: true } + } +} + +let tool: Awaited> + +async function buildTool() { + const server = new Server({ name: SERVER, version: "1.0.0" }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS })) + server.setRequestHandler(CallToolRequestSchema, async (req) => + handleCall(req.params.name, (req.params.arguments ?? {}) as Record), + ) + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await server.connect(serverTransport) + const client = new Client({ name: "test-client", version: "1.0.0" }) + await client.connect(clientTransport) + + const listed = (await client.listTools()).tools as MCPToolDef[] + const mcpTools: Record = {} + const mcpDefs: Record = {} + for (const def of listed) { + const key = McpCatalog.toolName(SERVER, def.name) + mcpDefs[key] = def + mcpTools[key] = McpCatalog.convertTool(def, client) + } + return Effect.runPromise(define(mcpTools, mcpDefs, [SERVER]).pipe(Effect.flatMap(Tool.init), Effect.provide(layer))) +} + +const run = (code: string) => Effect.runPromise(tool.execute({ code }, ctx)) + +beforeAll(async () => { + tool = await buildTool() +}) + +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 desc = JSON.parse(out.output) + expect(desc.path).toBe("fixtures.add") + expect(desc.signature).toBe( + "tools.fixtures.add(input: { a: number; b: number }): Promise<{ result: { sum: number }; attachments?: Attachment[] }>", + ) + expect(desc.outputSchema).toBeDefined() + }) + + 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 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 result = JSON.parse(out.output) + expect(result.items.map((i: any) => i.path)).toContain("fixtures.screenshot") + }) + + test("calls a text tool and unwraps the result envelope", async () => { + const out = await run("const r = await tools.fixtures.get_text({ name: 'world' }); return r.result") + expect(out.output).toBe("hello world") + expect(out.metadata.toolCalls).toEqual(["fixtures_get_text"]) + expect(out.attachments).toBeUndefined() + }) + + test("exposes structured data from a tool with an outputSchema", async () => { + const out = await run("const r = await tools.fixtures.add({ a: 2, b: 3 }); return r.result.sum") + expect(out.output).toBe("5") + }) + + test("composes multiple structured calls and returns a plain object", async () => { + const out = await run(` + const first = await tools.fixtures.add({ a: 1, b: 2 }) + const second = await tools.fixtures.add({ a: first.result.sum, b: 10 }) + return { total: second.result.sum } + `) + expect(JSON.parse(out.output)).toEqual({ total: 13 }) + expect(out.metadata.toolCalls).toEqual(["fixtures_add", "fixtures_add"]) + }) + + test("forwards an image as an attachment when the whole result is returned", async () => { + const out = await run("return await tools.fixtures.screenshot({})") + expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: `data:image/png;base64,${PNG}` }]) + }) + + test("drops media when only .result is returned", async () => { + const out = await run("const r = await tools.fixtures.screenshot({}); return { result: 'captured' }") + expect(out.output).toBe("captured") + expect(out.attachments).toBeUndefined() + }) + + test("runs calls in parallel and forwards multiple attachments the model curates", async () => { + const out = await run(` + const [a, b] = await Promise.all([tools.fixtures.screenshot({}), tools.fixtures.screenshot({})]) + return { result: 'two shots', attachments: [...(a.attachments ?? []), ...(b.attachments ?? [])] } + `) + expect(out.output).toBe("two shots") + expect(out.attachments).toHaveLength(2) + expect(out.metadata.toolCalls.sort()).toEqual(["fixtures_screenshot", "fixtures_screenshot"]) + }) + + test("propagates an MCP isError into the program as a catchable error", async () => { + const out = await run("try { await tools.fixtures.boom({}) } catch (e) { return 'caught: ' + e.message }") + expect(out.output).toBe("caught: kaboom") + }) + + test("an uncaught MCP error surfaces as a failed execution", async () => { + const out = await run("await tools.fixtures.boom({}); return 'unreachable'") + expect(out.metadata.error).toBe(true) + expect(out.output).toContain("kaboom") + }) + + test("asks permission for each MCP call but not for discovery helpers", async () => { + const asked: string[] = [] + const permCtx: Tool.Context = { ...ctx, ask: (req: any) => Effect.sync(() => void asked.push(req.permission)) } + await Effect.runPromise( + tool.execute( + { + code: ` + await tools.search('add') + await tools.describe('fixtures.add') + await tools.fixtures.add({ a: 1, b: 1 }) + return 'done' + `, + }, + permCtx, + ), + ) + expect(asked).toEqual(["fixtures_add"]) + }) +}) From ba12049ca56bd51feffc0382f9832950823e8e34 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 10:08:02 -0500 Subject: [PATCH 10/41] feat(opencode): tokenized, ranked tool search in code mode Replace the contiguous-substring search with tokenized, field-weighted scoring adapted from the deferred-tool-search bridge (#34368): exact tool name > path > description > indexed parameter text, summed across terms and ranked by relevance. Index each tool's parameter names/descriptions so tools are findable by their inputs. Keep the namespace filter and { items, total } shape. Add unit + end-to-end search tests. --- packages/opencode/src/session/code-mode.ts | 87 +++++++++++++++-- .../opencode/test/session/code-mode.test.ts | 94 ++++++++++++++++++- 2 files changed, 172 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index bce0b03d8991..8816f5666e69 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -309,6 +309,77 @@ export function fromReturn(value: unknown): { output: string; attachments?: Atta return { output: formatValue(value) } } +/** A search-indexed catalog entry: the fields ranking matches against, with + * `searchText` (path + description + parameter names/descriptions) precomputed. */ +export type SearchEntry = { path: string; server: string; description: string; searchText: string } + +/** The lowercased searchable text for a tool: its path, description, and the name + * (and description, when present) of each input parameter. */ +function searchTextFor(entry: CatalogEntry): string { + const parts = [entry.path, entry.description] + try { + const schema = asSchema(entry.tool.inputSchema).jsonSchema as JSONSchema7 | undefined + const props = schema?.properties + if (props && typeof props === "object") { + for (const [name, value] of Object.entries(props)) { + parts.push(name) + const desc = (value as JSONSchema7 | undefined)?.description + if (typeof desc === "string") parts.push(desc) + } + } + } catch { + // fall back to path + description only + } + return parts.join("\n").toLowerCase() +} + +/** Split a query into lowercased search terms, dropping empties and the `*` wildcard. */ +const tokenize = (query: string) => + query + .toLowerCase() + .split(/[^a-z0-9_-]+/) + .map((term) => term.trim()) + .filter((term) => term.length > 0 && term !== "*") + +/** + * Rank catalog entries against a query using tokenized, field-weighted scoring + * (adapted from the deferred-tool-search bridge). Each term contributes per field: + * exact tool name (20) > path substring (8) > description (4) > any searchable text (2), + * summed across terms. Because paths are `server.tool`, the exact tier matches a + * whole path segment (e.g. the term `search` matches `github.search`). An empty + * query lists everything (alphabetical). Results are ranked by score, tie-broken by path. + */ +export function rankTools( + entries: ReadonlyArray, + query: string, + namespace?: string, + limit = 25, +): { items: { path: string; description: string }[]; total: number } { + const terms = tokenize(query) + const scoped = namespace ? entries.filter((entry) => entry.server === namespace) : entries + const ranked = scoped + .map((entry) => { + const path = entry.path.toLowerCase() + const description = entry.description.toLowerCase() + const score = terms.reduce( + (total, term) => + total + + (path === term || path.endsWith(`.${term}`) ? 20 : 0) + + (path.includes(term) ? 8 : 0) + + (description.includes(term) ? 4 : 0) + + (entry.searchText.includes(term) ? 2 : 0), + 0, + ) + return { entry, score } + }) + .filter((item) => terms.length === 0 || item.score > 0) + .sort((a, b) => b.score - a.score || a.entry.path.localeCompare(b.entry.path)) + return { + items: ranked.slice(0, limit).map(({ entry }) => ({ path: entry.path, description: brief(entry.description) })), + total: ranked.length, + } +} + export function define( mcpTools: Record, mcpDefs: Record, @@ -317,19 +388,19 @@ export function define( const groups = groupByServer(mcpTools, servers, mcpDefs) const catalog: CatalogEntry[] = [...groups.values()].flat() const byKey = new Map(catalog.map((entry) => [entry.key, entry] as const)) + const index: SearchEntry[] = catalog.map((entry) => ({ + path: entry.path, + server: entry.server, + description: entry.description, + searchText: searchTextFor(entry), + })) const search = (query: unknown, options: unknown) => { - const q = (typeof query === "string" ? query : "").toLowerCase() + const q = typeof query === "string" ? query : "" 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, - } + return rankTools(index, q, namespace, limit) } const describeTool = (path: unknown) => { diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index eaead70a6425..21ca486c6348 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -1,5 +1,14 @@ import { describe, expect, test } from "bun:test" -import { Parameters, define, describe as describeTools, formatValue, groupByServer, toEnvelope } from "@/session/code-mode" +import { + Parameters, + define, + describe as describeTools, + formatValue, + groupByServer, + rankTools, + toEnvelope, + type SearchEntry, +} from "@/session/code-mode" import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import { Agent } from "@/agent/agent" import { Tool } from "@/tool/tool" @@ -323,4 +332,87 @@ describe("code mode execute", () => { expect(suppressed.attachments).toBeUndefined() expect(JSON.parse(suppressed.output)).toEqual({ name: "shot.png" }) }) + + test("indexes parameter names so tools are searchable by their inputs", async () => { + const tool = await build({ + // The query word appears only as a parameter name, not in path or description. + traces_lookup: mcpTool("lookup", () => "", { + type: "object", + properties: { trace_id: { type: "string", description: "the distributed trace identifier" } }, + }), + other_noop: mcpTool("noop", () => ""), + }) + const out = await Effect.runPromise(tool.execute({ code: "return await tools.search('trace_id')" }, ctx)) + const result = JSON.parse(out.output) + expect(result.items.map((i: any) => i.path)).toEqual(["traces.lookup"]) + }) +}) + +describe("rankTools", () => { + const E = (path: string, description: string, params = ""): SearchEntry => ({ + path, + server: path.split(".")[0]!, + description, + searchText: [path, description, params].join("\n").toLowerCase(), + }) + + test("matches multiple non-contiguous terms (not just a contiguous substring)", () => { + const entries = [ + E("github.create_issue", "Create a new issue on a repository"), + E("github.list_pulls", "List pull requests"), + ] + const { items, total } = rankTools(entries, "create issue") + expect(total).toBe(1) + expect(items[0]!.path).toBe("github.create_issue") + }) + + test("ranks an exact tool-name match above a substring match", () => { + const entries = [E("github.search_issues", "Search issues"), E("github.search", "Full text search")] + const { items } = rankTools(entries, "search") + expect(items[0]!.path).toBe("github.search") + }) + + test("ranks a name match above a description-only match", () => { + const entries = [ + E("datadog.list_monitors", "Enumerate alerting definitions"), + E("datadog.get_dashboard", "List the monitors on a dashboard"), + ] + const { items } = rankTools(entries, "monitors") + expect(items[0]!.path).toBe("datadog.list_monitors") + }) + + test("matches against indexed parameter text", () => { + const entries = [E("traces.lookup", "Fetch a span", "trace_id the distributed trace id"), E("other.noop", "Does nothing")] + const { items, total } = rankTools(entries, "trace_id") + expect(total).toBe(1) + expect(items[0]!.path).toBe("traces.lookup") + }) + + test("respects the namespace filter", () => { + const entries = [E("github.search", "search"), E("linear.search", "search")] + const { items, total } = rankTools(entries, "search", "linear") + expect(total).toBe(1) + expect(items[0]!.path).toBe("linear.search") + }) + + test("an empty query (or bare wildcard) lists everything alphabetically", () => { + const entries = [E("b.two", "second"), E("a.one", "first")] + for (const q of ["", "*"]) { + const { items, total } = rankTools(entries, q) + expect(total).toBe(2) + expect(items.map((i) => i.path)).toEqual(["a.one", "b.two"]) + } + }) + + test("honors the limit while reporting the full match total", () => { + const entries = Array.from({ length: 10 }, (_, i) => E(`s.tool_${i}`, "searchable tool")) + const { items, total } = rankTools(entries, "searchable", undefined, 3) + expect(total).toBe(10) + expect(items).toHaveLength(3) + }) + + test("returns nothing when no term matches", () => { + const entries = [E("github.search", "search")] + expect(rankTools(entries, "nonexistent")).toEqual({ items: [], total: 0 }) + }) }) From a3bfba809ff81b590ec474b39b657563d5d459d4 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 11:11:55 -0500 Subject: [PATCH 11/41] feat(opencode): unify code mode discovery under tools.$rune MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/opencode/src/session/code-mode.ts | 35 +++++++++------ .../opencode/src/session/rune/tool-runtime.ts | 45 ++----------------- .../session/code-mode-integration.test.ts | 10 ++--- .../opencode/test/session/code-mode.test.ts | 20 ++++----- 4 files changed, 39 insertions(+), 71 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 8816f5666e69..8289d9096485 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 4501493374e6..77e74f6e6a57 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 6cb0fd86dea0..35497544b401 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 21ca486c6348..38c7ba168713 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"]) }) From cbcc67b1e25812400e8da7c8e731c1399acdfad2 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 13:59:33 -0500 Subject: [PATCH 12/41] refactor(opencode): single source of discovery docs, clearer attachment wording - Reword the attachment line in the execute description: attachments are opaque media handles you forward to the user, not byte-readable in code. - Strip the hardcoded tools.$rune.search/describe block from the runtime's instructions(): discovery is not a runtime feature (the embedder registers and documents it), and the baked-in object-arg form contradicted code mode's positional API. Removes the only prompt discrepancy. - Make the runtime's unknown-tool suggestion generic instead of naming $rune. Discovery is now documented in exactly one place (code-mode describe()). --- packages/opencode/src/session/code-mode.ts | 6 +++--- packages/opencode/src/session/rune/tool-runtime.ts | 14 ++++---------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 8289d9096485..7a35f396364b 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -181,9 +181,9 @@ export function describe(groups: Map): string { "- 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? }`.", - "`result` is the structured data; `attachments` carries images/files for the user. Return a whole tool", - "result to forward its attachments, or return only its `.result` to drop the media. You cannot read the", - "contents of an attachment in code — only pass it along.", + "`result` is the structured data you compute over; `attachments` carry media (images, files) to show", + "the user. Return a tool's whole result to keep its attachments, or return just its `.result` to drop", + "them. Attachments are opaque handles — pass them through; their bytes aren't available in code.", "", "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.$rune.search('', { namespace })` to list a namespace.", diff --git a/packages/opencode/src/session/rune/tool-runtime.ts b/packages/opencode/src/session/rune/tool-runtime.ts index 77e74f6e6a57..fe2c02139090 100644 --- a/packages/opencode/src/session/rune/tool-runtime.ts +++ b/packages/opencode/src/session/rune/tool-runtime.ts @@ -151,12 +151,11 @@ export const catalog = (tools: HostTools): ReadonlyArray // `$rune` namespace), not by the runtime, so there are no reserved namespaces. export const assertValidTools = (_tools: HostTools): void => {} +// Generic Rune-language instructions. Discovery (e.g. searching a large or dynamic +// catalog) is not a runtime feature; an embedder that wants it registers ordinary +// host tools and documents them itself, so nothing is hardcoded here. export const instructions = (tools: HostTools): string => { const described = catalog(tools) - const discovery = [ - "- tools.$rune.search({ query: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string }>; total: number }>", - "- tools.$rune.describe({ path: string }): Promise<{ path: string; description: string; signature: string }>", - ] const lines = [ "Write a Rune Program to answer the request. Return code only.", "Rune Programs can call explicit tools.* capabilities and transform plain data.", @@ -165,11 +164,6 @@ export const instructions = (tools: HostTools): string => { "Available Tool Capabilities:", ...described.map((tool) => `- ${tool.signature} // ${tool.description}`), "", - ...(discovery.length > 0 ? [ - "For a large or dynamic catalog, you can discover additional capabilities in the program:", - ...discovery, - "", - ] : []), "Common syntax: arrow functions and `function` declarations (hoisted) with closures, default/rest parameters, destructuring (incl. rest/defaults), optional chaining, template literals, conditionals, switch, loops, spread (arrays/objects/strings), try/catch, ternary, the `in` operator, logical assignment (??=/||=/&&=), and bitwise operators (& | ^ ~ << >> >>>). Signal failure with `throw` (any value) or `throw new Error(message)`.", "Transform data with array methods (map/filter/reduce/reduceRight/flatMap/forEach/find/findIndex/findLast/findLastIndex/sort/toSorted/slice/concat/indexOf/at/flat/reverse/toReversed/with/includes/join, plus push/pop/shift/unshift for accumulation), string methods (toLowerCase/toUpperCase/trim/split/slice/substring/replace/replaceAll/includes/startsWith/endsWith/indexOf/padStart/padEnd/repeat/charCodeAt), number methods (toFixed/toString(radix)/toPrecision), Object.keys/values/entries/fromEntries/hasOwn, Math.* (incl. PI/E), JSON.parse/stringify, Array.from/isArray/of, Number.isInteger/isNaN/parseInt, String.fromCharCode, parseInt/parseFloat, and Number/String/Boolean.", "Use Promise.all([...]) for parallel tool calls (a direct array of calls, or items.map((item) => tool call)).", @@ -182,7 +176,7 @@ const resolve = (tools: HostTools, path: ReadonlyArray): HostTool< for (const segment of path) { if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) { - throw new ToolRuntimeError("UnknownCapability", `Unknown tool '${path.join(".")}'.`, ["Use tools.$rune.search({ query }) to find available described capabilities."]) + throw new ToolRuntimeError("UnknownCapability", `Unknown tool '${path.join(".")}'.`, ["Call a capability by its exact tools.* path."]) } value = value[segment] as HostTool | Definition | HostTools } From 79275e60fb6189a59d98770d49d91598b046f9d3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 14:10:06 -0500 Subject: [PATCH 13/41] fix(opencode): describe attachments as routable values, not opaque MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous wording claimed attachment bytes 'aren't available in code', but a tool result's { result, attachments } envelope is copied straight into the sandbox (tool-runtime invoke -> checkedCopyIn), so attachment.url is a real data: URL string the program can read and route — e.g. feed one tool's media into another tool's input, which is exactly what code mode is uniquely good at. Reword the description to say so. Still not the emit pattern: only `result` becomes conversation text; returned attachments lower to FileParts and nothing else in the sandbox re-enters the chat. Add an integration test asserting the data URL is readable in-program. --- packages/opencode/src/session/code-mode.ts | 7 ++++--- .../test/session/code-mode-integration.test.ts | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 7a35f396364b..14c242f95c57 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -181,9 +181,10 @@ export function describe(groups: Map): string { "- 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? }`.", - "`result` is the structured data you compute over; `attachments` carry media (images, files) to show", - "the user. Return a tool's whole result to keep its attachments, or return just its `.result` to drop", - "them. Attachments are opaque handles — pass them through; their bytes aren't available in code.", + "`result` is the structured data; `attachments` are media as `{ type: 'file', mime, url }` — ordinary", + "values you can read and route (e.g. feed one tool's attachment into another tool's input). Whichever", + "attachments you return are shown to the user as media; only `result` becomes text, so nothing in the", + "sandbox (attachment bytes included) re-enters the conversation unless you put it in `result`.", "", "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.$rune.search('', { namespace })` to list a namespace.", diff --git a/packages/opencode/test/session/code-mode-integration.test.ts b/packages/opencode/test/session/code-mode-integration.test.ts index 35497544b401..01823cb9600b 100644 --- a/packages/opencode/test/session/code-mode-integration.test.ts +++ b/packages/opencode/test/session/code-mode-integration.test.ts @@ -162,6 +162,22 @@ describe("code mode integration (real MCP server)", () => { expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: `data:image/png;base64,${PNG}` }]) }) + test("an attachment's bytes are readable and routable in code, not opaque", async () => { + // The data: URL carrying the base64 payload is an ordinary string in the + // sandbox: the program can inspect it (and thus route it into another tool). + const out = await run(` + const shot = await tools.fixtures.screenshot({}) + const url = shot.attachments[0].url + return { result: { mime: shot.attachments[0].mime, isDataUrl: url.startsWith('data:'), bytes: url.length } } + `) + expect(JSON.parse(out.output)).toEqual({ + mime: "image/png", + isDataUrl: true, + bytes: `data:image/png;base64,${PNG}`.length, + }) + expect(out.attachments).toBeUndefined() + }) + test("drops media when only .result is returned", async () => { const out = await run("const r = await tools.fixtures.screenshot({}); return { result: 'captured' }") expect(out.output).toBe("captured") From 394c37084b96478ce66371546806138b50ebea36 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 15:03:45 -0500 Subject: [PATCH 14/41] docs(opencode): add rune.md (how it works, what's missing) --- packages/opencode/src/session/rune/rune.md | 91 ++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 packages/opencode/src/session/rune/rune.md diff --git a/packages/opencode/src/session/rune/rune.md b/packages/opencode/src/session/rune/rune.md new file mode 100644 index 000000000000..fdf68b0abd68 --- /dev/null +++ b/packages/opencode/src/session/rune/rune.md @@ -0,0 +1,91 @@ +# Rune + +A sandboxed JavaScript interpreter. It runs untrusted model-authored code that calls +host-provided tools and transforms plain JSON. It is **not** a JS engine: it walks an +AST and hand-implements an allowlisted subset of the language. Anything not explicitly +implemented throws. + +## How it works + +### Pipeline (`Rune.execute`) +1. **Wrap** the source in `async function __rune__() { ... }`. +2. **Transpile** via the TypeScript compiler (`transpileModule`, target ESNext) — strips + types only. Runtime-agnostic (no `Bun.Transpiler`, no Node `module` APIs). +3. **Parse** the stripped body with `acorn` → ESTree AST. (Acorn is a parser only: it + provides zero runtime/stdlib.) +4. **Interpret**: a tree-walking evaluator (`Interpreter`) executes the AST as an + `Effect`. Host tool calls are async; everything else is synchronous evaluation. + +### Values +- Only **Data Values** cross boundaries: `null`, `undefined`, `boolean`, `number`, + `string`, and null-prototype objects/arrays of Data Values. +- `copyIn`/`copyOut` deep-clone at every boundary (tool args, tool results, return). + Runtime references (functions, `tools`) never leak into data. +- Objects are null-prototype (no inherited `Object` methods, no prototype pollution). + +### Tools +- The host passes a tree of functions under `tools`. The program calls + `await tools.(...)`; the interpreter resolves the path and invokes the host + function, copying args in and the result back. +- Unknown path → `UnknownCapability`. Host failures surface as catchable in-program + errors. + +### Result +`{ ok: true, value, toolCalls }` or `{ ok: false, error: { kind, message, location?, +suggestions? }, toolCalls }`. `value` is the program's `return`. Uncaught `throw` +becomes `ok: false`. + +### Limits (`ExecutionLimits`, enforced; defaults) +| Limit | Default | Bounds | +|---|---|---| +| `maxOperations` | 100,000 | interpreter steps (kills infinite loops) | +| `timeoutMs` | 10,000 | wall-clock | +| `maxToolCalls` | 100 | tool invocations | +| `maxConcurrency` | 8 | in-flight `Promise.all` calls | +| `maxSourceBytes` | 32,000 | source size | +| `maxDataBytes` | 256,000 | any single data value (args/result/assignment/return) | +| `maxAuditBytes` | 1,000,000 | cumulative tool-call audit trail | +| `maxValueDepth` | 32 | nesting depth of a data value | +| `maxCollectionLength` | 10,000 | elements/keys in one array/object | + +Bytes are measured ~`JSON.stringify(value).byteLength`. `maxDataBytes` is per-value, +not cumulative. (Code mode overrides `maxDataBytes`→10MB, `timeoutMs`→30s.) + +## Standard library (allowlist — everything else throws) + +- **Globals**: `tools`, `Promise`, `Object`, `Math`, `JSON`, `Number`, `String`, + `Boolean`, `Array`, `parseInt`, `parseFloat`, `undefined`. +- **String**: case/trim, split, slice/substring/substr, includes/startsWith/endsWith, + indexOf/lastIndexOf, replace/replaceAll, repeat, padStart/padEnd, charAt/at, + charCodeAt/codePointAt, concat. **String args only.** +- **Array**: map/filter/reduce/reduceRight/forEach/find/findIndex/some/every/sort/ + toSorted/slice/concat/flat/flatMap/reverse/toReversed/with/join/includes/indexOf/at + + push/pop/shift/unshift. +- **Object**: keys/values/entries/hasOwn/assign/fromEntries. +- **Math**: max/min/abs/floor/ceil/round/trunc/sign/sqrt/cbrt/pow/hypot/log/log2/log10/ + exp, PI, E. +- **Number**: isInteger/isFinite/isNaN/isSafeInteger/parseInt/parseFloat; instance + toFixed/toExponential/toPrecision/toString(radix). +- **JSON**: parse, stringify (no replacer). +- **Syntax**: arrow + `function` (hoisted), closures, default/rest params, destructuring, + spread, optional chaining, template literals, conditionals, switch, loops, `for...of`, + try/catch, ternary, `in`, logical assignment, bitwise ops, `await`, `Promise.all`. + +## What is missing + +- **`Date`** — no dates or time. +- **`RegExp` / regex literals** — none. `replace`/`split` take plain strings only. +- **`Map` / `Set` / `WeakMap` / `WeakSet`** — none. +- **`console`** — no logging/output. +- **`Promise`** — only `Promise.all`. No `new Promise`, `race`, `allSettled`, `resolve`, + `reject`. +- **`new`** — only the `Error` family (`Error`, `TypeError`, `RangeError`, `SyntaxError`, + `ReferenceError`, `EvalError`, `URIError`), and they yield plain `{ name, message }` + data objects, not real `Error` instances. +- **Classes, generators, `async function*`, `for await...of`, labeled break/continue** — + rejected as `UnsupportedSyntax`. +- **`Symbol`, `BigInt`** — none. +- **Partial built-ins** — e.g. `JSON.stringify` ignores replacers; `Array.from` takes no + map fn; `NaN`/`Infinity` are not valid data values. +- **No I/O** — no fetch, fs, timers, env, or any ambient capability. The only outside + contact is host `tools`. From 55aa8cce4412998e7acf9291ee6c6beb672e75b5 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 15:46:30 -0500 Subject: [PATCH 15/41] feat(opencode): inline budgeted call signatures in code mode preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview previously showed name + prose per tool but no parameters, so the model couldn't call a tool correctly without a $rune.describe round-trip (or a failed guess — e.g. calling context7 resolve-library-id with only libraryName, missing the required query). Replace the prose preview with a compact, directly-callable input signature: tools.context7["resolve-library-id"](input: { query: string; libraryName: string }) All namespaces are still always listed with counts; the budget now caps inlined signatures, and the description states explicitly that any tool not shown must be found via $rune.search/$rune.describe first. The full typed signature (with the Promise return) and schemas remain $rune.describe-only. --- packages/opencode/src/session/code-mode.ts | 30 +++++++++++------ .../opencode/test/session/code-mode.test.ts | 32 +++++++++++-------- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 14c242f95c57..a96f45123086 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -156,19 +156,26 @@ const returnType = (outputSchema: JSONSchema7 | undefined) => const signatureFor = (entry: CatalogEntry) => `tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)}): ${returnType(entry.outputSchema)}` +/** The compact, directly-callable signature for the inline preview: the call path + * plus its input type, but without the (uniform) `Promise<{ result, attachments? }>` + * return — that full typed form is reserved for `tools.$rune.describe`. */ +const previewSignature = (entry: CatalogEntry) => + `tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)})` + /** - * Character budget for the inline tool preview in the tool description. All - * namespaces are always listed; individual tools are previewed (cheapest first, - * server by server) until this many characters of preview lines are used, after - * which the remaining namespaces show counts only. This front-loads a useful slice - * of the catalog — cutting discovery round-trips — without dumping every tool. + * Character budget for the inline signature preview in the tool description. All + * namespaces are always listed; per-tool call signatures are previewed (cheapest + * first, server by server) until this many characters are used, after which the + * remaining namespaces show counts only. This front-loads a directly-callable slice + * of the catalog — cutting discovery round-trips — without dumping every signature. */ 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.$rune.describe`. + * list of namespaces. A budgeted preview of per-tool call signatures is inlined; the + * full typed signature + schemas are fetched on demand with `tools.$rune.describe`, + * and any tool not previewed must be found via `tools.$rune.search` first. */ export function describe(groups: Map): string { const lines = [ @@ -193,14 +200,19 @@ export function describe(groups: Map): string { lines.push("", "No MCP servers are currently connected.") return lines.join("\n") } - lines.push("", "Available namespaces (use tools.$rune.search / tools.$rune.describe to explore tools not shown):") + lines.push( + "", + "Every connected server is listed below with its tool count. A budgeted sample of tool", + "signatures is shown inline; any tool not shown must be found with `tools.$rune.search` /", + "`tools.$rune.describe` before you call it.", + ) let used = 0 let previewing = true for (const [server, entries] of [...groups].sort(([a], [b]) => a.localeCompare(b))) { lines.push(`- ${server} (${entries.length} tool${entries.length === 1 ? "" : "s"})`) if (!previewing) continue for (const entry of entries) { - const line = ` - ${entry.path}${entry.description ? ` — ${brief(entry.description, 80)}` : ""}` + const line = ` - ${previewSignature(entry)}` if (used + line.length > PREVIEW_BUDGET) { previewing = false break diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 38c7ba168713..0c4ad4d8152c 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -65,10 +65,14 @@ describe("code mode execute", () => { await expect(Effect.runPromise(decode({}))).rejects.toThrow() }) - test("lists all namespaces, previews tools within budget, and documents discovery", () => { + test("lists all namespaces, previews tool signatures within budget, and documents discovery", () => { const groups = groupByServer( { - github_create_issue: mcpTool("create_issue", () => ""), + github_create_issue: mcpTool("create_issue", () => "", { + type: "object", + properties: { title: { type: "string" }, body: { type: "string" } }, + required: ["title"], + }), github_list_issues: mcpTool("list_issues", () => ""), linear_search: mcpTool("search", () => ""), }, @@ -78,21 +82,23 @@ describe("code mode execute", () => { expect(description).toContain("tools.$rune.search(query") expect(description).toContain("tools.$rune.describe(path)") + // Every namespace is always listed, with counts. 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.$rune.describe). + // Tools are previewed inline as compact, directly-callable input signatures. + expect(description).toContain("tools.github.create_issue(input: { title: string; body?: string })") + expect(description).toContain("tools.linear.search(input: object)") + // ...but not the full Promise return — that comes from tools.$rune.describe. expect(description).not.toContain("): Promise<") }) test("falls back to namespaces-only when the catalog exceeds the preview budget", () => { const tools: Record = {} - const longDesc = "performs a meaningful operation against the service with several options" for (let i = 0; i < 60; i++) { - tools[`alpha_op_${i}`] = mcpTool(`op_${i}`, () => "", { type: "object", properties: {} }) - ;(tools[`alpha_op_${i}`] as any).description = longDesc + tools[`alpha_op_${i}`] = mcpTool(`op_${i}`, () => "", { + type: "object", + properties: { value: { type: "string" }, count: { type: "number" } }, + }) } tools["zeta_only_tool"] = mcpTool("only_tool", () => "") const groups = groupByServer(tools, ["alpha", "zeta"]) @@ -101,10 +107,10 @@ describe("code mode execute", () => { // Every namespace is always present, with counts. expect(description).toContain("- alpha (60 tools)") expect(description).toContain("- zeta (1 tool)") - // The preview is budget-capped, so the later namespace's tools are not all inlined. - expect(description).not.toContain("zeta.only_tool") - // Some early tools are still previewed. - expect(description).toContain("alpha.op_0") + // The preview is budget-capped, so the later namespace's signatures are not all inlined. + expect(description).not.toContain("tools.zeta.only_tool(") + // Some early signatures are still previewed. + expect(description).toContain("tools.alpha.op_0(") }) test("tools.$rune.search and tools.$rune.describe expose the catalog on demand", async () => { From dc75ea0cc0e3501b0689fd16810e9f6f4ac8f259 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 16:56:37 -0500 Subject: [PATCH 16/41] feat(opencode): state whether the code mode tool list is complete or partial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview now reports its own comprehensiveness so the model knows when it has the whole catalog vs. when it must search: - Overall: "This is the COMPLETE list ..." when every tool fits the budget, else "This is a PARTIAL list — X of Y tools are shown ...". - Per namespace: a fully-shown server reads `- github (2 tools)`; a truncated one is annotated `- alpha (70 tools, 31 shown)` or `- zeta (1 tool, none shown)`. When complete, the model isn't pushed toward needless $rune.search calls; when partial, exactly what's missing is unambiguous. --- packages/opencode/src/session/code-mode.ts | 58 +++++++++++++------ .../opencode/test/session/code-mode.test.ts | 12 ++-- 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index a96f45123086..719d815accab 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -200,26 +200,50 @@ export function describe(groups: Map): string { lines.push("", "No MCP servers are currently connected.") return lines.join("\n") } - lines.push( - "", - "Every connected server is listed below with its tool count. A budgeted sample of tool", - "signatures is shown inline; any tool not shown must be found with `tools.$rune.search` /", - "`tools.$rune.describe` before you call it.", - ) + // Select which signatures fit the budget (cheapest first within each server, + // servers alphabetical) before emitting, so the list can state exactly how + // comprehensive it is — overall and per namespace. + const ordered = [...groups].sort(([a], [b]) => a.localeCompare(b)) + const shown = new Map() let used = 0 - let previewing = true - for (const [server, entries] of [...groups].sort(([a], [b]) => a.localeCompare(b))) { - lines.push(`- ${server} (${entries.length} tool${entries.length === 1 ? "" : "s"})`) - if (!previewing) continue - for (const entry of entries) { - const line = ` - ${previewSignature(entry)}` - if (used + line.length > PREVIEW_BUDGET) { - previewing = false - break + let budgetLeft = true + let totalTools = 0 + let totalShown = 0 + for (const [server, entries] of ordered) { + totalTools += entries.length + const picked: string[] = [] + if (budgetLeft) { + for (const entry of entries) { + const line = ` - ${previewSignature(entry)}` + if (used + line.length > PREVIEW_BUDGET) { + budgetLeft = false + break + } + picked.push(line) + used += line.length } - lines.push(line) - used += line.length } + shown.set(server, picked) + totalShown += picked.length + } + + const complete = totalShown === totalTools + lines.push( + "", + complete + ? "This is the COMPLETE list of available tools — every connected tool is shown below with its call signature. Use `tools.$rune.describe(path)` for a tool's full types." + : `This is a PARTIAL list — ${totalShown} of ${totalTools} tools are shown below. Any tool not listed must be found with \`tools.$rune.search\` first; use \`tools.$rune.describe(path)\` for full types.`, + ) + for (const [server, entries] of ordered) { + const picked = shown.get(server)! + const total = entries.length + const count = `${total} tool${total === 1 ? "" : "s"}` + // Annotate only when a namespace is not fully shown, so a comprehensive + // namespace reads cleanly and a truncated one is unambiguous. + const label = + picked.length === total ? count : picked.length === 0 ? `${count}, none shown` : `${count}, ${picked.length} shown` + lines.push(`- ${server} (${label})`) + for (const line of picked) lines.push(line) } return lines.join("\n") } diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 0c4ad4d8152c..ac6c212f4b69 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -82,7 +82,8 @@ describe("code mode execute", () => { expect(description).toContain("tools.$rune.search(query") expect(description).toContain("tools.$rune.describe(path)") - // Every namespace is always listed, with counts. + // Small catalog: the list is comprehensive and says so, with clean counts. + expect(description).toContain("This is the COMPLETE list") expect(description).toContain("- github (2 tools)") expect(description).toContain("- linear (1 tool)") // Tools are previewed inline as compact, directly-callable input signatures. @@ -104,10 +105,11 @@ describe("code mode execute", () => { const groups = groupByServer(tools, ["alpha", "zeta"]) const description = describeTools(groups) - // Every namespace is always present, with counts. - expect(description).toContain("- alpha (60 tools)") - expect(description).toContain("- zeta (1 tool)") - // The preview is budget-capped, so the later namespace's signatures are not all inlined. + // The list states it is partial, and every namespace is still present with its total. + expect(description).toContain("This is a PARTIAL list") + expect(description).toContain("- alpha (60 tools") + // The later namespace is fully truncated, and says so. + expect(description).toContain("- zeta (1 tool, none shown)") expect(description).not.toContain("tools.zeta.only_tool(") // Some early signatures are still previewed. expect(description).toContain("tools.alpha.op_0(") From bc427b11b71922213eff08deca98f0e1123ea6cf Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 17:13:16 -0500 Subject: [PATCH 17/41] feat: live code-mode execute UI in the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: code-mode execute now streams per-call progress. Metadata.toolCalls becomes CallEntry[] ({ tool: dotted-path, status: running|completed|error }) and is published via ctx.metadata on every status change — when a child MCP call starts and when it resolves/fails — so the tool part updates live. TUI: add an Execute component (dispatched for the "execute" tool). Condensed view shows a run header (spinner while running, ✓ when done) plus a live `↳` line per child tool call, colored on failure; clicking toggles a syntax- highlighted view of the program source. Unlike Task, there is no child session, so the call list is sourced entirely from the streamed metadata. --- packages/opencode/src/session/code-mode.ts | 42 ++++++++--- .../session/code-mode-integration.test.ts | 9 ++- .../opencode/test/session/code-mode.test.ts | 42 ++++++++++- packages/tui/src/routes/session/index.tsx | 73 +++++++++++++++++++ 4 files changed, 150 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 719d815accab..41bacdb9c129 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -24,8 +24,12 @@ export const Parameters = Schema.Struct({ }), }) +/** One child tool call, surfaced live so the UI can render a per-call line that + * updates as the program runs. `tool` is the dotted path (e.g. `github.create_issue`). */ +export type CallEntry = { tool: string; status: "running" | "completed" | "error" } + type Metadata = { - toolCalls: string[] + toolCalls: CallEntry[] error?: boolean } @@ -477,24 +481,42 @@ export function define( description: describe(groups), parameters: Parameters, execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { - const calls: string[] = [] + const calls: CallEntry[] = [] + // Stream the current call list to the UI. Sent on every status change so the + // tool part shows each child call appearing and resolving while the program runs. + const publish = () => + ctx.metadata({ title: "Code mode", metadata: { toolCalls: calls.map((c) => ({ ...c })) } }) + const mark = (index: number, status: CallEntry["status"]) => + Effect.suspend(() => { + calls[index] = { ...calls[index]!, status } + return publish() + }) // One host function per MCP tool: gate on permission, dispatch to the native // MCP tool, and coerce the result into the { result, attachments? } envelope. // A failure (e.g. an MCP isError) fails the Effect, which the interpreter // surfaces as a catchable in-program error. - const callTool = (key: string, tool: AITool) => (input: unknown) => + const callTool = (entry: CatalogEntry) => (input: unknown) => Effect.gen(function* () { - yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) - calls.push(key) - const result = yield* Effect.tryPromise({ + yield* ctx.ask({ permission: entry.key, metadata: {}, patterns: ["*"], always: ["*"] }) + const index = calls.length + calls.push({ tool: entry.path, status: "running" }) + yield* publish() + return yield* Effect.tryPromise({ try: () => Promise.resolve( - tool.execute!(input ?? {}, { toolCallId: ctx.callID ?? key, abortSignal: ctx.abort, messages: [] }), + entry.tool.execute!(input ?? {}, { + toolCallId: ctx.callID ?? entry.key, + abortSignal: ctx.abort, + messages: [], + }), ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), - }) - return toEnvelope(result) + }).pipe( + Effect.tap(() => mark(index, "completed")), + Effect.tapError(() => mark(index, "error")), + Effect.map(toEnvelope), + ) }) // The Rune host-tool tree: per-server namespaces (`tools..`) @@ -514,7 +536,7 @@ export function define( namespace = {} tools[entry.server] = namespace } - namespace[entry.local] = callTool(entry.key, entry.tool) + namespace[entry.local] = callTool(entry) } const result = yield* Rune.execute({ diff --git a/packages/opencode/test/session/code-mode-integration.test.ts b/packages/opencode/test/session/code-mode-integration.test.ts index 01823cb9600b..58f0dd976de4 100644 --- a/packages/opencode/test/session/code-mode-integration.test.ts +++ b/packages/opencode/test/session/code-mode-integration.test.ts @@ -138,7 +138,7 @@ describe("code mode integration (real MCP server)", () => { test("calls a text tool and unwraps the result envelope", async () => { const out = await run("const r = await tools.fixtures.get_text({ name: 'world' }); return r.result") expect(out.output).toBe("hello world") - expect(out.metadata.toolCalls).toEqual(["fixtures_get_text"]) + expect(out.metadata.toolCalls).toEqual([{ tool: "fixtures.get_text", status: "completed" }]) expect(out.attachments).toBeUndefined() }) @@ -154,7 +154,10 @@ describe("code mode integration (real MCP server)", () => { return { total: second.result.sum } `) expect(JSON.parse(out.output)).toEqual({ total: 13 }) - expect(out.metadata.toolCalls).toEqual(["fixtures_add", "fixtures_add"]) + expect(out.metadata.toolCalls).toEqual([ + { tool: "fixtures.add", status: "completed" }, + { tool: "fixtures.add", status: "completed" }, + ]) }) test("forwards an image as an attachment when the whole result is returned", async () => { @@ -191,7 +194,7 @@ describe("code mode integration (real MCP server)", () => { `) expect(out.output).toBe("two shots") expect(out.attachments).toHaveLength(2) - expect(out.metadata.toolCalls.sort()).toEqual(["fixtures_screenshot", "fixtures_screenshot"]) + expect(out.metadata.toolCalls.map((c) => c.tool)).toEqual(["fixtures.screenshot", "fixtures.screenshot"]) }) test("propagates an MCP isError into the program as a catchable error", async () => { diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index ac6c212f4b69..93d04300ccc5 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -174,7 +174,7 @@ describe("code mode execute", () => { expect(seen).toEqual([{ name: "world" }]) expect(output.output).toBe("HELLO WORLD") - expect(output.metadata.toolCalls).toEqual(["greeter_hello"]) + expect(output.metadata.toolCalls).toEqual([{ tool: "greeter.hello", status: "completed" }]) }) test("exposes structured content as data and composes multiple calls", async () => { @@ -199,7 +199,10 @@ describe("code mode execute", () => { ) expect(JSON.parse(output.output)).toEqual({ total: 13 }) - expect(output.metadata.toolCalls).toEqual(["math_add", "math_add"]) + expect(output.metadata.toolCalls).toEqual([ + { tool: "math.add", status: "completed" }, + { tool: "math.add", status: "completed" }, + ]) }) test("runs tool calls in parallel with Promise.all", async () => { @@ -216,7 +219,8 @@ describe("code mode execute", () => { ) expect(output.output).toBe("12") - expect(output.metadata.toolCalls.sort()).toEqual(["echo_one", "echo_two"]) + expect(output.metadata.toolCalls.map((c) => c.tool).sort()).toEqual(["echo.one", "echo.two"]) + expect(output.metadata.toolCalls.every((c) => c.status === "completed")).toBe(true) }) test("returns a readable error when the program throws", async () => { @@ -260,6 +264,38 @@ describe("code mode execute", () => { expect(asked.map((req: any) => req.permission)).toEqual(["a_tool", "b_tool"]) }) + test("streams live per-call metadata as a call starts and finishes", async () => { + const snapshots: Array<{ toolCalls: { tool: string; status: string }[] }> = [] + const recordingCtx: Tool.Context = { + ...ctx, + metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)), + } + const tool = await build({ greeter_hello: mcpTool("hello", () => ({ content: [{ type: "text", text: "hi" }] })) }) + + await Effect.runPromise(tool.execute({ code: "await tools.greeter.hello({}); return 'done'" }, recordingCtx)) + + // The UI sees the call appear as running, then resolve to completed. + expect(snapshots).toContainEqual({ toolCalls: [{ tool: "greeter.hello", status: "running" }] }) + expect(snapshots).toContainEqual({ toolCalls: [{ tool: "greeter.hello", status: "completed" }] }) + }) + + test("marks a failed child call as error in the live metadata", async () => { + const snapshots: Array<{ toolCalls: { tool: string; status: string }[] }> = [] + const recordingCtx: Tool.Context = { + ...ctx, + metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)), + } + const tool = await build({ + bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "boom" }] })), + }) + + await Effect.runPromise( + tool.execute({ code: "try { await tools.bad.tool({}) } catch (e) { return 'caught' }" }, recordingCtx), + ) + + expect(snapshots).toContainEqual({ toolCalls: [{ tool: "bad.tool", status: "error" }] }) + }) + test("unit: toEnvelope wraps result and extracts media as attachments", () => { expect(toEnvelope({ structuredContent: { x: 1 }, content: [] })).toEqual({ result: { x: 1 } }) expect(toEnvelope({ content: [{ type: "text", text: "hi" }] })).toEqual({ result: "hi" }) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index f832174aa4f0..b56a4d54a78d 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1758,6 +1758,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess + + + @@ -2322,6 +2325,75 @@ export function formatCompletedSubagentDetail(toolcalls: number, duration: strin return `${formatSubagentToolcalls(toolcalls)} · ${duration}` } +type CodeCall = { tool: string; status: "running" | "completed" | "error" } + +function codeCalls(value: unknown): CodeCall[] { + if (!Array.isArray(value)) return [] + return value.filter( + (call): call is CodeCall => + !!call && + typeof call === "object" && + typeof (call as CodeCall).tool === "string" && + ["running", "completed", "error"].includes((call as CodeCall).status), + ) +} + +// The code-mode `execute` tool: a header with the run status, a live `↳` line per +// child tool call (sourced from streamed metadata, not a child session like Task), +// and the program source on demand. +function Execute(props: ToolProps) { + const { theme, syntax } = useTheme() + const isRunning = createMemo(() => props.part.state.status === "running") + const calls = createMemo(() => codeCalls(props.metadata.toolCalls)) + const code = createMemo(() => stringValue(props.input.code) ?? "") + const [expanded, setExpanded] = createSignal(false) + + const summary = createMemo(() => { + const count = calls().length + if (count === 0) return "Execute" + return `Execute · ${count} tool call${count === 1 ? "" : "s"}` + }) + + return ( + <> + setExpanded((value) => !value) : undefined} + > + {summary()} + + + {(call) => ( + + + ↳ {call.tool} + {call.status === "running" ? " …" : call.status === "error" ? " (failed)" : ""} + + + )} + + + + + ↳ {expanded() ? "Hide code" : "View code"} + + + + + + + + + + + + ) +} + function Edit(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() @@ -2578,6 +2650,7 @@ const toolDisplays = new Set([ "todowrite", "question", "skill", + "execute", ]) export function toolDisplay(tool: string) { From 2726a742030856d9c54d8f5ebab625d9aa20fe25 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 18:03:23 -0500 Subject: [PATCH 18/41] fix(tui): normalize execute tool styling --- packages/opencode/src/session/code-mode.ts | 8 +++--- packages/tui/src/routes/session/index.tsx | 32 +++++++++++----------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 41bacdb9c129..152b9a976851 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -484,8 +484,8 @@ export function define( const calls: CallEntry[] = [] // Stream the current call list to the UI. Sent on every status change so the // tool part shows each child call appearing and resolving while the program runs. - const publish = () => - ctx.metadata({ title: "Code mode", metadata: { toolCalls: calls.map((c) => ({ ...c })) } }) + const publish = (error?: boolean) => + ctx.metadata({ title: "execute", metadata: { toolCalls: calls.map((c) => ({ ...c })), ...(error ? { error } : {}) } }) const mark = (index: number, status: CallEntry["status"]) => Effect.suspend(() => { calls[index] = { ...calls[index]!, status } @@ -548,7 +548,7 @@ export function define( if (result.ok) { const { output, attachments } = fromReturn(result.value) return { - title: "Code mode", + title: "execute", metadata: { toolCalls: calls }, output, ...(attachments && attachments.length > 0 ? { attachments } : {}), @@ -560,7 +560,7 @@ export function define( ? "\nUse tools.$rune.search(query) to discover available tools." : "" return { - title: "Code mode", + title: "execute", metadata: { toolCalls: calls, error: true }, output: result.error.message + hint, } satisfies Tool.ExecuteResult diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index b56a4d54a78d..74f4fa74d594 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2325,42 +2325,42 @@ export function formatCompletedSubagentDetail(toolcalls: number, duration: strin return `${formatSubagentToolcalls(toolcalls)} · ${duration}` } -type CodeCall = { tool: string; status: "running" | "completed" | "error" } +type ExecuteCall = { tool: string; status: "running" | "completed" | "error" } -function codeCalls(value: unknown): CodeCall[] { +function executeCalls(value: unknown): ExecuteCall[] { if (!Array.isArray(value)) return [] return value.filter( - (call): call is CodeCall => + (call): call is ExecuteCall => !!call && typeof call === "object" && - typeof (call as CodeCall).tool === "string" && - ["running", "completed", "error"].includes((call as CodeCall).status), + typeof (call as ExecuteCall).tool === "string" && + ["running", "completed", "error"].includes((call as ExecuteCall).status), ) } -// The code-mode `execute` tool: a header with the run status, a live `↳` line per -// child tool call (sourced from streamed metadata, not a child session like Task), -// and the program source on demand. +// The `execute` tool streams child tool calls through metadata, not a child session like Task. function Execute(props: ToolProps) { const { theme, syntax } = useTheme() const isRunning = createMemo(() => props.part.state.status === "running") - const calls = createMemo(() => codeCalls(props.metadata.toolCalls)) + const calls = createMemo(() => executeCalls(props.metadata.toolCalls)) const code = createMemo(() => stringValue(props.input.code) ?? "") const [expanded, setExpanded] = createSignal(false) const summary = createMemo(() => { const count = calls().length - if (count === 0) return "Execute" - return `Execute · ${count} tool call${count === 1 ? "" : "s"}` + if (count === 0) return "execute" + return `execute · ${formatSubagentToolcalls(count)}` }) + const complete = createMemo(() => (props.part.state.status === "pending" ? false : summary())) + return ( <> setExpanded((value) => !value) : undefined} > @@ -2377,9 +2377,9 @@ function Execute(props: ToolProps) { )} - + setExpanded((value) => !value)}> - ↳ {expanded() ? "Hide code" : "View code"} + ↳ {expanded() ? "hide code" : "view code"} From 05b934616b44f9e3a0b4f7750619d93b9d9ac38f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 19:23:49 -0500 Subject: [PATCH 19/41] fix(tui): surface execute child call details --- packages/opencode/src/session/code-mode.ts | 70 +++++++++++----- packages/opencode/src/session/rune/rune.md | 69 ++++++++++++++++ .../session/code-mode-integration.test.ts | 7 +- .../opencode/test/session/code-mode.test.ts | 80 ++++++++++++++++--- packages/tui/src/routes/session/index.tsx | 71 ++++++++-------- 5 files changed, 231 insertions(+), 66 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 152b9a976851..40ae92dc2c10 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -26,7 +26,7 @@ export const Parameters = Schema.Struct({ /** One child tool call, surfaced live so the UI can render a per-call line that * updates as the program runs. `tool` is the dotted path (e.g. `github.create_issue`). */ -export type CallEntry = { tool: string; status: "running" | "completed" | "error" } +export type CallEntry = { tool: string; status: "running" | "completed" | "error"; input?: Record } type Metadata = { toolCalls: CallEntry[] @@ -67,9 +67,20 @@ const brief = (text: string | undefined, max = 120) => { return line.length > max ? line.slice(0, max - 1) + "…" : line } +function displayInput(input: unknown): Record | undefined { + if (input === null || input === undefined) return + if (typeof input === "object" && !Array.isArray(input)) { + const value = input as Record + if (Object.keys(value).length > 0) return value + return + } + return { input } +} + /** 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(".", "_") + * server/tool split is cosmetic, so `tools.a.b`, `tools["a.b"]`, `a/b`, and `a_b` + * all resolve to the same key — the model never has to guess the separator. */ +const toKey = (segments: readonly string[]) => segments.join("_").replace(/[./]/g, "_") /** * Group the flat `server_tool` catalog into per-server namespaces. `servers` are @@ -379,12 +390,17 @@ function searchTextFor(entry: CatalogEntry): string { return parts.join("\n").toLowerCase() } -/** Split a query into lowercased search terms, dropping empties and the `*` wildcard. */ +/** + * Split a query into lowercased search terms. camelCase boundaries are split + * (`resolveLibrary` -> `resolve library`) and `_ - . /` are treated as separators, + * so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all tokenize + * alike. Empties and the `*` wildcard are dropped. + */ const tokenize = (query: string) => query + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .toLowerCase() - .split(/[^a-z0-9_-]+/) - .map((term) => term.trim()) + .split(/[^a-z0-9]+/) .filter((term) => term.length > 0 && term !== "*") /** @@ -453,11 +469,14 @@ export function define( 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) + // Fuzzy "did you mean": rank the leaf name within its namespace, then fall + // back to a global search. Split only on namespace separators (`. _ /`) so a + // hyphenated tool name (e.g. `resolve-library-id`) stays one searchable leaf. + const segments = path.split(/[._/]+/).filter((s) => s.length > 0) + const leaf = segments.at(-1) ?? path + const namespace = segments.length > 1 ? segments[0] : undefined + const scoped = namespace ? rankTools(index, leaf, namespace, 5).items : [] + const suggestions = (scoped.length > 0 ? scoped : rankTools(index, leaf, undefined, 5).items).map((i) => i.path) return { error: { code: "tool_not_found", message: `No tool at '${path}'.`, suggestions } } } let inputSchema: unknown @@ -491,6 +510,17 @@ export function define( calls[index] = { ...calls[index]!, status } return publish() }) + const tracked = (tool: string, input: unknown, effect: Effect.Effect) => + Effect.gen(function* () { + const index = calls.length + const childInput = displayInput(input) + calls.push({ tool, status: "running", ...(childInput ? { input: childInput } : {}) }) + yield* publish() + return yield* effect.pipe( + Effect.tap(() => mark(index, "completed")), + Effect.tapError(() => mark(index, "error")), + ) + }) // One host function per MCP tool: gate on permission, dispatch to the native // MCP tool, and coerce the result into the { result, attachments? } envelope. @@ -499,10 +529,7 @@ export function define( const callTool = (entry: CatalogEntry) => (input: unknown) => Effect.gen(function* () { yield* ctx.ask({ permission: entry.key, metadata: {}, patterns: ["*"], always: ["*"] }) - const index = calls.length - calls.push({ tool: entry.path, status: "running" }) - yield* publish() - return yield* Effect.tryPromise({ + return yield* tracked(entry.path, input, Effect.tryPromise({ try: () => Promise.resolve( entry.tool.execute!(input ?? {}, { @@ -513,10 +540,8 @@ export function define( ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }).pipe( - Effect.tap(() => mark(index, "completed")), - Effect.tapError(() => mark(index, "error")), Effect.map(toEnvelope), - ) + )) }) // The Rune host-tool tree: per-server namespaces (`tools..`) @@ -525,8 +550,13 @@ export function define( // approve any child call. const tools: HostTools = { [RUNE_NS]: { - [SEARCH]: (query: unknown, options: unknown) => Effect.succeed(search(query, options)), - [DESCRIBE]: (path: unknown) => Effect.succeed(describeTool(path)), + [SEARCH]: (query: unknown, options: unknown) => + tracked( + "$rune.search", + { query, ...(typeof options === "object" && options !== null && !Array.isArray(options) ? options : {}) }, + Effect.succeed(search(query, options)), + ), + [DESCRIBE]: (path: unknown) => tracked("$rune.describe", { path }, Effect.succeed(describeTool(path))), }, } for (const entry of catalog) { diff --git a/packages/opencode/src/session/rune/rune.md b/packages/opencode/src/session/rune/rune.md index fdf68b0abd68..ef5a9b959f3a 100644 --- a/packages/opencode/src/session/rune/rune.md +++ b/packages/opencode/src/session/rune/rune.md @@ -89,3 +89,72 @@ not cumulative. (Code mode overrides `maxDataBytes`→10MB, `timeoutMs`→30s.) map fn; `NaN`/`Infinity` are not valid data values. - **No I/O** — no fetch, fs, timers, env, or any ambient capability. The only outside contact is host `tools`. + +# Code mode (discovery layer) + +Code mode (`session/code-mode.ts`) is a *consumer* of Rune, not part of the interpreter. It +exposes connected MCP tools to the program as `tools..(input)` and adds its own +discovery capabilities under `tools.$rune.*`. The design borrows from prior art (`executor`, +R. Sullivan) and deliberately tolerates the mistakes weaker models commonly make rather than +punishing them. The trade-offs below are intentional. + +## tools.$rune.search(query, { namespace?, limit? }) + +Ranked, in-memory search over the tool catalog, recomputed per call (no persistent index). +Returns `{ items: [{ path, description }], total }`. + +- **Weighted, tokenized scoring** (`rankTools`): per query term, exact path segment (20) > + path substring (8) > description (4) > any indexed text (2), summed across terms. The + indexed text is the path, the description, and each input parameter's name + description — + so a query can match a tool by one of its argument names. +- **camelCase / separator-resistant tokenizer** (`tokenize`): splits camelCase boundaries and + treats `_ - . /` as separators, so `library`, `resolveLibraryId`, and `resolve-library-id` + all tokenize alike. Models phrase queries inconsistently; this keeps recall up without the + query having to match a tool's exact casing or punctuation. +- **Namespace scoping**: optional `namespace` filters to one server. An empty query (or bare + `*`) lists everything alphabetically. `limit` caps `items` (default 25); `total` always + reports the full match count so the model knows when results were truncated. + +## tools.$rune.describe(path) + +Returns `{ path, description, signature, inputSchema, outputSchema? }` for one tool. + +- **Compact TS signature *and* raw JSON Schema.** `signature` is a compact TypeScript-ish form + (`renderType`), e.g. + `tools.github.create_issue(input: { title: string; body?: string }): Promise<{ result: unknown; attachments?: Attachment[] }>`. + We *also* return the raw `inputSchema` (and `outputSchema` when the server declares one). + Executor ships TS only and strips per-parameter descriptions to save tokens; we keep the + schema so the model still sees parameter docs, enums, and formats. `describe` is on-demand, + so the extra tokens are cheap — we chose completeness over compactness here. +- **Return type shown ahead of the call.** Every tool resolves to the uniform envelope + `{ result, attachments? }`, surfaced in `signature`. `result` is typed `unknown` unless the + server declares a structured `outputSchema` (many MCP servers return text, in which case + `result` is a plain string). + +## Path handling — separator-tolerant + +The flat catalog key is `server_tool`, but the model is never required to guess the +separator. `toKey` normalizes `. / _` to the same key, so `tools.context7.resolve-library-id`, +`describe('context7/resolve-library-id')`, and `describe('context7_resolve-library-id')` all +resolve to the same tool. A slash-vs-dot mismatch previously made `describe` silently miss +(returning a soft error the model then destructured into `{}`); normalizing the separator +removes that whole failure mode. + +## Errors are soft, with "did you mean" — never thrown + +`tools.$rune.search` and `tools.$rune.describe` never throw. An unknown `describe(path)` +returns `{ error: { code: 'tool_not_found', message, suggestions } }`. Suggestions come from a +fuzzy fallback: rank the *leaf* name within its namespace, then fall back to a global search, +returning real callable paths (e.g. `context7/resolve-library` → suggests +`context7.resolve-library-id`). This matches executor and avoids derailing a whole program +over one typo — the model branches on `result.error` and retries with a suggestion. (Tool +*calls* on a genuinely unknown path still surface as a catchable in-program error via Rune's +`UnknownCapability`; only the discovery helpers return soft errors.) + +## Deliberately NOT adopted from executor (yet) + +- **`console` capture** — executor surfaces a `console.log` buffer in its result envelope. + Rune has no `console` (see "What is missing") and the interpreter is frozen, so this is + deferred to separate interpreter work rather than faked here. +- **TS-only `describe`** — we keep JSON Schema alongside the TS signature for the parameter + docs/enums executor drops (above). diff --git a/packages/opencode/test/session/code-mode-integration.test.ts b/packages/opencode/test/session/code-mode-integration.test.ts index 58f0dd976de4..324693c160f8 100644 --- a/packages/opencode/test/session/code-mode-integration.test.ts +++ b/packages/opencode/test/session/code-mode-integration.test.ts @@ -133,12 +133,13 @@ describe("code mode integration (real MCP server)", () => { 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") + expect(out.metadata.toolCalls).toEqual([{ tool: "$rune.search", status: "completed", input: { query: "screenshot" } }]) }) test("calls a text tool and unwraps the result envelope", async () => { const out = await run("const r = await tools.fixtures.get_text({ name: 'world' }); return r.result") expect(out.output).toBe("hello world") - expect(out.metadata.toolCalls).toEqual([{ tool: "fixtures.get_text", status: "completed" }]) + expect(out.metadata.toolCalls).toEqual([{ tool: "fixtures.get_text", status: "completed", input: { name: "world" } }]) expect(out.attachments).toBeUndefined() }) @@ -155,8 +156,8 @@ describe("code mode integration (real MCP server)", () => { `) expect(JSON.parse(out.output)).toEqual({ total: 13 }) expect(out.metadata.toolCalls).toEqual([ - { tool: "fixtures.add", status: "completed" }, - { tool: "fixtures.add", status: "completed" }, + { tool: "fixtures.add", status: "completed", input: { a: 1, b: 2 } }, + { tool: "fixtures.add", status: "completed", input: { a: 3, b: 10 } }, ]) }) diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 93d04300ccc5..23ec5e96bb4b 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -132,6 +132,9 @@ describe("code mode execute", () => { 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"]) + expect(searched.metadata.toolCalls).toEqual([ + { tool: "$rune.search", status: "completed", input: { query: "issue", namespace: "github" } }, + ]) const described = await Effect.runPromise( tool.execute({ code: "return await tools.$rune.describe('github.create_issue')" }, ctx), @@ -141,9 +144,34 @@ describe("code mode execute", () => { expect(desc.signature).toBe( "tools.github.create_issue(input: { title: string; body?: string }): Promise<{ result: unknown; attachments?: Attachment[] }>", ) + expect(described.metadata.toolCalls).toEqual([ + { tool: "$rune.describe", status: "completed", input: { path: "github.create_issue" } }, + ]) 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") + expect(missing.metadata.toolCalls).toEqual([ + { tool: "$rune.describe", status: "completed", input: { path: "github.nope" } }, + ]) + }) + + test("describe resolves a tool path regardless of separator (dot, slash, or underscore)", async () => { + const tool = await build({ "context7_resolve-library-id": mcpTool("resolve-library-id", () => "") }) + for (const path of ["context7.resolve-library-id", "context7/resolve-library-id", "context7_resolve-library-id"]) { + const described = await Effect.runPromise(tool.execute({ code: `return await tools.$rune.describe(${JSON.stringify(path)})` }, ctx)) + expect(JSON.parse(described.output).path).toBe("context7.resolve-library-id") + } + }) + + test("describe suggests the real tool for a mistyped path (did-you-mean)", async () => { + const tool = await build({ "context7_resolve-library-id": mcpTool("resolve-library-id", () => "") }) + // Wrong leaf within the right namespace falls back to a namespace-scoped search. + const missing = await Effect.runPromise( + tool.execute({ code: "return await tools.$rune.describe('context7/resolve-library')" }, ctx), + ) + const error = JSON.parse(missing.output).error + expect(error.code).toBe("tool_not_found") + expect(error.suggestions).toContain("context7.resolve-library-id") }) test("groups multi-underscore server names by longest matching prefix", () => { @@ -174,7 +202,7 @@ describe("code mode execute", () => { expect(seen).toEqual([{ name: "world" }]) expect(output.output).toBe("HELLO WORLD") - expect(output.metadata.toolCalls).toEqual([{ tool: "greeter.hello", status: "completed" }]) + expect(output.metadata.toolCalls).toEqual([{ tool: "greeter.hello", status: "completed", input: { name: "world" } }]) }) test("exposes structured content as data and composes multiple calls", async () => { @@ -200,8 +228,8 @@ describe("code mode execute", () => { expect(JSON.parse(output.output)).toEqual({ total: 13 }) expect(output.metadata.toolCalls).toEqual([ - { tool: "math.add", status: "completed" }, - { tool: "math.add", status: "completed" }, + { tool: "math.add", status: "completed", input: { a: 1, b: 2 } }, + { tool: "math.add", status: "completed", input: { a: 3, b: 10 } }, ]) }) @@ -265,22 +293,54 @@ describe("code mode execute", () => { }) test("streams live per-call metadata as a call starts and finishes", async () => { - const snapshots: Array<{ toolCalls: { tool: string; status: string }[] }> = [] + const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record }[] }> = [] const recordingCtx: Tool.Context = { ...ctx, metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)), } const tool = await build({ greeter_hello: mcpTool("hello", () => ({ content: [{ type: "text", text: "hi" }] })) }) - await Effect.runPromise(tool.execute({ code: "await tools.greeter.hello({}); return 'done'" }, recordingCtx)) + await Effect.runPromise(tool.execute({ code: "await tools.greeter.hello({ name: 'Ada' }); return 'done'" }, recordingCtx)) // The UI sees the call appear as running, then resolve to completed. - expect(snapshots).toContainEqual({ toolCalls: [{ tool: "greeter.hello", status: "running" }] }) - expect(snapshots).toContainEqual({ toolCalls: [{ tool: "greeter.hello", status: "completed" }] }) + expect(snapshots).toContainEqual({ toolCalls: [{ tool: "greeter.hello", status: "running", input: { name: "Ada" } }] }) + expect(snapshots).toContainEqual({ toolCalls: [{ tool: "greeter.hello", status: "completed", input: { name: "Ada" } }] }) + }) + + test("streams discovery helpers with the same per-call metadata shape", async () => { + const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record }[] }> = [] + const recordingCtx: Tool.Context = { + ...ctx, + metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)), + } + const tool = await build({ github_create_issue: mcpTool("create_issue", () => "") }) + + await Effect.runPromise( + tool.execute( + { + code: ` + await tools.$rune.search('issue', { namespace: 'github' }) + await tools.$rune.describe('github.create_issue') + return 'done' + `, + }, + recordingCtx, + ), + ) + + expect(snapshots).toContainEqual({ + toolCalls: [{ tool: "$rune.search", status: "running", input: { query: "issue", namespace: "github" } }], + }) + expect(snapshots).toContainEqual({ + toolCalls: [ + { tool: "$rune.search", status: "completed", input: { query: "issue", namespace: "github" } }, + { tool: "$rune.describe", status: "completed", input: { path: "github.create_issue" } }, + ], + }) }) test("marks a failed child call as error in the live metadata", async () => { - const snapshots: Array<{ toolCalls: { tool: string; status: string }[] }> = [] + const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record }[] }> = [] const recordingCtx: Tool.Context = { ...ctx, metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)), @@ -290,10 +350,10 @@ describe("code mode execute", () => { }) await Effect.runPromise( - tool.execute({ code: "try { await tools.bad.tool({}) } catch (e) { return 'caught' }" }, recordingCtx), + tool.execute({ code: "try { await tools.bad.tool({ reason: 'test' }) } catch (e) { return 'caught' }" }, recordingCtx), ) - expect(snapshots).toContainEqual({ toolCalls: [{ tool: "bad.tool", status: "error" }] }) + expect(snapshots).toContainEqual({ toolCalls: [{ tool: "bad.tool", status: "error", input: { reason: "test" } }] }) }) test("unit: toEnvelope wraps result and extracts media as attachments", () => { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 74f4fa74d594..8502a967f1aa 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2325,26 +2325,29 @@ export function formatCompletedSubagentDetail(toolcalls: number, duration: strin return `${formatSubagentToolcalls(toolcalls)} · ${duration}` } -type ExecuteCall = { tool: string; status: "running" | "completed" | "error" } +type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record } function executeCalls(value: unknown): ExecuteCall[] { if (!Array.isArray(value)) return [] - return value.filter( - (call): call is ExecuteCall => - !!call && - typeof call === "object" && - typeof (call as ExecuteCall).tool === "string" && - ["running", "completed", "error"].includes((call as ExecuteCall).status), - ) + return value.flatMap((call) => { + const item = recordValue(call) + const tool = stringValue(item?.tool) + const status = stringValue(item?.status) + if (!tool || !status || !["running", "completed", "error"].includes(status)) return [] + return [{ tool, status: status as ExecuteCall["status"], input: recordValue(item?.input) }] + }) } // The `execute` tool streams child tool calls through metadata, not a child session like Task. function Execute(props: ToolProps) { - const { theme, syntax } = useTheme() + const ctx = use() + const { theme } = useTheme() const isRunning = createMemo(() => props.part.state.status === "running") const calls = createMemo(() => executeCalls(props.metadata.toolCalls)) - const code = createMemo(() => stringValue(props.input.code) ?? "") - const [expanded, setExpanded] = createSignal(false) + const output = createMemo(() => stripAnsi(props.output?.trim() ?? "")) + const hasRuntimeError = createMemo(() => props.metadata.error === true) + const outputPreview = createMemo(() => collapseToolOutput(output(), 4, 4 * Math.max(20, ctx.width - 6)).output) + const showOutput = createMemo(() => output() && (hasRuntimeError() || calls().length === 0)) const summary = createMemo(() => { const count = calls().length @@ -2357,37 +2360,39 @@ function Execute(props: ToolProps) { return ( <> setExpanded((value) => !value) : undefined} > {summary()} - {(call) => ( - - - ↳ {call.tool} - {call.status === "running" ? " …" : call.status === "error" ? " (failed)" : ""} - - - )} + {(call) => { + const args = input(call.input ?? {}) + return ( + + + ↳ {call.tool} + {args ? ` ${args}` : ""} + {call.status === "running" ? " …" : call.status === "error" ? " (failed)" : ""} + + + ) + }} - - setExpanded((value) => !value)}> - - ↳ {expanded() ? "hide code" : "view code"} - - - - - - - - + + + + {(line, index) => ( + + {index() === 0 ? "↳ " : " "} + {line} + + )} + From 5085a13b0e5c56471c6feb235459482df182f23f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 22:34:23 -0500 Subject: [PATCH 20/41] feat(opencode): render code-mode types as TypeScript, not JSON Schema tools.$rune.describe now returns { path, description, signature, input, output? } with input/output as TypeScript instead of raw JSON Schema. Rewrite renderType into a total, cycle-safe JSON-Schema -> TS renderer: resolve local $refs (collapsing recursive refs to their name), render enums/const as literals, anyOf/oneOf and nullable type arrays as unions, allOf as an intersection (unwrapping the Pydantic allOf:[{$ref}] shape), tuples, and additionalProperties as an index signature. Pretty mode emits JSDoc (multi-line preserved) for described fields, with */ neutralized. Falls back to any/object and never throws; depth-capped. Docs updated; code-mode/renderType test coverage expanded. --- packages/opencode/src/session/code-mode.ts | 172 +++++++++++++--- packages/opencode/src/session/rune/rune.md | 46 +++-- .../session/code-mode-integration.test.ts | 5 +- .../opencode/test/session/code-mode.test.ts | 191 +++++++++++++++++- 4 files changed, 369 insertions(+), 45 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 40ae92dc2c10..42e45b8d5687 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -115,44 +115,161 @@ export function groupByServer( const access = (segment: string) => (IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`) +/** An object property name, bare when it is a valid identifier, else quoted. */ +const propKey = (name: string) => (IDENTIFIER.test(name) ? name : JSON.stringify(name)) + +/** Join type strings into a union, de-duplicating members and preserving order. An + * empty set of members (e.g. an empty `enum`/`anyOf`) is `never`, never the empty string. */ +const asUnion = (parts: string[]) => { + const unique = [...new Set(parts)] + return unique.length > 0 ? unique.join(" | ") : "never" +} + +/** Resolve a local JSON-Schema `$ref` (`#/$defs/Foo`) against the document root. + * Returns undefined for external or unresolvable refs. */ +function resolveRef(ref: string, root: JSONSchema7 | undefined): JSONSchema7 | undefined { + if (!root || !ref.startsWith("#/")) return undefined + let node: unknown = root + for (const raw of ref.slice(2).split("/")) { + const segment = raw.replace(/~1/g, "/").replace(/~0/g, "~") + if (!node || typeof node !== "object") return undefined + node = (node as Record)[segment] + } + return node && typeof node === "object" ? (node as JSONSchema7) : undefined +} + +const MAX_DEPTH = 8 + +/** Options for {@link renderType}. `root` anchors `$ref` resolution (defaults to the + * top-level schema); `pretty` switches from a single-line type to an indented, + * JSDoc-annotated block used by `describe`. */ +export type RenderOptions = { root?: JSONSchema7; pretty?: boolean } + /** - * Render a JSON Schema as a compact TypeScript-ish type string for model-facing - * signatures. Depth-limited and total — never throws, falls back to `any`/`object`. + * Render a JSON Schema as a TypeScript type string for model-facing signatures. + * Total (never throws — falls back to `any`/`object`) and cycle-safe: local `$ref`s + * are inlined, self-referential ones collapse to the ref name. Compact by default + * (single line, no docs); `pretty` produces an indented block with `/** … *\/` docs + * on described fields. Handles enums, `const`, `anyOf`/`oneOf` unions, `allOf` + * intersections (the common Pydantic `allOf: [{ $ref }]` shape), nullable `type` + * arrays, tuples, and `additionalProperties`. */ -export function renderType(def: JSONSchema7 | boolean | undefined, depth = 0): string { +export function renderType( + def: JSONSchema7 | boolean | undefined, + options: RenderOptions = {}, + depth = 0, + seen: ReadonlySet = new Set(), +): string { if (!def || typeof def === "boolean") return "any" - if (Array.isArray(def.enum)) return def.enum.map((value) => JSON.stringify(value)).join(" | ") + // Absolute recursion ceiling. Object/array recursion increments `depth`, and so do + // the union/nullable branches below, so this bounds every recursion path — including + // pure-union structural cycles that the `$ref` `seen` guard cannot see. Keeps the + // "never throws" contract even for pathological (non-JSON-transport) input. + if (depth > MAX_DEPTH) return "any" + const root = options.root ?? def + const opts: RenderOptions = { ...options, root } + + if (typeof def.$ref === "string") { + const target = resolveRef(def.$ref, root) + const name = def.$ref.split("/").pop() || "any" + if (!target) return "any" + if (seen.has(target)) return name // recursive type: reference by name rather than loop + return renderType(target, opts, depth, new Set([...seen, target])) + } + if (Array.isArray(def.enum)) return asUnion(def.enum.map((value) => JSON.stringify(value))) if (def.const !== undefined) return JSON.stringify(def.const) - if (Array.isArray(def.anyOf ?? def.oneOf)) { - const alts = (def.anyOf ?? def.oneOf)! - return alts.map((alt) => renderType(alt as JSONSchema7, depth)).join(" | ") + + // allOf = intersection. The dominant Pydantic/FastMCP shape is `allOf: [{ $ref }]` + // with a sibling description/default, so a single member renders as just that member; + // any base `properties` on `def` itself are intersected in. + if (Array.isArray(def.allOf) && def.allOf.length > 0) { + const base = def.properties || def.additionalProperties !== undefined ? renderObject(def, opts, depth, seen) : undefined + const members = def.allOf.map((member) => renderType(member as JSONSchema7, opts, depth + 1, seen)) + const parts = [...(base ? [base] : []), ...members].filter((part) => part !== "any") + return parts.length === 0 ? "any" : parts.length === 1 ? parts[0]! : parts.join(" & ") + } + + // Nullable / multi-type: `["string","null"]` -> `string | null` (don't drop members). + if (Array.isArray(def.type)) { + return asUnion(def.type.map((type) => renderType({ ...def, type }, opts, depth + 1, seen))) } - const type = Array.isArray(def.type) ? def.type[0] : def.type - switch (type) { + + switch (def.type) { case "integer": return "number" case "string": case "number": case "boolean": case "null": - return type + return def.type case "array": { const items = Array.isArray(def.items) ? def.items[0] : def.items - return `${renderType(items as JSONSchema7 | undefined, depth + 1)}[]` + const inner = renderType(items as JSONSchema7 | undefined, opts, depth + 1, seen) + return /[ |&]/.test(inner) ? `(${inner})[]` : `${inner}[]` } } - if (type === "object" || def.properties) { - if (depth >= 3) return "object" - const props = def.properties ?? {} - const required = new Set(Array.isArray(def.required) ? def.required : []) - const fields = Object.entries(props).map( - ([name, value]) => `${name}${required.has(name) ? "" : "?"}: ${renderType(value as JSONSchema7, depth + 1)}`, - ) - return fields.length > 0 ? `{ ${fields.join("; ")} }` : "object" + + if (def.type === "object" || def.properties || def.additionalProperties !== undefined) { + return renderObject(def, opts, depth, seen) } + + // anyOf / oneOf union — checked after object handling so a base object paired with a + // `require one of` anyOf still renders its properties instead of collapsing to a union. + const union = def.anyOf ?? def.oneOf + if (Array.isArray(union)) return asUnion(union.map((alt) => renderType(alt as JSONSchema7, opts, depth + 1, seen))) return "any" } +/** + * Format a schema `description` as a JSDoc comment at the given indent, preserving + * multi-line text (a single line stays `/** … *\/`; multiple lines become a `*`-prefixed + * block). `*\/` is neutralized so a description can't close the comment early, and blank + * leading/trailing/edge lines are trimmed. Returns "" (with a trailing newline when + * non-empty) so callers can prepend it directly to the field line. + */ +function jsdoc(description: string | undefined, pad: string): string { + if (!description) return "" + const lines = description + .replaceAll("*/", "* /") + .split("\n") + .map((line) => line.replace(/\s+$/, "")) + while (lines.length > 0 && lines[0]!.trim() === "") lines.shift() + while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop() + if (lines.length === 0) return "" + if (lines.length === 1) return `${pad}/** ${lines[0]} */\n` + const body = lines.map((line) => `${pad} *${line ? ` ${line}` : ""}`).join("\n") + return `${pad}/**\n${body}\n${pad} */\n` +} + +function renderObject( + def: JSONSchema7, + opts: RenderOptions, + depth: number, + seen: ReadonlySet, +): string { + const props = (def.properties ?? {}) as Record + const names = Object.keys(props) + const additional = def.additionalProperties + const indexType = + additional === true ? "any" : additional && typeof additional === "object" ? renderType(additional, opts, depth + 1, seen) : undefined + if (names.length === 0) return indexType ? `{ [key: string]: ${indexType} }` : "object" + if (depth >= MAX_DEPTH) return "object" + + const required = new Set(Array.isArray(def.required) ? def.required : []) + const field = (name: string) => `${propKey(name)}${required.has(name) ? "" : "?"}: ${renderType(props[name], opts, depth + 1, seen)}` + + if (!opts.pretty) { + const fields = names.map(field) + if (indexType) fields.push(`[key: string]: ${indexType}`) + return `{ ${fields.join("; ")} }` + } + + const pad = " ".repeat(depth + 1) + const lines = names.map((name) => `${jsdoc(props[name]?.description, pad)}${pad}${field(name)}`) + if (indexType) lines.push(`${pad}[key: string]: ${indexType}`) + return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}` +} + function inputType(tool: AITool): string { try { const schema = asSchema(tool.inputSchema).jsonSchema as JSONSchema7 | undefined @@ -199,7 +316,7 @@ export function describe(groups: Map): string { "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? }`", + "- `await tools.$rune.describe(path)` -> `{ path, description, signature, input, output? }` (types as TypeScript)", "- 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? }`.", @@ -479,18 +596,23 @@ export function define( const suggestions = (scoped.length > 0 ? scoped : rankTools(index, leaf, undefined, 5).items).map((i) => i.path) return { error: { code: "tool_not_found", message: `No tool at '${path}'.`, suggestions } } } - let inputSchema: unknown + // Everything the model sees is TypeScript: `signature` is the compact one-line + // call form; `input`/`output` are the detailed types (multi-line, with JSDoc for + // any described fields and literal unions for enums) that raw JSON Schema used to + // carry. `output` is present only when the server declares an outputSchema. + let input = "unknown" try { - inputSchema = asSchema(entry.tool.inputSchema).jsonSchema + const schema = asSchema(entry.tool.inputSchema).jsonSchema as JSONSchema7 | undefined + input = renderType(schema, { pretty: true }) } catch { - inputSchema = undefined + input = "unknown" } return { path: entry.path, description: entry.description, signature: signatureFor(entry), - inputSchema, - ...(entry.outputSchema ? { outputSchema: entry.outputSchema } : {}), + input, + ...(entry.outputSchema ? { output: renderType(entry.outputSchema, { pretty: true }) } : {}), } } diff --git a/packages/opencode/src/session/rune/rune.md b/packages/opencode/src/session/rune/rune.md index ef5a9b959f3a..04268e00b749 100644 --- a/packages/opencode/src/session/rune/rune.md +++ b/packages/opencode/src/session/rune/rune.md @@ -94,9 +94,8 @@ not cumulative. (Code mode overrides `maxDataBytes`→10MB, `timeoutMs`→30s.) Code mode (`session/code-mode.ts`) is a *consumer* of Rune, not part of the interpreter. It exposes connected MCP tools to the program as `tools..(input)` and adds its own -discovery capabilities under `tools.$rune.*`. The design borrows from prior art (`executor`, -R. Sullivan) and deliberately tolerates the mistakes weaker models commonly make rather than -punishing them. The trade-offs below are intentional. +discovery capabilities under `tools.$rune.*`. The design deliberately tolerates the mistakes +weaker models commonly make rather than punishing them. The trade-offs below are intentional. ## tools.$rune.search(query, { namespace?, limit? }) @@ -117,15 +116,28 @@ Returns `{ items: [{ path, description }], total }`. ## tools.$rune.describe(path) -Returns `{ path, description, signature, inputSchema, outputSchema? }` for one tool. +Returns `{ path, description, signature, input, output? }` for one tool. **Everything is +TypeScript — no raw JSON Schema is ever surfaced to the model.** -- **Compact TS signature *and* raw JSON Schema.** `signature` is a compact TypeScript-ish form - (`renderType`), e.g. +- **Compact signature + detailed TS types.** `signature` is the one-line call form, e.g. `tools.github.create_issue(input: { title: string; body?: string }): Promise<{ result: unknown; attachments?: Attachment[] }>`. - We *also* return the raw `inputSchema` (and `outputSchema` when the server declares one). - Executor ships TS only and strips per-parameter descriptions to save tokens; we keep the - schema so the model still sees parameter docs, enums, and formats. `describe` is on-demand, - so the extra tokens are cheap — we chose completeness over compactness here. + `input` (and `output`, when the server declares an `outputSchema`) are the *detailed* types + rendered by `renderType` in pretty mode: an indented block with `/** … */` JSDoc on described + fields and literal unions for enums. This carries everything the raw JSON Schema used to — + parameter docs, enums, required-ness — now expressed as TypeScript rather than a schema blob. + A field's `description` becomes a JSDoc comment (single-line `/** … */`, or a multi-line + block, preserved as written) because a bare type has nowhere else to hold it; `*/` in a + description is neutralized so it cannot close the comment early. `describe` is on-demand, so + keeping the docs costs nothing on the hot path. +- **`renderType` is a total, cycle-safe JSON-Schema → TS renderer.** Local `$ref`s + (`#/$defs/…`) are resolved against the document root; a self-referential ref collapses to its + name rather than looping. It renders enums/`const` as literals, `anyOf`/`oneOf` and nullable + `type` arrays (`["string","null"]` → `string | null`) as unions, `allOf` as an intersection + (unwrapping the common Pydantic `allOf: [{ $ref }]` shape), tuples, and + `additionalProperties` as an index signature. It never throws — unknown shapes fall back to + `any`/`object`, and depth is capped. (Rune's own Effect-schema renderer in `rune/tool.ts`, + `renderSchema`, is separate and has known gaps — no `$ref` cycle guard, and unions containing + a number collapse to `number`; code mode does not use it.) - **Return type shown ahead of the call.** Every tool resolves to the uniform envelope `{ result, attachments? }`, surfaced in `signature`. `result` is typed `unknown` unless the server declares a structured `outputSchema` (many MCP servers return text, in which case @@ -146,15 +158,13 @@ removes that whole failure mode. returns `{ error: { code: 'tool_not_found', message, suggestions } }`. Suggestions come from a fuzzy fallback: rank the *leaf* name within its namespace, then fall back to a global search, returning real callable paths (e.g. `context7/resolve-library` → suggests -`context7.resolve-library-id`). This matches executor and avoids derailing a whole program -over one typo — the model branches on `result.error` and retries with a suggestion. (Tool +`context7.resolve-library-id`). This avoids derailing a whole program over one typo — the model +branches on `result.error` and retries with a suggestion. (Tool *calls* on a genuinely unknown path still surface as a catchable in-program error via Rune's `UnknownCapability`; only the discovery helpers return soft errors.) -## Deliberately NOT adopted from executor (yet) +## Not done yet -- **`console` capture** — executor surfaces a `console.log` buffer in its result envelope. - Rune has no `console` (see "What is missing") and the interpreter is frozen, so this is - deferred to separate interpreter work rather than faked here. -- **TS-only `describe`** — we keep JSON Schema alongside the TS signature for the parameter - docs/enums executor drops (above). +- **`console` capture** — a program has no way to surface `console.log` output. Rune has no + `console` (see "What is missing") and the interpreter is frozen, so buffering log output into + the result envelope is deferred to separate interpreter work rather than faked here. diff --git a/packages/opencode/test/session/code-mode-integration.test.ts b/packages/opencode/test/session/code-mode-integration.test.ts index 324693c160f8..41a24a577c7a 100644 --- a/packages/opencode/test/session/code-mode-integration.test.ts +++ b/packages/opencode/test/session/code-mode-integration.test.ts @@ -120,7 +120,10 @@ describe("code mode integration (real MCP server)", () => { expect(desc.signature).toBe( "tools.fixtures.add(input: { a: number; b: number }): Promise<{ result: { sum: number }; attachments?: Attachment[] }>", ) - expect(desc.outputSchema).toBeDefined() + // describe returns TypeScript for the input/output types, not raw JSON Schema. + expect(desc.input).toBe("{\n a: number\n b: number\n}") + expect(desc.output).toBe("{\n sum: number\n}") + expect(desc.outputSchema).toBeUndefined() }) test("describe falls back to result: unknown when no outputSchema is declared", async () => { diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 23ec5e96bb4b..0d3fc1ca9e61 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -6,6 +6,7 @@ import { formatValue, groupByServer, rankTools, + renderType, toEnvelope, type SearchEntry, } from "@/session/code-mode" @@ -415,7 +416,28 @@ describe("code mode execute", () => { expect(desc.signature).toBe( "tools.weather.current(input: { city?: string }): Promise<{ result: { tempC: number; summary?: string }; attachments?: Attachment[] }>", ) - expect(desc.outputSchema).toBeDefined() + // describe now returns the return shape as pretty TypeScript, not raw JSON Schema. + expect(desc.output).toBe("{\n tempC: number\n summary?: string\n}") + expect(desc.outputSchema).toBeUndefined() + }) + + test("describe returns the input type as TypeScript with JSDoc and enum literals", async () => { + const tool = await build({ + docs_resolve: mcpTool("resolve", () => "", { + type: "object", + properties: { + library: { type: "string", description: "The library name to resolve" }, + kind: { enum: ["react", "vue"] }, + }, + required: ["library"], + }), + }) + const described = await Effect.runPromise(tool.execute({ code: "return await tools.$rune.describe('docs.resolve')" }, ctx)) + const desc = JSON.parse(described.output) + expect(desc.input).toBe( + '{\n /** The library name to resolve */\n library: string\n kind?: "react" | "vue"\n}', + ) + expect(desc.inputSchema).toBeUndefined() }) test("forwards attachments from a returned tool result and drops them when only .result is returned", async () => { @@ -520,3 +542,170 @@ describe("rankTools", () => { expect(rankTools(entries, "nonexistent")).toEqual({ items: [], total: 0 }) }) }) + +describe("renderType", () => { + test("renders primitives, integers, and arrays", () => { + expect(renderType({ type: "string" })).toBe("string") + expect(renderType({ type: "integer" })).toBe("number") + expect(renderType({ type: "array", items: { type: "string" } })).toBe("string[]") + }) + + test("renders enums and const as literal types", () => { + expect(renderType({ enum: ["a", "b", "c"] })).toBe('"a" | "b" | "c"') + expect(renderType({ const: 42 })).toBe("42") + }) + + test("renders a nullable type array as a union (does not drop null)", () => { + expect(renderType({ type: ["string", "null"] })).toBe("string | null") + }) + + test("parenthesizes a union inside an array", () => { + expect(renderType({ type: "array", items: { type: ["string", "null"] } })).toBe("(string | null)[]") + }) + + test("renders additionalProperties as an index signature", () => { + expect(renderType({ type: "object", additionalProperties: { type: "number" } })).toBe("{ [key: string]: number }") + expect(renderType({ type: "object", additionalProperties: true })).toBe("{ [key: string]: any }") + }) + + test("resolves a local $ref against the document root", () => { + const schema = { + type: "object", + properties: { node: { $ref: "#/$defs/Node" } }, + required: ["node"], + $defs: { Node: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } }, + } as any + expect(renderType(schema)).toBe("{ node: { id: string } }") + }) + + test("collapses a self-referential $ref to its name instead of looping", () => { + const schema = { + $defs: { Node: { type: "object", properties: { next: { $ref: "#/$defs/Node" } } } }, + $ref: "#/$defs/Node", + } as any + // Must terminate; the recursive position falls back to the ref name. + expect(renderType(schema)).toBe("{ next?: Node }") + }) + + test("pretty mode emits an indented block with JSDoc for described fields", () => { + const schema = { + type: "object", + properties: { + name: { type: "string", description: "The library name to resolve" }, + kind: { enum: ["lib", "app"] }, + }, + required: ["name"], + } as any + expect(renderType(schema, { pretty: true })).toBe( + '{\n /** The library name to resolve */\n name: string\n kind?: "lib" | "app"\n}', + ) + }) + + test("renders anyOf / oneOf as a union", () => { + expect(renderType({ anyOf: [{ type: "string" }, { type: "number" }] })).toBe("string | number") + expect(renderType({ oneOf: [{ const: "a" }, { const: "b" }] })).toBe('"a" | "b"') + }) + + test("empty enum / anyOf / type arrays render as never, not an empty string", () => { + expect(renderType({ enum: [] })).toBe("never") + expect(renderType({ anyOf: [] })).toBe("never") + expect(renderType({ type: [] as any })).toBe("never") + }) + + test("renders a tuple's first item type", () => { + expect(renderType({ type: "array", items: [{ type: "string" }, { type: "number" }] as any })).toBe("string[]") + }) + + test("combines named properties with an additionalProperties index signature", () => { + const schema = { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + additionalProperties: { type: "number" }, + } as any + expect(renderType(schema)).toBe("{ id: string; [key: string]: number }") + }) + + test("quotes non-identifier property names", () => { + const schema = { type: "object", properties: { "content-type": { type: "string" } } } as any + expect(renderType(schema)).toBe('{ "content-type"?: string }') + }) + + test("nests pretty objects with increasing indentation", () => { + const schema = { + type: "object", + properties: { outer: { type: "object", properties: { inner: { type: "string" } }, required: ["inner"] } }, + required: ["outer"], + } as any + expect(renderType(schema, { pretty: true })).toBe("{\n outer: {\n inner: string\n }\n}") + }) + + test("resolves mutually recursive $refs without looping", () => { + const schema = { + $ref: "#/$defs/A", + $defs: { + A: { type: "object", properties: { b: { $ref: "#/$defs/B" } } }, + B: { type: "object", properties: { a: { $ref: "#/$defs/A" } } }, + }, + } as any + // A -> B -> A: the second A is on the resolution path, so it collapses to its name. + expect(renderType(schema)).toBe("{ b?: { a?: A } }") + }) + + test("preserves a multi-line description as a multi-line JSDoc block", () => { + const schema = { + type: "object", + properties: { + query: { type: "string", description: "The search query.\nSupports globs.\n\nExamples: *.ts" }, + }, + required: ["query"], + } as any + expect(renderType(schema, { pretty: true })).toBe( + "{\n /**\n * The search query.\n * Supports globs.\n *\n * Examples: *.ts\n */\n query: string\n}", + ) + }) + + test("neutralizes a comment terminator inside a JSDoc description", () => { + const schema = { type: "object", properties: { x: { type: "string", description: "danger */ oops" } } } as any + const out = renderType(schema, { pretty: true }) + expect(out).toContain("/** danger * / oops */") + expect(out).not.toContain("*/ oops") + }) + + test("is total on a self-referential union (never throws)", () => { + const a: any = { anyOf: [] } + a.anyOf.push(a) // structural cycle with no $ref + expect(() => renderType(a)).not.toThrow() + }) + + test("unwraps the Pydantic allOf: [{ $ref }] shape with sibling description", () => { + const schema = { + type: "object", + properties: { + config: { allOf: [{ $ref: "#/$defs/Config" }], description: "the config block" }, + }, + required: ["config"], + $defs: { Config: { type: "object", properties: { level: { type: "integer" } }, required: ["level"] } }, + } as any + expect(renderType(schema)).toBe("{ config: { level: number } }") + }) + + test("renders multi-member allOf as an intersection", () => { + const schema = { + allOf: [ + { type: "object", properties: { a: { type: "string" } }, required: ["a"] }, + { type: "object", properties: { b: { type: "number" } }, required: ["b"] }, + ], + } as any + expect(renderType(schema)).toBe("{ a: string } & { b: number }") + }) + + test("renders a base object's properties even when it also carries a require-one-of anyOf", () => { + const schema = { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + anyOf: [{ required: ["a"] }, { required: ["b"] }], + } as any + expect(renderType(schema)).toBe("{ a?: string; b?: string }") + }) +}) From c16bba8bc033b4e2b40bd7a65d811562ece7b883 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 1 Jul 2026 10:54:42 -0500 Subject: [PATCH 21/41] feat(opencode): JSDoc tags, Result return hints, and opaque attachments - describe/preview now surface each tool's return type as Result (alias defined once in the prompt); the inline preview shows it too, so the model sees a tool's result shape without a describe round-trip. T is the declared outputSchema else unknown, with prose telling the model to inspect an unknown result before assuming fields. - renderType pretty mode emits JSDoc tags a TS type can't express: @default, @format, @deprecated, @minItems/@maxItems (multi-line descriptions preserved). - Attachments are now opaque handles: a tool's media becomes { type:'file', id, mime, filename?, bytes } with no inline bytes. Real bytes stay host-side in a per-execution attachmentTable; the program propagates or drops a handle (return it to surface the image to the model+user) but cannot read or leak the base64. Documents the divergence from prior art in rune.md. - Records the throw/catch (not errors-as-values) decision in rune.md. --- packages/opencode/src/session/code-mode.ts | 200 +++++++++++++----- packages/opencode/src/session/rune/rune.md | 47 +++- .../session/code-mode-integration.test.ts | 22 +- .../opencode/test/session/code-mode.test.ts | 93 ++++++-- 4 files changed, 274 insertions(+), 88 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 42e45b8d5687..880a4f013acb 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -34,14 +34,23 @@ type Metadata = { } /** - * A model-facing attachment: the same shape used for both child tool results and - * the program's final `return`, and identical to a session `FilePart` (minus the - * ids), so it lowers 1:1 into `Tool.ExecuteResult.attachments`. + * A real attachment: identical to a session `FilePart` (minus the ids) and carrying + * the actual bytes (`url`, often a base64 `data:` URL), so it lowers 1:1 into + * `Tool.ExecuteResult.attachments`. This never crosses into the sandbox — the program + * only ever sees the opaque {@link AttachmentHandle}. */ export type Attachment = NonNullable[number] +/** + * The opaque, model-facing view of an attachment: metadata only, no bytes. A program + * can inspect `mime`/`filename`/`bytes`, propagate the handle (return it to show the + * user) or drop it, but can NOT read or leak the contents — so a stray `return`/log + * can never dump a base64 blob back into the conversation. + */ +export type AttachmentHandle = { type: "file"; id: string; mime: string; filename?: string; bytes?: number } + /** The envelope every tool call resolves to, and the shape a program should `return`. */ -export type Envelope = { result: unknown; attachments?: Attachment[] } +export type Envelope = { result: unknown; attachments?: AttachmentHandle[] } const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ const SEARCH = "search" @@ -220,19 +229,38 @@ export function renderType( return "any" } +/** Schema constraints that a TypeScript type can't express natively but a model + * benefits from, surfaced as JSDoc tags (`@default`, `@format`, `@deprecated`, …). */ +function docTags(schema: JSONSchema7 | boolean | undefined): string[] { + if (!schema || typeof schema === "boolean") return [] + // `deprecated` is a later JSON-Schema draft than the `ai` JSONSchema7 type models. + const s = schema as JSONSchema7 & { deprecated?: boolean } + const tags: string[] = [] + if (s.deprecated === true) tags.push("@deprecated") + if (s.default !== undefined) { + try { + tags.push(`@default ${JSON.stringify(s.default)}`) + } catch { + // unserializable default: skip rather than emit a broken tag + } + } + if (typeof s.format === "string") tags.push(`@format ${s.format}`) + if (typeof s.minItems === "number") tags.push(`@minItems ${s.minItems}`) + if (typeof s.maxItems === "number") tags.push(`@maxItems ${s.maxItems}`) + return tags +} + /** - * Format a schema `description` as a JSDoc comment at the given indent, preserving - * multi-line text (a single line stays `/** … *\/`; multiple lines become a `*`-prefixed - * block). `*\/` is neutralized so a description can't close the comment early, and blank - * leading/trailing/edge lines are trimmed. Returns "" (with a trailing newline when - * non-empty) so callers can prepend it directly to the field line. + * Format a schema `description` plus `tags` as a JSDoc comment at the given indent, + * preserving multi-line text (a single line stays `/** … *\/`; multiple lines become a + * `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and + * blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so + * callers can prepend it directly to the field line. */ -function jsdoc(description: string | undefined, pad: string): string { - if (!description) return "" - const lines = description - .replaceAll("*/", "* /") - .split("\n") - .map((line) => line.replace(/\s+$/, "")) +function jsdoc(description: string | undefined, tags: string[], pad: string): string { + const lines = [...(description ? description.split("\n") : []), ...tags].map((line) => + line.replaceAll("*/", "* /").replace(/\s+$/, ""), + ) while (lines.length > 0 && lines[0]!.trim() === "") lines.shift() while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop() if (lines.length === 0) return "" @@ -265,7 +293,7 @@ function renderObject( } const pad = " ".repeat(depth + 1) - const lines = names.map((name) => `${jsdoc(props[name]?.description, pad)}${pad}${field(name)}`) + const lines = names.map((name) => `${jsdoc(props[name]?.description, docTags(props[name]), pad)}${pad}${field(name)}`) if (indexType) lines.push(`${pad}[key: string]: ${indexType}`) return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}` } @@ -280,19 +308,23 @@ function inputType(tool: AITool): string { } } -/** The return type the model sees for any tool: the structured `outputSchema` (when - * the MCP server declares one) wrapped in the result envelope, else `unknown`. */ -const returnType = (outputSchema: JSONSchema7 | undefined) => - `Promise<{ result: ${outputSchema ? renderType(outputSchema) : "unknown"}; attachments?: Attachment[] }>` +/** The `T` in `Result`: the structured `outputSchema` (when the MCP server declares + * one), else `unknown` — an untyped result has no guaranteed shape and must be inspected, + * not assumed. `Result` itself is defined once in the tool description prose. */ +const resultType = (outputSchema: JSONSchema7 | undefined) => (outputSchema ? renderType(outputSchema) : "unknown") + +/** The full, awaited call type shown by `tools.$rune.describe`. */ +const returnType = (outputSchema: JSONSchema7 | undefined) => `Promise>` const signatureFor = (entry: CatalogEntry) => `tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)}): ${returnType(entry.outputSchema)}` -/** The compact, directly-callable signature for the inline preview: the call path - * plus its input type, but without the (uniform) `Promise<{ result, attachments? }>` - * return — that full typed form is reserved for `tools.$rune.describe`. */ +/** The directly-callable signature for the inline preview. Unlike the full `describe` + * form it drops the uniform `Promise<…>` wrapper (calls are always awaited) but DOES + * show the awaited `Result` — so the model sees each tool's return shape without a + * discovery round-trip. */ const previewSignature = (entry: CatalogEntry) => - `tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)})` + `tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)}): Result<${resultType(entry.outputSchema)}>` /** * Character budget for the inline signature preview in the tool description. All @@ -317,16 +349,24 @@ export function describe(groups: Map): string { "from your MCP servers):", "- `await tools.$rune.search(query, { namespace?, limit? })` -> `{ items: [{ path, description }], total }`", "- `await tools.$rune.describe(path)` -> `{ path, description, signature, input, output? }` (types as TypeScript)", - "- 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? }`.", - "`result` is the structured data; `attachments` are media as `{ type: 'file', mime, url }` — ordinary", - "values you can read and route (e.g. feed one tool's attachment into another tool's input). Whichever", - "attachments you return are shown to the user as media; only `result` becomes text, so nothing in the", - "sandbox (attachment bytes included) re-enters the conversation unless you put it in `result`.", + "Call a tool by its path: `await tools..(input)`. Every call — and your final `return` —", + "uses the same envelope: `type Result = { result: T; attachments?: Attachment[] }`. The signatures", + "below (and `tools.$rune.describe`) show each tool's `T` as its return type.", + "", + "`result` (the `T`) is the tool's own payload. It is typed `unknown` unless the server declares an output", + "schema — an `unknown` result has NO guaranteed shape, so inspect it (e.g. `return` it to see it, or read", + "it defensively) before assuming any fields.", + "", + "`attachments` are files a tool produced (an image, a document, …), given to you as references you hold", + "but don't read inline: `type Attachment = { type: 'file'; mime: string; filename?: string; bytes?: number }`.", + "To actually SEE a file — e.g. look at a screenshot before deciding your next step — include it in what you", + "`return` (e.g. `return { result: summary, attachments: shot.attachments }`): returned attachments come back", + "into the conversation as real viewable images/files, so both YOU (on your next turn) and the user can see", + "them. Omit an attachment to discard it. You route whole attachment handles; you don't read their raw bytes.", "", - "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.$rune.search('', { namespace })` to list a namespace.", + "Only what you `return` re-enters the conversation — `result` becomes text; everything else in the sandbox", + "stays there. Compose multiple calls in one program and `return` the final value. Use `tools.$rune.search('', { namespace })` to list a namespace.", ] if (groups.size === 0) { lines.push("", "No MCP servers are currently connected.") @@ -388,15 +428,69 @@ const lastSegment = (uri: string) => { const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}` +/** Decoded byte length of a `data:` URL's base64 payload, or undefined for a + * non-data URL (e.g. an external `resource_link`) whose size we don't know. */ +function dataUrlBytes(url: string): number | undefined { + if (!url.startsWith("data:")) return undefined + const comma = url.indexOf(",") + if (comma === -1) return undefined + const base64 = url.slice(comma + 1) + if (base64.length === 0) return 0 + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0 + return Math.max(0, Math.floor((base64.length * 3) / 4) - padding) +} + +/** Functions for converting between real attachments and the opaque handles the + * sandbox sees. See {@link attachmentTable}. */ +export type AttachmentTable = { + /** Register a real attachment, returning the opaque handle to hand to the program. */ + seal: (attachment: Attachment) => AttachmentHandle + /** Resolve a handle the program returned back to its real attachment, or undefined + * if it isn't one this table issued (a fabricated or stale handle is dropped). */ + resolve: (handle: unknown) => Attachment | undefined +} + +/** + * A per-execution table that keeps real attachment bytes host-side and only ever + * exposes opaque handles to the sandbox. The bytes never enter the program's context, + * so a program cannot read or accidentally re-emit them; on `return`, a propagated + * handle is looked up here to recover the real attachment for the user. + */ +export function attachmentTable(): AttachmentTable { + const real = new Map() + let seq = 0 + return { + seal(attachment) { + const id = `att_${++seq}` + real.set(id, attachment) + const bytes = dataUrlBytes(attachment.url) + return { + type: "file", + id, + mime: attachment.mime, + ...(attachment.filename ? { filename: attachment.filename } : {}), + ...(bytes !== undefined ? { bytes } : {}), + } + }, + resolve(handle) { + if (!handle || typeof handle !== "object") return undefined + const id = (handle as Record).id + return typeof id === "string" ? real.get(id) : undefined + }, + } +} + /** * Reduce an MCP tool result to the `{ result, attachments? }` envelope. `result` * is the structured content (or joined text); media blocks (image/audio/resource) - * become attachments. Lenient — never throws on unexpected shapes. + * become opaque attachment handles via `seal` (the bytes stay host-side). Lenient — + * never throws on unexpected shapes. */ -export function toEnvelope(result: unknown): Envelope { +export function toEnvelope(result: unknown, seal: AttachmentTable["seal"]): Envelope { if (result === null || typeof result !== "object") return { result } const record = result as { structuredContent?: unknown; content?: unknown } - const attachments: Attachment[] = [] + const attachments: AttachmentHandle[] = [] + const push = (attachment: Attachment) => attachments.push(seal(attachment)) const text: string[] = [] const content = Array.isArray(record.content) ? record.content : [] for (const item of content) { @@ -409,7 +503,7 @@ export function toEnvelope(result: unknown): Envelope { case "image": case "audio": if (typeof block.data === "string" && typeof block.mimeType === "string") { - attachments.push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) }) + push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) }) } break case "resource": { @@ -418,7 +512,7 @@ export function toEnvelope(result: unknown): Envelope { const mime = typeof res.mimeType === "string" ? res.mimeType : "application/octet-stream" const uri = typeof res.uri === "string" ? res.uri : undefined if (typeof res.blob === "string") { - attachments.push({ type: "file", mime, url: dataUrl(mime, res.blob), filename: uri ? lastSegment(uri) : undefined }) + push({ type: "file", mime, url: dataUrl(mime, res.blob), filename: uri ? lastSegment(uri) : undefined }) } else if (typeof res.text === "string") { text.push(res.text) } @@ -427,7 +521,7 @@ export function toEnvelope(result: unknown): Envelope { } case "resource_link": if (typeof block.uri === "string") { - attachments.push({ + push({ type: "file", mime: typeof block.mimeType === "string" ? block.mimeType : "application/octet-stream", url: block.uri, @@ -461,21 +555,22 @@ export function formatValue(value: unknown): string { } } -const isAttachment = (value: unknown): value is Attachment => { - if (!value || typeof value !== "object") return false - const a = value as Record - return a.type === "file" && typeof a.mime === "string" && typeof a.url === "string" -} - /** - * Lower the program's return value into model-facing output + attachments. The - * value is treated as a `{ result, attachments? }` envelope when it has a `result` - * key; otherwise the whole value is the result. Attachments are model-curated. + * Lower the program's return value into model-facing output + attachments. The value + * is treated as a `{ result, attachments? }` envelope when it has a `result` key; + * otherwise the whole value is the result. Attachments are model-curated: each returned + * handle is resolved back to its real bytes via `resolve`; anything that isn't a handle + * this run issued is dropped. */ -export function fromReturn(value: unknown): { output: string; attachments?: Attachment[] } { +export function fromReturn( + value: unknown, + resolve: AttachmentTable["resolve"], +): { output: string; attachments?: Attachment[] } { if (value !== null && typeof value === "object" && "result" in value) { const env = value as { result: unknown; attachments?: unknown } - const attachments = Array.isArray(env.attachments) ? env.attachments.filter(isAttachment) : [] + const attachments = Array.isArray(env.attachments) + ? env.attachments.map(resolve).filter((a): a is Attachment => a !== undefined) + : [] return attachments.length > 0 ? { output: formatValue(env.result), attachments } : { output: formatValue(env.result) } @@ -623,6 +718,9 @@ export function define( parameters: Parameters, execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { const calls: CallEntry[] = [] + // Real attachment bytes stay in this table for the life of the call; the sandbox + // only ever handles opaque references to them (see attachmentTable). + const files = attachmentTable() // Stream the current call list to the UI. Sent on every status change so the // tool part shows each child call appearing and resolving while the program runs. const publish = (error?: boolean) => @@ -662,7 +760,7 @@ export function define( ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }).pipe( - Effect.map(toEnvelope), + Effect.map((raw) => toEnvelope(raw, files.seal)), )) }) @@ -698,7 +796,7 @@ export function define( }) if (result.ok) { - const { output, attachments } = fromReturn(result.value) + const { output, attachments } = fromReturn(result.value, files.resolve) return { title: "execute", metadata: { toolCalls: calls }, diff --git a/packages/opencode/src/session/rune/rune.md b/packages/opencode/src/session/rune/rune.md index 04268e00b749..a25dc84f2598 100644 --- a/packages/opencode/src/session/rune/rune.md +++ b/packages/opencode/src/session/rune/rune.md @@ -120,7 +120,9 @@ Returns `{ path, description, signature, input, output? }` for one tool. **Every TypeScript — no raw JSON Schema is ever surfaced to the model.** - **Compact signature + detailed TS types.** `signature` is the one-line call form, e.g. - `tools.github.create_issue(input: { title: string; body?: string }): Promise<{ result: unknown; attachments?: Attachment[] }>`. + `tools.github.create_issue(input: { title: string; body?: string }): Promise>`, + where `type Result = { result: T; attachments?: Attachment[] }` is defined once in the tool + description prose so signatures stay short. `input` (and `output`, when the server declares an `outputSchema`) are the *detailed* types rendered by `renderType` in pretty mode: an indented block with `/** … */` JSDoc on described fields and literal unions for enums. This carries everything the raw JSON Schema used to — @@ -138,10 +140,32 @@ TypeScript — no raw JSON Schema is ever surfaced to the model.** `any`/`object`, and depth is capped. (Rune's own Effect-schema renderer in `rune/tool.ts`, `renderSchema`, is separate and has known gaps — no `$ref` cycle guard, and unions containing a number collapse to `number`; code mode does not use it.) -- **Return type shown ahead of the call.** Every tool resolves to the uniform envelope - `{ result, attachments? }`, surfaced in `signature`. `result` is typed `unknown` unless the - server declares a structured `outputSchema` (many MCP servers return text, in which case - `result` is a plain string). +- **Return type shown ahead of the call — in the preview too.** Every tool resolves to + `Result`, where `T` is the structured `outputSchema` when the server declares one, else + `unknown`. The `T` is surfaced not just by `describe` but in the budgeted inline preview in the + tool description (`tools.x.y(input: …): Result`), so the model sees a tool's result shape + without a discovery round-trip. `unknown` is deliberate — an untyped result has no guaranteed + shape (many MCP servers return plain text), so the prose directs the model to inspect it (e.g. + `return` it) before assuming fields, rather than pretending a shape we can't verify. + +## Attachments are opaque handles — bytes never enter the sandbox + +A tool's media (image/audio/resource content blocks) becomes an `attachments` array on the +result envelope, but the program only ever sees an **opaque handle**, not the bytes: +`type Attachment = { type: 'file'; id: string; mime: string; filename?: string; bytes?: number }`. +The real bytes (a base64 `data:` URL) are kept host-side in a per-execution `attachmentTable` +keyed by `id` (`code-mode.ts`); the handle carries only metadata. The program can inspect +`mime`/`bytes`, **propagate** a handle (return it under `attachments` to show the user), or +**drop** it — but it cannot read or re-emit the contents. On `return`, each propagated handle is +resolved back to its real attachment via the table; a fabricated or stale handle resolves to +nothing and is dropped. + +This is a deliberate divergence from the prior art we studied (both expose the base64 directly +and lean on prompt guidance plus output truncation). Making the handle opaque means a careless +`return`/log **cannot** dump a base64 blob back into the conversation — the leak is structurally +impossible rather than merely discouraged. The trade-off: a program can no longer read attachment +bytes to route them into another tool's input; if that need arises it would be an explicit host +call (e.g. a `readAttachment(handle)`), not the always-on default. Not implemented yet. ## Path handling — separator-tolerant @@ -152,7 +176,18 @@ resolve to the same tool. A slash-vs-dot mismatch previously made `describe` sil (returning a soft error the model then destructured into `{}`); normalizing the separator removes that whole failure mode. -## Errors are soft, with "did you mean" — never thrown +## Tool-call errors throw; discovery errors are soft + +A **tool call** that fails (an MCP `isError`, a transport failure, or a call to a path that +doesn't exist) throws inside the program, so the model uses ordinary `try`/`catch` — the same +control flow it already writes for any async call. We deliberately do *not* wrap results in an +`{ ok, error }` value: a uniform success envelope (`{ result, attachments? }`) plus normal +exceptions is simpler to reason about and to type than forcing every call site to branch on a +discriminated union. An uncaught tool error fails the whole `execute` run and is reported back. + +The **discovery helpers** are the exception — see below. + +## Discovery errors are soft, with "did you mean" — never thrown `tools.$rune.search` and `tools.$rune.describe` never throw. An unknown `describe(path)` returns `{ error: { code: 'tool_not_found', message, suggestions } }`. Suggestions come from a diff --git a/packages/opencode/test/session/code-mode-integration.test.ts b/packages/opencode/test/session/code-mode-integration.test.ts index 41a24a577c7a..d23ab1ab588e 100644 --- a/packages/opencode/test/session/code-mode-integration.test.ts +++ b/packages/opencode/test/session/code-mode-integration.test.ts @@ -118,7 +118,7 @@ describe("code mode integration (real MCP server)", () => { const desc = JSON.parse(out.output) expect(desc.path).toBe("fixtures.add") expect(desc.signature).toBe( - "tools.fixtures.add(input: { a: number; b: number }): Promise<{ result: { sum: number }; attachments?: Attachment[] }>", + "tools.fixtures.add(input: { a: number; b: number }): Promise>", ) // describe returns TypeScript for the input/output types, not raw JSON Schema. expect(desc.input).toBe("{\n a: number\n b: number\n}") @@ -129,7 +129,7 @@ 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.$rune.describe('fixtures.get_text')") const desc = JSON.parse(out.output) - expect(desc.signature).toContain("Promise<{ result: unknown; attachments?: Attachment[] }>") + expect(desc.signature).toContain("Promise>") }) test("search finds a tool by keyword", async () => { @@ -169,20 +169,24 @@ describe("code mode integration (real MCP server)", () => { expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: `data:image/png;base64,${PNG}` }]) }) - test("an attachment's bytes are readable and routable in code, not opaque", async () => { - // The data: URL carrying the base64 payload is an ordinary string in the - // sandbox: the program can inspect it (and thus route it into another tool). + test("an attachment is an opaque handle: metadata only, no readable bytes", async () => { + // The program sees mime/bytes but NOT the data — a stray return can't leak base64. const out = await run(` const shot = await tools.fixtures.screenshot({}) - const url = shot.attachments[0].url - return { result: { mime: shot.attachments[0].mime, isDataUrl: url.startsWith('data:'), bytes: url.length } } + const a = shot.attachments[0] + return { result: { mime: a.mime, hasUrl: 'url' in a, hasData: 'data' in a, bytes: a.bytes, keys: Object.keys(a).sort() } } `) expect(JSON.parse(out.output)).toEqual({ mime: "image/png", - isDataUrl: true, - bytes: `data:image/png;base64,${PNG}`.length, + hasUrl: false, + hasData: false, + bytes: Buffer.from(PNG, "base64").byteLength, + keys: ["bytes", "id", "mime", "type"], }) + // Returning the handle inside `.result` (not as an attachment) surfaces no media + // and — crucially — carries no base64, so nothing large re-enters the conversation. expect(out.attachments).toBeUndefined() + expect(out.output).not.toContain(PNG) }) test("drops media when only .result is returned", async () => { diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 0d3fc1ca9e61..9b0e449edd85 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { Parameters, + attachmentTable, define, describe as describeTools, formatValue, @@ -87,10 +88,13 @@ describe("code mode execute", () => { expect(description).toContain("This is the COMPLETE list") expect(description).toContain("- github (2 tools)") expect(description).toContain("- linear (1 tool)") - // Tools are previewed inline as compact, directly-callable input signatures. - expect(description).toContain("tools.github.create_issue(input: { title: string; body?: string })") - expect(description).toContain("tools.linear.search(input: object)") - // ...but not the full Promise return — that comes from tools.$rune.describe. + // Tools are previewed inline as directly-callable signatures that now include the + // awaited return type (Result) so the model sees the result shape up front. + expect(description).toContain("tools.github.create_issue(input: { title: string; body?: string }): Result") + expect(description).toContain("tools.linear.search(input: object): Result") + // The Result envelope alias is defined once in the prose. + expect(description).toContain("type Result = { result: T; attachments?: Attachment[] }") + // ...but the preview drops the uniform Promise<…> wrapper — that full form comes from describe. expect(description).not.toContain("): Promise<") }) @@ -143,7 +147,7 @@ describe("code mode execute", () => { const desc = JSON.parse(described.output) expect(desc.path).toBe("github.create_issue") expect(desc.signature).toBe( - "tools.github.create_issue(input: { title: string; body?: string }): Promise<{ result: unknown; attachments?: Attachment[] }>", + "tools.github.create_issue(input: { title: string; body?: string }): Promise>", ) expect(described.metadata.toolCalls).toEqual([ { tool: "$rune.describe", status: "completed", input: { path: "github.create_issue" } }, @@ -357,29 +361,43 @@ describe("code mode execute", () => { expect(snapshots).toContainEqual({ toolCalls: [{ tool: "bad.tool", status: "error", input: { reason: "test" } }] }) }) - test("unit: toEnvelope wraps result and extracts media as attachments", () => { - expect(toEnvelope({ structuredContent: { x: 1 }, content: [] })).toEqual({ result: { x: 1 } }) - expect(toEnvelope({ content: [{ type: "text", text: "hi" }] })).toEqual({ result: "hi" }) - expect(toEnvelope("raw")).toEqual({ result: "raw" }) + test("unit: toEnvelope wraps result and extracts media as opaque attachment handles", () => { + const table = attachmentTable() + expect(toEnvelope({ structuredContent: { x: 1 }, content: [] }, table.seal)).toEqual({ result: { x: 1 } }) + expect(toEnvelope({ content: [{ type: "text", text: "hi" }] }, table.seal)).toEqual({ result: "hi" }) + expect(toEnvelope("raw", table.seal)).toEqual({ result: "raw" }) - // image/audio blocks become data-URL file attachments; text stays in result - expect( - toEnvelope({ + // image/audio blocks become OPAQUE handles (mime/bytes, NO url/data); text stays in result + const withImage = toEnvelope( + { content: [ { type: "text", text: "see image" }, { type: "image", data: "AAAA", mimeType: "image/png" }, ], - }), - ).toEqual({ - result: "see image", - attachments: [{ type: "file", mime: "image/png", url: "data:image/png;base64,AAAA" }], + }, + table.seal, + ) + expect(withImage.result).toBe("see image") + expect(withImage.attachments).toEqual([{ type: "file", id: "att_1", mime: "image/png", bytes: 3 }]) + // The handle exposes no bytes, but resolves back to the real attachment host-side. + expect((withImage.attachments![0] as any).url).toBeUndefined() + expect(table.resolve(withImage.attachments![0])).toEqual({ + type: "file", + mime: "image/png", + url: "data:image/png;base64,AAAA", }) - // media-only result has an undefined result but still surfaces the attachment - expect(toEnvelope({ content: [{ type: "image", data: "BBBB", mimeType: "image/jpeg" }] })).toEqual({ - result: undefined, - attachments: [{ type: "file", mime: "image/jpeg", url: "data:image/jpeg;base64,BBBB" }], - }) + // media-only result: undefined result, still surfaces the handle + const mediaOnly = toEnvelope({ content: [{ type: "image", data: "BBBB", mimeType: "image/jpeg" }] }, table.seal) + expect(mediaOnly.result).toBeUndefined() + expect(mediaOnly.attachments).toEqual([{ type: "file", id: "att_2", mime: "image/jpeg", bytes: 3 }]) + }) + + test("unit: attachmentTable resolve drops fabricated or stale handles", () => { + const table = attachmentTable() + expect(table.resolve({ type: "file", id: "att_999", mime: "image/png" })).toBeUndefined() + expect(table.resolve({ type: "file" })).toBeUndefined() + expect(table.resolve("nope")).toBeUndefined() }) test("unit: formatValue", () => { @@ -414,7 +432,7 @@ describe("code mode execute", () => { 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[] }>", + "tools.weather.current(input: { city?: string }): Promise>", ) // describe now returns the return shape as pretty TypeScript, not raw JSON Schema. expect(desc.output).toBe("{\n tempC: number\n summary?: string\n}") @@ -665,6 +683,37 @@ describe("renderType", () => { ) }) + test("emits JSDoc tags for schema constraints TypeScript can't express", () => { + const schema = { + type: "object", + properties: { + when: { type: "string", format: "date-time", default: "now", description: "start time" }, + legacy: { type: "boolean", deprecated: true }, + tags: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 5 }, + }, + required: ["when"], + } as any + expect(renderType(schema, { pretty: true })).toBe( + [ + "{", + " /**", + " * start time", + ' * @default "now"', + " * @format date-time", + " */", + " when: string", + " /** @deprecated */", + " legacy?: boolean", + " /**", + " * @minItems 1", + " * @maxItems 5", + " */", + " tags?: string[]", + "}", + ].join("\n"), + ) + }) + test("neutralizes a comment terminator inside a JSDoc description", () => { const schema = { type: "object", properties: { x: { type: "string", description: "danger */ oops" } } } as any const out = renderType(schema, { pretty: true }) From 2d9015c30d8a46621481c3437149a380bb610a3f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 1 Jul 2026 11:02:01 -0500 Subject: [PATCH 22/41] fix(tui): simplify execute running state --- packages/tui/src/routes/session/index.tsx | 27 +++++++++-------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 8502a967f1aa..13167e6d6a89 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2342,52 +2342,47 @@ function executeCalls(value: unknown): ExecuteCall[] { function Execute(props: ToolProps) { const ctx = use() const { theme } = useTheme() - const isRunning = createMemo(() => props.part.state.status === "running") + const isLoading = createMemo(() => props.part.state.status === "pending" || props.part.state.status === "running") const calls = createMemo(() => executeCalls(props.metadata.toolCalls)) const output = createMemo(() => stripAnsi(props.output?.trim() ?? "")) const hasRuntimeError = createMemo(() => props.metadata.error === true) const outputPreview = createMemo(() => collapseToolOutput(output(), 4, 4 * Math.max(20, ctx.width - 6)).output) const showOutput = createMemo(() => output() && (hasRuntimeError() || calls().length === 0)) + const detailPadding = 3 + INLINE_TOOL_ICON_WIDTH - const summary = createMemo(() => { - const count = calls().length - if (count === 0) return "execute" - return `execute · ${formatSubagentToolcalls(count)}` - }) - - const complete = createMemo(() => (props.part.state.status === "pending" ? false : summary())) + const complete = createMemo(() => (props.part.state.status === "pending" ? false : "execute")) return ( <> - {summary()} + execute {(call) => { const args = input(call.input ?? {}) return ( - - + + ↳ {call.tool} {args ? ` ${args}` : ""} - {call.status === "running" ? " …" : call.status === "error" ? " (failed)" : ""} + {call.status === "error" ? " (failed)" : ""} ) }} - + {(line, index) => ( - + {index() === 0 ? "↳ " : " "} {line} From 064c34b25a62fd4916f88ce7eb5bd14aba4fa7e7 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 1 Jul 2026 11:36:37 -0500 Subject: [PATCH 23/41] feat(opencode): capture console output and surface it to the model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - console.log/warn/error/info/debug are now a real Rune interpreter builtin: a seeded global that formats its args (strings verbatim, objects/arrays as JSON, space-joined) and appends a line to a per-run LogCollector. It is not a tool call (spends no tool-call budget), returns undefined, and any other member throws. Formatting is charged to maxOperations and total captured output is bounded by maxAuditBytes so a logging loop can't exhaust memory. - The collector is shared by reference with parallel interpreter forks (like the operation budget) and lives in execute()'s outer scope, so logs are surfaced on every ExecuteResult path — success, thrown error, and timeout. - ExecuteResult (and its schema) gain a logs field; code mode appends captured logs to the model-facing output as a trailing '[level] message' section on both the success and error paths (withLogs). Logs go to the model only. - Documents console in rune.md; drops the now-satisfied 'not done yet' entry. --- packages/opencode/src/session/code-mode.ts | 18 ++- packages/opencode/src/session/rune/rune.md | 39 +++++-- packages/opencode/src/session/rune/rune.ts | 104 +++++++++++++++++- .../session/code-mode-integration.test.ts | 34 ++++++ .../opencode/test/session/code-mode.test.ts | 12 ++ 5 files changed, 187 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 880a4f013acb..0f5c45939736 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -3,7 +3,7 @@ import { asSchema, type Tool as AITool, type JSONSchema7 } from "ai" import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import { Effect, Schema } from "effect" import { Rune } from "./rune/rune" -import type { ExecutionLimits } from "./rune/rune" +import type { ExecutionLimits, LogEntry } from "./rune/rune" import type { HostTools } from "./rune/tool-runtime" export const CODE_MODE_TOOL = "execute" @@ -544,6 +544,18 @@ export function toEnvelope(result: unknown, seal: AttachmentTable["seal"]): Enve return attachments.length > 0 ? { result: value, attachments } : { result: value } } +/** + * Append captured `console.*` output to the model-facing text as a trailing `Logs:` section, + * so a program's diagnostics ride back alongside its result (and errors). Each line is + * `[level] message`; returns the text unchanged when nothing was logged. This is the sandbox's + * only stdout-like channel — it goes to the model, not the user. + */ +export function withLogs(output: string, logs: ReadonlyArray): string { + if (logs.length === 0) return output + const section = "Logs:\n" + logs.map((entry) => `[${entry.level}] ${entry.message}`).join("\n") + return output.length > 0 ? `${output}\n\n${section}` : section +} + /** Coerce the program's return value to model-facing text without ever failing on shape. */ export function formatValue(value: unknown): string { if (typeof value === "string") return value @@ -800,7 +812,7 @@ export function define( return { title: "execute", metadata: { toolCalls: calls }, - output, + output: withLogs(output, result.logs), ...(attachments && attachments.length > 0 ? { attachments } : {}), } satisfies Tool.ExecuteResult } @@ -812,7 +824,7 @@ export function define( return { title: "execute", metadata: { toolCalls: calls, error: true }, - output: result.error.message + hint, + output: withLogs(result.error.message + hint, result.logs), } satisfies Tool.ExecuteResult }), }), diff --git a/packages/opencode/src/session/rune/rune.md b/packages/opencode/src/session/rune/rune.md index a25dc84f2598..f3529890993b 100644 --- a/packages/opencode/src/session/rune/rune.md +++ b/packages/opencode/src/session/rune/rune.md @@ -31,9 +31,10 @@ implemented throws. errors. ### Result -`{ ok: true, value, toolCalls }` or `{ ok: false, error: { kind, message, location?, -suggestions? }, toolCalls }`. `value` is the program's `return`. Uncaught `throw` -becomes `ok: false`. +`{ ok: true, value, toolCalls, logs }` or `{ ok: false, error: { kind, message, location?, +suggestions? }, toolCalls, logs }`. `value` is the program's `return`. Uncaught `throw` +becomes `ok: false`. `logs` is the captured `console.*` output (see below), present on +every path — including timeout and failure — so logs emitted before a crash survive. ### Limits (`ExecutionLimits`, enforced; defaults) | Limit | Default | Bounds | @@ -53,8 +54,12 @@ not cumulative. (Code mode overrides `maxDataBytes`→10MB, `timeoutMs`→30s.) ## Standard library (allowlist — everything else throws) -- **Globals**: `tools`, `Promise`, `Object`, `Math`, `JSON`, `Number`, `String`, +- **Globals**: `tools`, `console`, `Promise`, `Object`, `Math`, `JSON`, `Number`, `String`, `Boolean`, `Array`, `parseInt`, `parseFloat`, `undefined`. +- **console**: `log`/`warn`/`error`/`info`/`debug`. Each formats its args (strings verbatim, + objects/arrays as JSON, space-joined) and appends a line to `logs`; it returns `undefined`, + is **not** a tool call (spends no tool-call budget), and any other member throws. Formatting + is charged to `maxOperations`, and total captured output is bounded by `maxAuditBytes`. - **String**: case/trim, split, slice/substring/substr, includes/startsWith/endsWith, indexOf/lastIndexOf, replace/replaceAll, repeat, padStart/padEnd, charAt/at, charCodeAt/codePointAt, concat. **String args only.** @@ -76,7 +81,6 @@ not cumulative. (Code mode overrides `maxDataBytes`→10MB, `timeoutMs`→30s.) - **`Date`** — no dates or time. - **`RegExp` / regex literals** — none. `replace`/`split` take plain strings only. - **`Map` / `Set` / `WeakMap` / `WeakSet`** — none. -- **`console`** — no logging/output. - **`Promise`** — only `Promise.all`. No `new Promise`, `race`, `allSettled`, `resolve`, `reject`. - **`new`** — only the `Error` family (`Error`, `TypeError`, `RangeError`, `SyntaxError`, @@ -167,6 +171,25 @@ impossible rather than merely discouraged. The trade-off: a program can no longe bytes to route them into another tool's input; if that need arises it would be an explicit host call (e.g. a `readAttachment(handle)`), not the always-on default. Not implemented yet. +## console output is surfaced to the model as a trailing `Logs:` section + +The sandbox has no stdout, but `console.log`/`warn`/`error`/`info`/`debug` are available (an +interpreter builtin, not a tool). Code mode reads the run's `logs` and, when non-empty, appends +them to the model-facing text as a trailing section — one `[level] message` line each: + +``` + + +Logs: +[log] resolved 3 candidates +[warn] falling back to first match +``` + +This holds on the error path too, so a program's diagnostics ride back with the failure that +followed them. Logs go to the **model only** (not the user) — they are the program's scratch +channel for narrating what it did, distinct from the `return` value (the answer) and returned +`attachments` (media for the model + user). A program that logs nothing gets no section. + ## Path handling — separator-tolerant The flat catalog key is `server_tool`, but the model is never required to guess the @@ -197,9 +220,3 @@ returning real callable paths (e.g. `context7/resolve-library` → suggests branches on `result.error` and retries with a suggestion. (Tool *calls* on a genuinely unknown path still surface as a catchable in-program error via Rune's `UnknownCapability`; only the discovery helpers return soft errors.) - -## Not done yet - -- **`console` capture** — a program has no way to surface `console.log` output. Rune has no - `console` (see "What is missing") and the interpreter is frozen, so buffering log output into - the result envelope is deferred to separate interpreter work rather than faked here. diff --git a/packages/opencode/src/session/rune/rune.ts b/packages/opencode/src/session/rune/rune.ts index 530fa9e49a7d..414e389adec1 100644 --- a/packages/opencode/src/session/rune/rune.ts +++ b/packages/opencode/src/session/rune/rune.ts @@ -57,11 +57,18 @@ export type ExecuteOptions = {}> = { limits?: ExecutionLimits } +/** One captured `console.*` line: the method used and the formatted message. */ +export type LogEntry = { + readonly level: "log" | "warn" | "error" | "info" | "debug" + readonly message: string +} + export type ExecuteResult = | { ok: true value: unknown toolCalls: ReadonlyArray + logs: ReadonlyArray } | { ok: false @@ -72,6 +79,7 @@ export type ExecuteResult = suggestions?: ReadonlyArray } toolCalls: ReadonlyArray + logs: ReadonlyArray } export type RuneOptions = {}> = Omit, "code"> @@ -91,6 +99,7 @@ export const ExecuteResultSchema = Schema.Union([ ok: Schema.Literal(true), value: Schema.Unknown, toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })), + logs: Schema.Array(Schema.Struct({ level: Schema.String, message: Schema.String })), }), Schema.Struct({ ok: Schema.Literal(false), @@ -101,6 +110,7 @@ export const ExecuteResultSchema = Schema.Union([ suggestions: Schema.optional(Schema.Array(Schema.String)), }), toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })), + logs: Schema.Array(Schema.Struct({ level: Schema.String, message: Schema.String })), }), ]) @@ -203,6 +213,56 @@ class CoercionFunction { constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {} } +// The `console` builtin. `console.` resolves to a ConsoleMethodReference; calling it +// formats its arguments and appends a line to the run's shared LogCollector. Logging is a pure +// side effect on the host — it never dispatches a tool, spends the tool-call budget, or returns +// a value the program can branch on (every method returns undefined, like JS `console`). +class ConsoleReference {} + +const consoleLevels = new Set(["log", "warn", "error", "info", "debug"] as const) + +class ConsoleMethodReference { + constructor(readonly level: LogEntry["level"]) {} +} + +/** + * Collects `console.*` output for one execution. Shared by reference across parallel + * interpreter forks (like the operation budget), so logs emitted inside `Promise.all` + * / `.map` callbacks are captured too. Bounded by a total character budget — once spent, + * further lines are dropped (the last accepted line is truncated to fit) so a logging loop + * can't grow memory without also spending the operation budget. + */ +class LogCollector { + readonly entries: LogEntry[] = [] + private used = 0 + constructor(private readonly maxChars: number) {} + push(level: LogEntry["level"], message: string): void { + if (this.used >= this.maxChars) return + const remaining = this.maxChars - this.used + const text = message.length > remaining ? message.slice(0, remaining) : message + this.used += text.length + this.entries.push({ level, message: text }) + } +} + +/** Format one `console.*` argument the way `console` roughly does: strings verbatim, + * objects/arrays as JSON, opaque runtime references as a placeholder (never leaking their + * internals), everything else via String(). Never throws. */ +const formatLogArg = (value: unknown): string => { + if (typeof value === "string") return value + if (value === null) return "null" + if (value === undefined) return "undefined" + if (isRuntimeReference(value)) return "[runtime reference]" + if (typeof value === "object") { + try { + return JSON.stringify(value) ?? coerceToString(value) + } catch { + return coerceToString(value) + } + } + return String(value) +} + class ProgramThrow { constructor(readonly value: unknown) {} } @@ -479,7 +539,8 @@ const boundedData = (value: unknown, label: string, node: AstNode, limits: Resol const isRuntimeReference = (value: unknown): boolean => value instanceof RuneFunction || value instanceof ToolReference || value instanceof IntrinsicReference || value instanceof GlobalNamespace || value instanceof GlobalMethodReference || value instanceof PromiseNamespace || - value instanceof PromiseAllReference || value instanceof CoercionFunction + value instanceof PromiseAllReference || value instanceof CoercionFunction || + value instanceof ConsoleReference || value instanceof ConsoleMethodReference const containsRuntimeReference = (value: unknown, seen = new Set()): boolean => { if (isRuntimeReference(value)) return true @@ -904,6 +965,9 @@ class Interpreter { private readonly limits: ResolvedExecutionLimits private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect private readonly budget: { operations: number } + // Shared by reference with any parallel forks (see forkForParallelCallback) so every + // console.* line lands in one ordered collection regardless of which fork emitted it. + private readonly logs: LogCollector private lastValue: unknown // Cached byte size (and, for objects, key count) of each live container, maintained incrementally // by the mutation helpers so appending in a loop is O(1)/op rather than re-walking the whole @@ -916,14 +980,17 @@ class Interpreter { limits: ResolvedExecutionLimits, invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, budget: { operations: number } = { operations: 0 }, + logs: LogCollector = new LogCollector(limits.maxAuditBytes), ) { const globalScope = new Map() this.scopes = [globalScope] this.limits = limits this.invokeTool = invokeTool this.budget = budget + this.logs = logs this.lastValue = undefined globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) + globalScope.set("console", { mutable: false, value: new ConsoleReference() }) globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) globalScope.set("undefined", { mutable: false, value: undefined }) globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") }) @@ -1740,6 +1807,14 @@ class Interpreter { self.recordWork(workUnits(coercionResult), node) return boundedData(coercionResult, `${callable.name} result`, node, self.limits) } + if (callable instanceof ConsoleMethodReference) { + const message = args.map(formatLogArg).join(" ") + // Charge formatting to the operation budget so a console.log loop is bounded by + // maxOperations, not just the wall-clock timeout. + self.recordWork(message.length, node) + self.logs.push(callable.level, message) + return undefined + } throw new InterpreterRuntimeError("Only tool capabilities are callable in Rune.", callee) }) } @@ -2310,7 +2385,7 @@ class Interpreter { } } - private getMemberReference(node: AstNode): Effect.Effect { + private getMemberReference(node: AstNode): Effect.Effect { const objectNode = getNode(node, "object") const propertyNode = getNode(node, "property") const computed = getBoolean(node, "computed") @@ -2339,6 +2414,13 @@ class Interpreter { throw new InterpreterRuntimeError(`Promise.${String(key)} is not available in Rune. Use Promise.all(...) for parallel Tool Capabilities.`, propertyNode) } + if (objectValue instanceof ConsoleReference) { + if (typeof key === "string" && consoleLevels.has(key as LogEntry["level"])) { + return new ConsoleMethodReference(key as LogEntry["level"]) + } + throw new InterpreterRuntimeError(`console.${String(key)} is not available in Rune. Use log, warn, error, info, or debug.`, propertyNode) + } + if (objectValue instanceof GlobalNamespace) { if (typeof key !== "string" || isBlockedMember(key)) { throw new InterpreterRuntimeError(`${objectValue.name}.${String(key)} is not available in Rune.`, propertyNode) @@ -2411,7 +2493,8 @@ class Interpreter { reference instanceof ToolReference || reference instanceof PromiseAllReference || reference instanceof IntrinsicReference || - reference instanceof GlobalMethodReference + reference instanceof GlobalMethodReference || + reference instanceof ConsoleMethodReference ) return reference if (Array.isArray(reference.target)) { if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { @@ -2444,7 +2527,8 @@ class Interpreter { reference instanceof ToolReference || reference instanceof PromiseAllReference || reference instanceof IntrinsicReference || - reference instanceof GlobalMethodReference + reference instanceof GlobalMethodReference || + reference instanceof ConsoleMethodReference ) { throw new InterpreterRuntimeError("Only data fields may be assigned in Rune.", node) } @@ -2643,7 +2727,7 @@ class Interpreter { } private forkForParallelCallback(): Interpreter { - const fork = new Interpreter(this.limits, this.invokeTool, this.budget) + const fork = new Interpreter(this.limits, this.invokeTool, this.budget, this.logs) fork.scopes.splice( 0, fork.scopes.length, @@ -2682,12 +2766,16 @@ export const execute = >(options: Ex const limits = resolveExecutionLimits(options.limits) ToolRuntime.assertValidTools((options.tools ?? {}) as HostTools>) const tools = ToolRuntime.make((options.tools ?? {}) as HostTools>, limits.maxToolCalls, limits) + // Lives in the outer scope (not the interpreter) so console output is surfaced on every + // result path, including timeout and failure where the interpreter instance is unreachable. + const logs = new LogCollector(limits.maxAuditBytes) if (new TextEncoder().encode(options.code).byteLength > limits.maxSourceBytes) { return Effect.succeed({ ok: false, error: { kind: "InvalidDataValue", message: `Code exceeds the maximum source size of ${limits.maxSourceBytes} bytes.` }, toolCalls: tools.calls, + logs: logs.entries, }) } @@ -2696,12 +2784,13 @@ export const execute = >(options: Ex ok: false, error: { kind: "ParseError", message: "Code cannot be empty." }, toolCalls: tools.calls, + logs: logs.entries, }) } const operation = Effect.gen(function*() { const program = parseProgram(options.code) - const interpreter = new Interpreter>(limits, tools.invoke) + const interpreter = new Interpreter>(limits, tools.invoke, undefined, logs) const value = yield* interpreter.run(program) const copied = copyIn(value, "Execution result", limits) if (dataByteLength(copied) > limits.maxDataBytes) { @@ -2711,6 +2800,7 @@ export const execute = >(options: Ex ok: true, value: copyOut(copied), toolCalls: tools.calls, + logs: logs.entries, } satisfies ExecuteResult }).pipe( Effect.timeoutOrElse({ @@ -2719,6 +2809,7 @@ export const execute = >(options: Ex ok: false, error: { kind: "TimeoutExceeded", message: `Execution timed out after ${limits.timeoutMs}ms.` }, toolCalls: tools.calls, + logs: logs.entries, } satisfies ExecuteResult), }), ) @@ -2729,6 +2820,7 @@ export const execute = >(options: Ex ok: false, error: normalizeError(Cause.squash(cause)), toolCalls: tools.calls, + logs: logs.entries, }), onSuccess: (result): ExecuteResult => result, }), diff --git a/packages/opencode/test/session/code-mode-integration.test.ts b/packages/opencode/test/session/code-mode-integration.test.ts index d23ab1ab588e..0d8adf32c75b 100644 --- a/packages/opencode/test/session/code-mode-integration.test.ts +++ b/packages/opencode/test/session/code-mode-integration.test.ts @@ -216,6 +216,40 @@ describe("code mode integration (real MCP server)", () => { expect(out.output).toContain("kaboom") }) + test("console output is captured and appended as a Logs section after the result", async () => { + const out = await run(` + console.log("looking up", { name: "world" }) + const r = await tools.fixtures.get_text({ name: "world" }) + console.warn("got", r.result) + return r.result + `) + expect(out.output).toBe('hello world\n\nLogs:\n[log] looking up {"name":"world"}\n[warn] got hello world') + expect(out.metadata.error).toBeUndefined() + }) + + test("console output is preserved on the error path", async () => { + const out = await run(` + console.log("before the throw") + await tools.fixtures.boom({}) + return "unreachable" + `) + expect(out.metadata.error).toBe(true) + expect(out.output).toContain("kaboom") + expect(out.output).toContain("Logs:\n[log] before the throw") + }) + + test("a program that logs nothing gets no Logs section", async () => { + const out = await run("return 'quiet'") + expect(out.output).toBe("quiet") + expect(out.output).not.toContain("Logs:") + }) + + test("console does not consume the tool-call metadata (logging is not a tool call)", async () => { + const out = await run("console.log('hi'); console.error('bye'); return 'ok'") + expect(out.output).toBe("ok\n\nLogs:\n[log] hi\n[error] bye") + expect(out.metadata.toolCalls).toEqual([]) + }) + test("asks permission for each MCP call but not for discovery helpers", async () => { const asked: string[] = [] const permCtx: Tool.Context = { ...ctx, ask: (req: any) => Effect.sync(() => void asked.push(req.permission)) } diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 9b0e449edd85..fac8e9e5d080 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -9,6 +9,7 @@ import { rankTools, renderType, toEnvelope, + withLogs, type SearchEntry, } from "@/session/code-mode" import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" @@ -406,6 +407,17 @@ describe("code mode execute", () => { expect(formatValue(undefined)).toBe("undefined") }) + test("unit: withLogs", () => { + // No logs: output is returned untouched. + expect(withLogs("result", [])).toBe("result") + // Logs are appended as a trailing section, one `[level] message` line each. + expect(withLogs("result", [{ level: "log", message: "a" }, { level: "warn", message: "b" }])).toBe( + "result\n\nLogs:\n[log] a\n[warn] b", + ) + // Empty output still gets the section (no leading blank lines). + expect(withLogs("", [{ level: "error", message: "boom" }])).toBe("Logs:\n[error] boom") + }) + test("terminates a runaway loop via the operation limit instead of hanging", async () => { const tool = await build({}) const output = await Effect.runPromise(tool.execute({ code: "while (true) {}" }, ctx)) From cc437b9e9a9721ae4d41d441daf0f73045fd1de7 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 1 Jul 2026 12:20:54 -0500 Subject: [PATCH 24/41] fix(opencode): make Rune tolerate idiomatic defensive JavaScript Bring the Rune interpreter closer to JS semantics so ordinary defensive code stops crashing where real JS would quietly yield undefined or succeed: - Unknown property reads on strings, numbers, and arrays now yield undefined instead of throwing (including under optional chaining, which only guards null/undefined receivers). MCP results are frequently JSON strings, so `result?.field ?? result` reads the field when present and falls back to the raw string otherwise. The method allowlist still errors (e.g. arr.splice keeps its rewrite hint). - `typeof undeclaredIdentifier` is now "undefined" rather than a reference error, so `typeof x !== "undefined"` feature-detection guards are safe. - Object spread of null/undefined is a no-op, so `{ ...maybeOpts, override }` merges work when the operand is absent. - Builtin coercions (Boolean/String/Number) are accepted as array callbacks, so filter(Boolean) / map(String) / map(Number) work. Adds rune-parity.test.ts covering each fix plus its guardrails, and documents the parity behavior in rune.md. --- packages/opencode/src/session/rune/rune.md | 16 +++ packages/opencode/src/session/rune/rune.ts | 63 ++++++--- .../opencode/test/session/rune-parity.test.ts | 120 ++++++++++++++++++ 3 files changed, 180 insertions(+), 19 deletions(-) create mode 100644 packages/opencode/test/session/rune-parity.test.ts diff --git a/packages/opencode/src/session/rune/rune.md b/packages/opencode/src/session/rune/rune.md index f3529890993b..0c08ef0b203b 100644 --- a/packages/opencode/src/session/rune/rune.md +++ b/packages/opencode/src/session/rune/rune.md @@ -76,6 +76,22 @@ not cumulative. (Code mode overrides `maxDataBytes`→10MB, `timeoutMs`→30s.) spread, optional chaining, template literals, conditionals, switch, loops, `for...of`, try/catch, ternary, `in`, logical assignment, bitwise ops, `await`, `Promise.all`. +## JavaScript semantics (parity with JS, so defensive code doesn't crash) + +Reading a value behaves like JS even when a key/name is absent — it yields `undefined` rather +than throwing, so idiomatic defensive code (`a?.b ?? c`, feature detection, merges) works: + +- **Unknown property reads yield `undefined`.** `"s".foo`, `(5).foo`, `[1,2].foo`, and + `obj.missing` are all `undefined` — including under `?.` (which only guards `null`/`undefined` + receivers). MCP results are often JSON *strings*, so `result?.field ?? result` reads the field + when present and falls back to the raw string otherwise instead of crashing. (Only the method + *allowlist* still errors — e.g. calling an unsupported `arr.splice(...)` gives a rewrite hint.) +- **`typeof undeclaredIdentifier` is `"undefined"`**, never a reference error, so + `typeof x !== "undefined"` guards are safe. (A bare `x` reference still throws.) +- **`{ ...null }` / `{ ...undefined }` is a no-op**, so `{ ...maybeOpts, override }` merges work + when the operand is absent. +- **Builtin coercions are valid array callbacks**: `filter(Boolean)`, `map(String)`, `map(Number)`. + ## What is missing - **`Date`** — no dates or time. diff --git a/packages/opencode/src/session/rune/rune.ts b/packages/opencode/src/session/rune/rune.ts index 414e389adec1..26bb7278a7d7 100644 --- a/packages/opencode/src/session/rune/rune.ts +++ b/packages/opencode/src/session/rune/rune.ts @@ -1662,7 +1662,14 @@ class Interpreter { private evaluateUnaryExpression(node: AstNode): Effect.Effect { const operator = getString(node, "operator") - return Effect.map(this.evaluateExpression(getNode(node, "argument")), (value) => { + const argument = getNode(node, "argument") + // `typeof undeclaredIdentifier` is `"undefined"` in JS (never a ReferenceError), so + // feature-detection guards like `typeof x !== "undefined"` don't crash. Short-circuit before + // evaluating the argument; a declared-but-TDZ binding still falls through to the normal throw. + if (operator === "typeof" && argument.type === "Identifier" && !this.resolveBinding(getString(argument, "name"))) { + return Effect.succeed("undefined") + } + return Effect.map(this.evaluateExpression(argument), (value) => { if (containsRuntimeReference(value)) { throw new InterpreterRuntimeError("Unary operators require data values in Rune.", node, "InvalidDataValue") } @@ -2072,10 +2079,17 @@ class Interpreter { } const callback = args[0] - if (!(callback instanceof RuneFunction)) { - throw new InterpreterRuntimeError(`Array.${name} expects an arrow function callback.`, node) + if (!(callback instanceof RuneFunction) && !(callback instanceof CoercionFunction)) { + throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node) } const self = this + // Accept a user arrow function or a builtin coercion callable (Boolean/String/Number), so the + // idioms `filter(Boolean)` / `map(String)` / `map(Number)` work as in JS. Coercions are + // synchronous; only RuneFunctions can await tool calls. + const apply = (callbackArgs: Array): Effect.Effect => + callback instanceof CoercionFunction + ? Effect.succeed(invokeCoercion(callback, callbackArgs, node, self.limits)) + : self.invokeFunction(callback, callbackArgs) return Effect.gen(function*() { // Iterate a snapshot taken at call time so a callback that mutates the array can't // self-extend the loop — matching JS, where elements appended during iteration are not visited. @@ -2083,13 +2097,13 @@ class Interpreter { switch (name) { case "map": { const values: Array = [] - for (const [index, item] of items.entries()) values.push(yield* self.invokeFunction(callback, [item, index, items])) + for (const [index, item] of items.entries()) values.push(yield* apply([item, index, items])) return boundedCollection(values) } case "flatMap": { const values: Array = [] for (const [index, item] of items.entries()) { - const mapped = yield* self.invokeFunction(callback, [item, index, items]) + const mapped = yield* apply([item, index, items]) if (Array.isArray(mapped)) values.push(...mapped) else values.push(mapped) boundedCollection(values) @@ -2099,32 +2113,32 @@ class Interpreter { case "filter": { const values: Array = [] for (const [index, item] of items.entries()) { - if (yield* self.invokeFunction(callback, [item, index, items])) values.push(item) + if (yield* apply([item, index, items])) values.push(item) } return boundedCollection(values) } case "find": for (const [index, item] of items.entries()) { - if (yield* self.invokeFunction(callback, [item, index, items])) return item + if (yield* apply([item, index, items])) return item } return undefined case "findIndex": for (const [index, item] of items.entries()) { - if (yield* self.invokeFunction(callback, [item, index, items])) return index + if (yield* apply([item, index, items])) return index } return -1 case "some": for (const [index, item] of items.entries()) { - if (yield* self.invokeFunction(callback, [item, index, items])) return true + if (yield* apply([item, index, items])) return true } return false case "every": for (const [index, item] of items.entries()) { - if (!(yield* self.invokeFunction(callback, [item, index, items]))) return false + if (!(yield* apply([item, index, items]))) return false } return true case "forEach": - for (const [index, item] of items.entries()) yield* self.invokeFunction(callback, [item, index, items]) + for (const [index, item] of items.entries()) yield* apply([item, index, items]) return undefined case "reduce": { let accumulator: unknown @@ -2138,7 +2152,7 @@ class Interpreter { start = 1 } for (let index = start; index < items.length; index += 1) { - accumulator = yield* self.invokeFunction(callback, [accumulator, items[index], index, items]) + accumulator = yield* apply([accumulator, items[index], index, items]) } return accumulator } @@ -2154,18 +2168,18 @@ class Interpreter { start = items.length - 2 } for (let index = start; index >= 0; index -= 1) { - accumulator = yield* self.invokeFunction(callback, [accumulator, items[index], index, items]) + accumulator = yield* apply([accumulator, items[index], index, items]) } return accumulator } case "findLast": for (let index = items.length - 1; index >= 0; index -= 1) { - if (yield* self.invokeFunction(callback, [items[index], index, items])) return items[index] + if (yield* apply([items[index], index, items])) return items[index] } return undefined case "findLastIndex": for (let index = items.length - 1; index >= 0; index -= 1) { - if (yield* self.invokeFunction(callback, [items[index], index, items])) return index + if (yield* apply([items[index], index, items])) return index } return -1 } @@ -2227,7 +2241,10 @@ class Interpreter { if (property.type === "SpreadElement") { const spread = yield* self.evaluateExpression(getNode(property, "argument")) - if (spread === null || typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) { + // JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common + // `{ ...maybeOpts, override }` merge works when the operand is absent. + if (spread === null || spread === undefined) continue + if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) { throw new InterpreterRuntimeError("Object spread requires a data object in Rune.", property, "InvalidDataValue") } for (const [key, value] of Object.entries(spread)) { @@ -2436,12 +2453,17 @@ class Interpreter { if (typeof key === "number") return new ComputedValue(objectValue[key]) if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)]) if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key) - throw new InterpreterRuntimeError(`String property '${String(key)}' is not available in Rune.`, propertyNode) + // Unknown property on a string reads as `undefined`, matching JS (`"x".foo === undefined`), + // instead of throwing — so defensive access like `result?.login ?? result` on a JSON-string + // tool result doesn't crash. (Optional chaining only guards null/undefined receivers, so a + // real string still reaches here.) Only the method allowlist above yields callables. + return new ComputedValue(undefined) } if (typeof objectValue === "number") { if (typeof key === "string" && numberMethods.has(key)) return new IntrinsicReference(objectValue, key) - throw new InterpreterRuntimeError(`Number property '${String(key)}' is not available in Rune.`, propertyNode) + // Unknown property on a number reads as `undefined`, matching JS, rather than throwing. + return new ComputedValue(undefined) } // Number / String expose a small allowlist of statics; everything else stays opaque. @@ -2475,7 +2497,10 @@ class Interpreter { [supportedSyntaxMessage], ) } - throw new InterpreterRuntimeError(`Array property '${String(key)}' is not available in Rune.`, propertyNode) + // Unknown property on an array reads as `undefined`, matching JS (`[1,2].foo === undefined`), + // instead of throwing — so defensive access under optional chaining behaves as expected. The + // retryable-method hint above still fires for real methods we don't support (e.g. splice). + return new ComputedValue(undefined) } return { target: objectValue, key } } diff --git a/packages/opencode/test/session/rune-parity.test.ts b/packages/opencode/test/session/rune-parity.test.ts new file mode 100644 index 000000000000..2d38a5c1702c --- /dev/null +++ b/packages/opencode/test/session/rune-parity.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { Rune } from "@/session/rune/rune" + +// Runs a Rune program with no host tools and returns the ExecuteResult. These tests pin the +// JS-parity fixes for the "99% of ordinary defensive JavaScript just works" goal: cases where +// Rune used to throw but idiomatic JS yields undefined / succeeds. +const run = (code: string) => Effect.runPromise(Rune.execute({ code, tools: {} })) +const value = async (code: string) => { + const result = await run(code) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} +const error = async (code: string) => { + const result = await run(code) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} + +describe("H2: string property access reads as undefined (not a throw)", () => { + test("unknown property on a string is undefined", async () => { + expect(await value(`const s = "hi"; return s.login`)).toBeUndefined() + }) + + test("optional chaining + fallback on a string does not throw", async () => { + expect(await value(`const s = "hi"; return s?.login ?? "fallback"`)).toBe("fallback") + }) + + test("the real MCP pattern: result is a JSON string, defensive read falls through", async () => { + // me.result is a string; me.result?.login is undefined, so we fall back to the raw string. + expect(await value(`const me = { result: '{"login":"x"}' }; return me.result?.login ?? me.result`)).toBe( + '{"login":"x"}', + ) + }) + + test("unknown property on a number is undefined", async () => { + expect(await value(`return (5).foo ?? "n"`)).toBe("n") + }) + + test("supported string methods still work", async () => { + expect(await value(`return "AB".toLowerCase()`)).toBe("ab") + expect(await value(`return "hello".length`)).toBe(5) + }) +}) + +describe("H3: array property access reads as undefined (not a throw)", () => { + test("unknown property on an array is undefined", async () => { + expect(await value(`return [1,2,3].foo`)).toBeUndefined() + }) + + test("optional chaining on an array does not throw", async () => { + expect(await value(`return [1,2,3]?.foo ?? "fb"`)).toBe("fb") + }) + + test("real-but-unsupported array methods still give the rewrite hint", async () => { + const err = await error(`return [1,2,3].splice(0,1)`) + expect(err.kind).toBe("UnsupportedSyntax") + expect(err.message).toContain("splice") + }) + + test("supported array methods and indexing still work", async () => { + expect(await value(`return [1,2,3].map(x => x + 1)`)).toEqual([2, 3, 4]) + expect(await value(`return [1,2,3][9]`)).toBeUndefined() + }) +}) + +describe("H6: object spread of null/undefined is a no-op", () => { + test("spreading null is a no-op", async () => { + expect(await value(`const o = null; return { ...o, a: 1 }`)).toEqual({ a: 1 }) + }) + + test("spreading an absent argument merges cleanly", async () => { + expect(await value(`function f(opts){ return { ...opts, a: 1 } } return f(undefined)`)).toEqual({ a: 1 }) + }) + + test("spreading a real object still works", async () => { + expect(await value(`const o = { a: 1 }; return { ...o, b: 2 }`)).toEqual({ a: 1, b: 2 }) + }) + + test("spreading an array into an object still errors", async () => { + const err = await error(`return { ...[1,2], a: 1 }`) + expect(err.kind).toBe("InvalidDataValue") + }) +}) + +describe("H4: typeof on an undeclared identifier is 'undefined'", () => { + test("feature-detection guard does not throw", async () => { + expect(await value(`return typeof foo === "undefined" ? "safe" : "no"`)).toBe("safe") + }) + + test("typeof of a declared binding is unaffected", async () => { + expect(await value(`const x = 5; return typeof x`)).toBe("number") + expect(await value(`const s = "a"; return typeof s`)).toBe("string") + }) + + test("referencing an undeclared identifier outside typeof still throws", async () => { + const err = await error(`return foo + 1`) + expect(err.message).toContain("foo") + }) +}) + +describe("H5: builtin coercion functions work as array callbacks", () => { + test("filter(Boolean) drops falsy values", async () => { + expect(await value(`return [0, 1, "", 2, null, 3].filter(Boolean)`)).toEqual([1, 2, 3]) + }) + + test("map(String) coerces each element", async () => { + expect(await value(`return [1, 2, 3].map(String)`)).toEqual(["1", "2", "3"]) + }) + + test("arrow callbacks still work (no regression)", async () => { + expect(await value(`return [1, 2, 3, 4].filter(x => x % 2 === 0)`)).toEqual([2, 4]) + expect(await value(`return [1, 2, 3].reduce((a, b) => a + b, 0)`)).toBe(6) + }) + + test("a non-callable callback is still rejected", async () => { + const err = await error(`return [1,2,3].map(42)`) + expect(err.message).toContain("callback") + }) +}) From e06a099a88c903fa1ce34ca050af84d1267e7cbc Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 1 Jul 2026 12:26:55 -0500 Subject: [PATCH 25/41] fix(opencode): let NaN/Infinity flow in Rune, normalize to null at the boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rune previously threw the instant any non-finite number materialized, which killed the program before the model's own guard could run — so idiomatic code like parseInt(x) || 0, Number(x) then Number.isNaN(x), averages, and counters crashed mid-expression. Real JS (and a real-engine sandbox) let these values flow and rely on JSON serialization to turn them into null at the edge. - copyIn no longer rejects non-finite numbers, so NaN/Infinity exist as ordinary in-sandbox intermediates and defensive guards can run. - copyOut normalizes non-finite numbers to null as a value leaves the sandbox; final return and tool-call arguments both funnel through it, so one check pins both boundaries (matching JSON.stringify, which produces null anyway). - NaN/Infinity are now bindable identifiers (e.g. reduce(max, -Infinity)). Extends rune-parity.test.ts (guards run, non-finite -> null out incl. nested, a direct copyOut unit test) and updates rune.md. --- packages/opencode/src/session/rune/rune.md | 7 +++- packages/opencode/src/session/rune/rune.ts | 4 ++ .../opencode/src/session/rune/tool-runtime.ts | 26 +++++++++---- .../opencode/test/session/rune-parity.test.ts | 37 +++++++++++++++++++ 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/rune/rune.md b/packages/opencode/src/session/rune/rune.md index 0c08ef0b203b..75958bf9cfb5 100644 --- a/packages/opencode/src/session/rune/rune.md +++ b/packages/opencode/src/session/rune/rune.md @@ -91,6 +91,10 @@ than throwing, so idiomatic defensive code (`a?.b ?? c`, feature detection, merg - **`{ ...null }` / `{ ...undefined }` is a no-op**, so `{ ...maybeOpts, override }` merges work when the operand is absent. - **Builtin coercions are valid array callbacks**: `filter(Boolean)`, `map(String)`, `map(Number)`. +- **`NaN`/`Infinity` flow as ordinary values** (and are bindable identifiers), so `Number(x)` / + `parseInt(x) || 0` / averages / counters don't crash mid-expression — guards like + `Number.isNaN(x)` get to run. A non-finite number is normalized to `null` only when it crosses + out of the sandbox (final `return` or a tool argument), exactly as `JSON.stringify` would. ## What is missing @@ -106,7 +110,8 @@ than throwing, so idiomatic defensive code (`a?.b ?? c`, feature detection, merg rejected as `UnsupportedSyntax`. - **`Symbol`, `BigInt`** — none. - **Partial built-ins** — e.g. `JSON.stringify` ignores replacers; `Array.from` takes no - map fn; `NaN`/`Infinity` are not valid data values. + map fn. (`NaN`/`Infinity` are usable in-sandbox but serialize to `null` at the boundary — see + JavaScript semantics above.) - **No I/O** — no fetch, fs, timers, env, or any ambient capability. The only outside contact is host `tools`. diff --git a/packages/opencode/src/session/rune/rune.ts b/packages/opencode/src/session/rune/rune.ts index 26bb7278a7d7..655da452f3f1 100644 --- a/packages/opencode/src/session/rune/rune.ts +++ b/packages/opencode/src/session/rune/rune.ts @@ -1002,6 +1002,10 @@ class Interpreter { globalScope.set("Array", { mutable: false, value: new GlobalNamespace("Array") }) globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") }) globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") }) + // Non-finite numbers flow as ordinary values inside the sandbox (normalized to null at the + // boundary — see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`. + globalScope.set("NaN", { mutable: false, value: NaN }) + globalScope.set("Infinity", { mutable: false, value: Infinity }) } run(program: ProgramNode): Effect.Effect { diff --git a/packages/opencode/src/session/rune/tool-runtime.ts b/packages/opencode/src/session/rune/tool-runtime.ts index fe2c02139090..398da3100591 100644 --- a/packages/opencode/src/session/rune/tool-runtime.ts +++ b/packages/opencode/src/session/rune/tool-runtime.ts @@ -60,14 +60,17 @@ export const copyIn = (value: unknown, label: string, limits?: DataLimits, depth if (limits && depth > limits.maxValueDepth) { throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${limits.maxValueDepth}.`) } - if (value === null || value === undefined || typeof value === "string" || typeof value === "boolean") { - return value - } - - if (typeof value === "number") { - if (!Number.isFinite(value)) { - throw new ToolRuntimeError("InvalidDataValue", `${label} contains a non-finite number.`) - } + if ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "boolean" || + // NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real + // engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are + // normalized to `null` when the value leaves the sandbox — see copyOut — exactly as + // JSON.stringify already does at any tool boundary. + typeof value === "number" + ) { return value } @@ -111,6 +114,13 @@ export const copyIn = (value: unknown, label: string, limits?: DataLimits, depth } export const copyOut = (value: unknown): unknown => { + // Normalize non-finite numbers to null as the value crosses out of the sandbox (final return + // and tool-call arguments both funnel through here), matching JSON semantics — NaN/Infinity + // have no JSON representation, so JSON.stringify would produce null anyway. + if (typeof value === "number" && !Number.isFinite(value)) { + return null + } + if (Array.isArray(value)) { return value.map(copyOut) } diff --git a/packages/opencode/test/session/rune-parity.test.ts b/packages/opencode/test/session/rune-parity.test.ts index 2d38a5c1702c..31754a4198cf 100644 --- a/packages/opencode/test/session/rune-parity.test.ts +++ b/packages/opencode/test/session/rune-parity.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { Rune } from "@/session/rune/rune" +import { ToolRuntime } from "@/session/rune/tool-runtime" // Runs a Rune program with no host tools and returns the ExecuteResult. These tests pin the // JS-parity fixes for the "99% of ordinary defensive JavaScript just works" goal: cases where @@ -99,6 +100,42 @@ describe("H4: typeof on an undeclared identifier is 'undefined'", () => { }) }) +describe("H1: NaN/Infinity flow as intermediates and normalize to null at the boundary", () => { + test("guards run instead of the program crashing on a transient NaN", async () => { + expect(await value(`return parseInt("abc") || 0`)).toBe(0) + expect(await value(`const x = Number("abc"); return Number.isNaN(x) ? 0 : x`)).toBe(0) + expect(await value(`const o = {}; o.count = (o.count || 0) + 1; return o.count`)).toBe(1) + // average of an empty list, guarded — the classic divide-by-zero that used to throw pre-guard + expect(await value(`const a = []; return a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0`)).toBe(0) + }) + + test("a non-finite value becomes null when it leaves the sandbox", async () => { + expect(await value(`return 5/0`)).toBeNull() + expect(await value(`return 0/0`)).toBeNull() + expect(await value(`return Math.max()`)).toBeNull() + // nested, too — normalization walks the returned structure + expect(await value(`return { a: Number("x"), b: 2, c: [1/0] }`)).toEqual({ a: null, b: 2, c: [null] }) + }) + + test("NaN and Infinity are usable identifiers and inspectable in-sandbox", async () => { + expect(await value(`return Number.isNaN(NaN)`)).toBe(true) + expect(await value(`return Infinity > 1e9`)).toBe(true) + expect(await value(`return Number.isFinite(1/0)`)).toBe(false) + expect(await value(`return [3,1,2].reduce((a,b)=>Math.max(a,b), -Infinity)`)).toBe(3) + // JSON.stringify inside the sandbox matches JS: non-finite serializes to null + expect(await value(`return JSON.stringify({ x: Number("z") })`)).toBe('{"x":null}') + }) + + test("copyOut normalizes non-finite numbers to null (the shared return + tool-arg boundary)", () => { + // Tool-call arguments funnel through copyOut too, so this one function pins both boundaries. + expect(ToolRuntime.copyOut(NaN)).toBeNull() + expect(ToolRuntime.copyOut(Infinity)).toBeNull() + expect(ToolRuntime.copyOut(-Infinity)).toBeNull() + expect(ToolRuntime.copyOut(42)).toBe(42) + expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] })).toEqual({ a: null, b: [null, 1] }) + }) +}) + describe("H5: builtin coercion functions work as array callbacks", () => { test("filter(Boolean) drops falsy values", async () => { expect(await value(`return [0, 1, "", 2, null, 3].filter(Boolean)`)).toEqual([1, 2, 3]) From 51ea0acd970a0d8996bab9350b9c4f75981b87ba Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 10:58:24 -0500 Subject: [PATCH 26/41] feat(codemode): add @opencode-ai/codemode confined execution package Effect-native code execution over explicit schema-described tools: a hand-rolled interpreter over acorn ASTs (TypeScript stripped via transpileModule) with forgiving JS semantics, Date/RegExp/Map/Set value types, first-class promises (eager-fork, budgeted, timeout-interruptible), Tool.make over Effect Schema or render-only JSON Schema, ranked search with namespace scoping and exact-path lookup, a budgeted instruction catalog with workflow/rules prompting, tool-call start/end hooks, and public limits {timeoutMs, maxToolCalls, maxOutputBytes} with owned output truncation. Program failures are data, never Effect failures. 156 tests; see packages/codemode/codemode.md for design decisions. --- packages/codemode/AGENTS.md | 15 + packages/codemode/README.md | 321 ++ packages/codemode/codemode.md | 563 +++ packages/codemode/package.json | 26 + packages/codemode/src/codemode.ts | 3879 ++++++++++++++++++++ packages/codemode/src/index.ts | 27 + packages/codemode/src/tool-error.ts | 11 + packages/codemode/src/tool-runtime.ts | 687 ++++ packages/codemode/src/tool.ts | 223 ++ packages/codemode/src/values.ts | 54 + packages/codemode/test/codemode.test.ts | 794 ++++ packages/codemode/test/enumeration.test.ts | 159 + packages/codemode/test/parity.test.ts | 164 + packages/codemode/test/promise.test.ts | 428 +++ packages/codemode/test/stdlib.test.ts | 352 ++ packages/codemode/tsconfig.json | 7 + 16 files changed, 7710 insertions(+) create mode 100644 packages/codemode/AGENTS.md create mode 100644 packages/codemode/README.md create mode 100644 packages/codemode/codemode.md create mode 100644 packages/codemode/package.json create mode 100644 packages/codemode/src/codemode.ts create mode 100644 packages/codemode/src/index.ts create mode 100644 packages/codemode/src/tool-error.ts create mode 100644 packages/codemode/src/tool-runtime.ts create mode 100644 packages/codemode/src/tool.ts create mode 100644 packages/codemode/src/values.ts create mode 100644 packages/codemode/test/codemode.test.ts create mode 100644 packages/codemode/test/enumeration.test.ts create mode 100644 packages/codemode/test/parity.test.ts create mode 100644 packages/codemode/test/promise.test.ts create mode 100644 packages/codemode/test/stdlib.test.ts create mode 100644 packages/codemode/tsconfig.json diff --git a/packages/codemode/AGENTS.md b/packages/codemode/AGENTS.md new file mode 100644 index 000000000000..5fefa97407a2 --- /dev/null +++ b/packages/codemode/AGENTS.md @@ -0,0 +1,15 @@ +# @opencode-ai/codemode + +- This local package owns confined execution over explicit schema-described tools. Applications own authorization, persistence, external authority, and tool-specific delivery semantics. +- Do not add a speculative generic permission or approval policy. A host omits tools it does not expose and enforces domain authorization inside each provided tool. +- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it. +- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens. + +## Future Design Notes + +- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside the sandbox) instead. +- Improve the sandbox failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure. +- Preserve the public/private error split. Tool authors should be able to return a safe model-visible message while retaining a private cause for host diagnostics. Unknown host failures must remain sanitized by default. +- Think deliberately about richer binary boundaries before allowing `Blob`, `File`, `ArrayBuffer`, streams, or typed arrays beyond today’s JSON-like values. If CodeMode supports binary tool args/results, use explicit tagged data shapes and clear size limits rather than relying on ambient runtime serialization. +- Keep host capabilities explicit. Globals such as `fetch`, `crypto`, filesystem handles, extra modules, or network clients should be opt-in runtime capabilities with obvious policy defaults, not ambient authority. Default to unavailable unless a host deliberately provides the capability. +- If `fetch` is added, model it as a host-provided outbound capability with policy controls: allowed origins, methods, headers, response size, timeout, and whether response bodies may be returned, emitted, or only summarized through a tool. diff --git a/packages/codemode/README.md b/packages/codemode/README.md new file mode 100644 index 000000000000..5e648b8bdcff --- /dev/null +++ b/packages/codemode/README.md @@ -0,0 +1,321 @@ +# @opencode-ai/codemode + +Effect-native confined code execution over explicit, schema-described tools. + +CodeMode lets a model write a small JavaScript program that can call only the tools supplied by the host. The program can sequence calls, transform plain data, branch, loop, and run independent calls in parallel without receiving ambient filesystem, process, network, module, or application authority. + +The package is currently private to this workspace. Its API is designed around three uses: + +```ts +// One execution +yield* CodeMode.execute({ tools, code }) + +// A reusable runtime +const runtime = CodeMode.make({ tools, limits }) +yield* runtime.execute(code) + +// One agent-facing code tool +const codeTool = runtime.agentTool() +``` + +## Install + +Within this workspace: + +```json +{ + "dependencies": { + "@opencode-ai/codemode": "workspace:*" + } +} +``` + +CodeMode requires `effect` as a peer dependency. + +## Quick Start + +Define tools with Effect Schema, then place them in the object tree exposed to programs as `tools`: + +```ts +import { CodeMode, Tool } from "@opencode-ai/codemode" +import { Effect, Schema } from "effect" + +const lookupOrder = Tool.make({ + description: "Look up an order by ID", + input: Schema.Struct({ id: Schema.String }), + output: Schema.Struct({ id: Schema.String, status: Schema.String }), + run: ({ id }) => Effect.succeed({ id, status: "open" }), +}) + +const runtime = CodeMode.make({ + tools: { + orders: { + lookup: lookupOrder, + }, + }, +}) + +const result = yield* runtime.execute(` + const order = await tools.orders.lookup({ id: "order_42" }) + return { id: order.id, needsAttention: order.status !== "complete" } +`) +``` + +`result` is always an `ExecuteResult`. Program, validation, budget, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption. + +Successful result values are JSON-safe data. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well. + +## API + +### `Tool.make` + +```ts +const tool = Tool.make({ + description, + input, // Effect Schema (validating) or JSON Schema (render-only) + output, // optional; same choice + run, +}) +``` + +`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document (the natural shape for adapter-provided tools whose schemas arrive as JSON Schema, e.g. MCP definitions). Effect Schema input is decoded before `run` is invoked, and `run` returns the encoded representation of an Effect Schema `output`, which CodeMode decodes and copies before exposing it to the program. JSON Schemas only shape the model-visible signature; values pass through unvalidated (they still cross the plain-data boundary). + +`output` is optional. Without it the tool's signature advertises `Promise` and the host result is exposed as-is. + +The description and schemas are part of the model-visible tool contract. Keep descriptions concrete and put authorization in `run` or in the service it calls. + +### `CodeMode.execute` + +Use `CodeMode.execute` for a single execution: + +```ts +const result = yield* CodeMode.execute({ + tools: { orders: { lookup: lookupOrder } }, + code: `return await tools.orders.lookup({ id: "order_42" })`, + limits: { maxToolCalls: 10 }, + onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call), + onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call), +}) +``` + +The Effect environment is inferred from the supplied tools. CodeMode does not erase service requirements introduced by tool implementations. + +### `CodeMode.make` + +Use `CodeMode.make` when the tool set and execution policy are reused: + +```ts +const runtime = CodeMode.make({ + tools: { orders: { lookup: lookupOrder } }, + limits: { timeoutMs: 30_000 }, +}) + +runtime.catalog() // structured tool descriptions +runtime.instructions() // model-facing syntax and tool guide +runtime.execute(source) // ExecuteResult +runtime.agentTool() // { name, description, input, output, execute } +``` + +`catalog`, `instructions`, and `agentTool` are projections of the same configured tool tree. `agentTool().description` is exactly `instructions()`. + +### Results + +```ts +type ExecuteResult = ExecuteSuccess | ExecuteFailure + +interface ExecuteSuccess { + readonly ok: true + readonly value: Schema.Json + readonly logs?: ReadonlyArray + readonly truncated?: boolean + readonly toolCalls: ReadonlyArray +} + +interface ExecuteFailure { + readonly ok: false + readonly error: Diagnostic + readonly logs?: ReadonlyArray + readonly truncated?: boolean + readonly toolCalls: ReadonlyArray +} +``` + +`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. `truncated` is present when the value or logs were cut to fit `maxOutputBytes` (see Execution Limits). + +### Tool-call hooks + +`onToolCallStart` receives `{ index, name, input }` after input decoding and before tool execution. The input is decoded host-side data and may include values produced by schema transformations; applications should avoid logging sensitive tool arguments indiscriminately. + +`onToolCallEnd` receives `{ index, name, input, durationMs, outcome, message? }` when an admitted call settles. `outcome` is `"success"` or `"failure"`; `message` is the model-safe failure message and is present only on failure. Interrupted calls (for example when the execution timeout fires) do not produce an end event. Both hooks are Effect-returning and must not fail. + +## Discovery + +The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count, and as many complete tool signatures (each with a one-line description) as fit a byte budget are inlined — cheapest-first within a namespace, namespaces processed alphabetically. Once one signature does not fit, inlining stops for every remaining namespace, which then show counts only. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL — N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`). + +The default budget is 16,000 UTF-8 bytes. Override it when constructing a runtime: + +```ts +const runtime = CodeMode.make({ + tools, + discovery: { maxInlineCatalogBytes: 24_000 }, +}) +``` + +The budget must be a non-negative safe integer. + +The runtime search tool is always registered — including when the catalog is fully inlined — so a speculative `tools.$codemode.search` call never fails as an unknown tool. It is only advertised in the instructions when the inlined list is partial: + +```ts +const matches = await tools.$codemode.search({ + query: "order status", + namespace: "orders", // optional: scope to one top-level namespace + limit: 10, +}) +``` + +`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path) and capped at `limit` results (default 10). + +Each result contains the path, description, and generated TypeScript signature, so no second lookup is needed. Result paths carry the `tools.` prefix (`tools.orders.lookup`), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (with or without the `tools.` prefix) is treated as a lookup and returns that tool alone. + +The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call it by path; `JSON.parse` string results; return only the needed fields), a `## Rules` section (parse text results as JSON, return small instead of raw payloads, filter and aggregate large collections in code, inspect intermediates with `console.*` — captured as result logs — run independent calls through `Promise.all`, enumerate `tools` with `Object.keys`/`for...in`, and browse a namespace via search when it is advertised), a `## Syntax` section, and the budgeted `## Available tools` catalog. Every call form uses explicit `.`/`` placeholders — never a real or fabricated tool name. + +A host cannot define its own `$codemode` top-level namespace. + +## Supported Programs + +CodeMode executes a deliberately bounded JavaScript subset. It supports: + +- Plain data literals, property access, assignment, and destructuring. +- `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references — anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`. +- Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring. +- Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`. +- Common array, string, number, `Object`, `Math`, and `JSON` operations. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces and `Object.keys(tools.ns)` the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. +- `Date` — `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones). +- Regular expressions — `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. Function replacers are not supported. +- `Map` and `Set` — construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). +- First-class promises — an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic. +- `throw value` and `throw new Error(message)` for explicit program failure. + +At every data boundary (final result, tool arguments, `JSON.stringify`, and the internal data checkpoints of `Object.*`/coercion helpers) the four value types serialize exactly as `JSON.stringify` would: a Date becomes its ISO string (`null` when invalid) and RegExp/Map/Set become `{}`. Map and Set contents are charged against the internal data-size and collection-length budgets like any other collection. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`. + +It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` — `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available. + +CodeMode is an orchestration language, not a general JavaScript runtime. + +## Execution Limits + +The public limits are three knobs: + +| Limit | Default | Bounds | +| --- | ---: | --- | +| `timeoutMs` | 10,000 | Wall-clock execution time. | +| `maxToolCalls` | 100 | Tool calls admitted during the execution. | +| `maxOutputBytes` | 32,000 | Model-facing output: the serialized result value plus captured logs. | + +Pass only the overrides you need: + +```ts +const runtime = CodeMode.make({ + tools, + limits: { + maxToolCalls: 20, + timeoutMs: 60_000, + }, +}) +``` + +Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` override leaves the default intact. + +Exceeding `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept from the start until the remaining budget is exhausted (with a final marker line noting the cut), and the result carries `truncated: true`. + +The timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). Tool implementations remain responsible for making their external operations interruptible or independently bounded. + +Interpreter internals stay bounded by fixed internal budgets (operation count 100,000; in-sandbox data 256,000 bytes; collection length 10,000; value depth 32; source size 32,000 bytes; audit data 1,000,000 bytes; tool-call concurrency 8). These are not part of the public contract. + +## Diagnostics + +Failures are data: + +| Kind | Meaning | +| --- | --- | +| `ParseError` | Source is empty or cannot be parsed. | +| `UnsupportedSyntax` | Parsed JavaScript is outside the supported subset. | +| `UnknownTool` | A program referenced a tool the host did not provide. | +| `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. | +| `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. | +| `InvalidDataValue` | Program data violated the plain-data or size contract. | +| `OperationLimitExceeded` | Interpreter work exceeded the internal operation budget. | +| `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. | +| `AuditLimitExceeded` | Retained call metadata exceeded the internal audit budget. | +| `TimeoutExceeded` | Execution exceeded `timeoutMs`. | +| `ToolFailure` | A tool refused or failed. | +| `ExecutionFailure` | The program threw or another execution error occurred. | + +Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`: + +```ts +import { toolError } from "@opencode-ai/codemode" + +run: ({ id }) => + authorized(id) + ? loadOrder(id) + : Effect.fail(toolError("Order is unavailable")) +``` + +Only the supplied message is model-visible. The optional cause is never returned in `ExecuteResult`; hosts should perform any required internal logging before crossing this boundary. + +## Authority Boundary + +CodeMode confines programs to the supplied tool tree, but it does not decide what those tools may do. + +The host owns: + +- Authentication and authorization. +- Tool selection and immutable scope. +- Credentials and network clients. +- Persistence, idempotency, approval, and durable side effects. +- Logging and redaction policy. + +CodeMode owns: + +- Parsing and interpreting the supported subset without `eval`. +- Schema boundaries around tool calls. +- Plain-data copying and blocked prototype members. +- Resource limits, call accounting, and normalized diagnostics. +- Model-facing tool discovery and instructions. + +A program cannot gain authority through prose or generated code. It can only exercise authority already present in the supplied tools. Do not expose a broad tool and expect the prompt to restrict it. + +## Laws + +The public contract is guided by these equivalences: + +- `CodeMode.execute({ ...options, code })` is equivalent to `CodeMode.make(options).execute(code)`. +- `CodeMode.make(options).agentTool().execute({ code })` is equivalent to `CodeMode.make(options).execute(code)`. +- `CodeMode.make(options).agentTool().description` equals `CodeMode.make(options).instructions()`. +- A tool implementation is not invoked unless its input has decoded successfully. +- A tool result is not visible to the program unless its output has decoded and crossed the plain-data boundary successfully. +- Unknown host failures do not become model-visible diagnostics; `ToolError` is the explicit safe-message channel. +- Host interruption remains interruption rather than an `ExecuteFailure`. + +## Non-Goals + +- Generic permission prompts or approval workflows. +- Durable pause/resume, replay, or storage adapters. +- Exactly-once external side effects. +- Application authorization or product policy. +- A filesystem or process sandbox for arbitrary JavaScript. +- Compatibility with the full JavaScript language or npm ecosystem. + +Applications that need approval or durable consequences should model those above CodeMode and expose only the currently authorized tools. + +## Testing + +From the package directory: + +```sh +bun test +bun run typecheck +``` + +The direct suite covers public projections, discovery, schema boundaries, diagnostic sanitization, resource limits, tool-call observation, and interruption. diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md new file mode 100644 index 000000000000..83aaf57422f7 --- /dev/null +++ b/packages/codemode/codemode.md @@ -0,0 +1,563 @@ +# CodeMode — Status, Decisions, and Remaining Work + +This document is the working plan for `@opencode-ai/codemode` and its OpenCode integration. +It captures every locked decision, everything already implemented, and a detailed TODO of what +remains — enough context that someone (human or agent) can pick up any item cold. + +Tracking issue: https://github.com/anomalyco/opencode/issues/34787 +Working branch: `codemode-v2` (base: `dev`) + +--- + +## 1. What this is + +CodeMode gives a model one `execute` tool that runs JavaScript/TypeScript programs against a +tree of schema-described tools (`tools..(input)`), instead of exposing dozens +of MCP tools individually. The point is **control flow**: sequencing, filtering, and composing +tool calls in one program instead of round-tripping through the agent loop, plus not flooding +the context window when users connect many MCP servers. + +Architecture split (locked): + +- **`packages/codemode` (`@opencode-ai/codemode`)** — the generic, host-agnostic runtime: + a hand-rolled, Effect-native, tree-walking interpreter over acorn ASTs (TypeScript stripped + via `typescript`'s `transpileModule`), the tool runtime/data boundary, discovery/search, and + `Tool.make`. It knows nothing about OpenCode, MCP, permissions, or rendering. +- **`packages/opencode`** — the OpenCode integration: an MCP adapter that converts MCP tool + definitions into `Tool.make(...)` definitions, permission gating, host-side attachment + collection, the agent-facing `execute` tool, and TUI progress rendering. + +This package was seeded from the experiments workspace implementation +(`experiments/agents/packages/codemode`, package `@agents/codemode`) and then modified here. +The older vendored interpreter in `packages/opencode/src/session/rune/` was superseded by this +package and was **deleted** in Wave 3 (done, see below). + +--- + +## 2. Locked decisions + +From issue #34787 and design discussion. Do not relitigate these casually. + +### Core direction +- Generic CodeMode lives in its own package: `@opencode-ai/codemode` (repo scope convention; + the issue's `@opencode/codemode` name was normalized to the `@opencode-ai/*` convention). +- **Keep the hand-rolled interpreter.** No QuickJS/V8/sandbox-engine dependency. We own and + test the whole surface; the model only needs orchestration syntax, not a full runtime. +- Naming: `CodeMode`, `Tool`, `ToolError`, `UnknownTool` (diagnostic kind), `$codemode` + reserved discovery namespace. (Historical names — "rune", "capability" — are dead.) +- Existing OpenCode core tools (bash/edit/patch/...) stay registered normally for v1. + CodeMode covers MCP tools, user-registered tools, and deferred tools only. +- Test runner is `bun test`; typecheck is `tsgo --noEmit` (repo conventions). Not vitest. +- **Never reference external prior-art implementations** (other companies' code-execution + products/blog posts) in code, comments, commit messages, or docs in this repo. + +### MCP / tools +- The MCP adapter lives in OpenCode, not here. It converts MCP definitions into ordinary + `Tool.make(...)` definitions and hands CodeMode a plain tool tree. +- Permissions stay in the OpenCode adapter (each tool's `run` wraps the permission ask). + CodeMode stays dumb — no permission model in this package. +- Namespace collisions: last write wins (plain JS object override). No `tools.mcp.*` prefix, + no `_2` suffixing, no cleverness. OpenCode groups flat `server_tool` MCP names into + `tools..` namespaces before handing them over. + +### Discovery / search +- **Search only — no separate `describe`.** `tools.$codemode.search({ query?, namespace?, + limit? })` over the final tool tree, owned by this package. +- Search result item shape: `{ path, description, signature }`. The `signature` string embeds + the full input/output TypeScript types, so separate `input`/`output` fields were dropped as + redundant (the issue listed them; we resolved the redundancy in favor of signature-only). + Result `path`s carry the `tools.` prefix (post-wave fix) so each is directly usable as the + call site; the internal `ToolDescription.path` stays unprefixed. +- Default limit: **10** (done). Exact-path lookup goes through search too: a query equal to a + tool path (with or without the `tools.` prefix) returns that tool alone (done). +- Signatures render **native payloads**: `Promise`, NOT `Promise>`. + There is no result envelope; attachments never appear in return types (they are collected + host-side, see below). +- Tools without an output schema render `unknown` as their return type. + +### Schemas / Tool.make +- `Tool.make` carries rich metadata so search can render real signatures. +- Support **Effect Schema** (first-class, validating) and **JSON Schema** (initially + render-only — used for TypeScript rendering; the adapter may validate on its own). Leave + room for Standard Schema later. +- Tool implementations are **Effect-based** for v1 (`run` returns `Effect`). Promise + normalization for plugin authors can come later. + +### Attachments / output +- **No `output.text/file/image` API in v1.** (Deleted in Wave 2.) +- Tool calls return native structured payloads into the sandbox. Files/images emitted by + child tools **never enter the sandbox** — the OpenCode adapter strips and accumulates them + host-side as calls happen, then returns them on the outer `execute` tool result as ordinary + tool-result attachments (OpenCode already has `Tool.ExecuteResult.attachments` → vision + plumbing in `message-v2.ts`). +- No base64 in CodeMode values, ever. The model routes nothing; it can't accidentally dump + image bytes into context or drop attachments. + +### Runtime behavior +- Public limits are simple: `{ timeoutMs, maxToolCalls, maxOutputBytes }`. The other knobs + (maxOperations, maxDataBytes, maxValueDepth, maxCollectionLength, maxSourceBytes, + maxAuditBytes, maxConcurrency) are internal constants, still reachable for tests via the + `@internal` `InternalExecutionLimits` type (exported from `codemode.ts`, not from the package + index). (Done in Wave 2.) +- CodeMode owns truncation of its own returned output (`maxOutputBytes`). OpenCode's native + tool-output truncation (50KB / 2000 lines in `tool.ts` + `truncate.ts`) stays on as the + outer safety net for now; we may remove that layering later. +- Pure-JS built-ins only. **No ambient authority**: no fs, child processes, network/fetch, + process/env, or timers in v1. The agent has the bash tool for that. +- Forgiving JS semantics are locked (see §3, Wave 1a/1b-i) — missing props read `undefined`, + `typeof` never throws, NaN/Infinity flow in-sandbox, etc. +- `console.*` is captured into `logs` on the result; the host appends them to model-facing + output. Not a tool call; costs no tool budget. +- Simple tool-call **start/end hooks** for nested progress: `onToolCallStart({ index, name, + input })` and `onToolCallEnd({ index, name, input, durationMs, outcome, message? })`. + Interrupted calls fire no end event. No `CurrentToolCall` context service (removed in + Wave 2). + +--- + +## 3. Current status (what is already done on `codemode-v2`) + +Waves 0–5 and the post-wave fixes below are committed on `codemode-v2` (two commits: the +generic package, then the OpenCode integration). Verification: from +`packages/codemode`, `bun test` (156 pass / 0 fail across +`codemode/parity/stdlib/promise/enumeration`) and `bun run typecheck`; from +`packages/opencode`, `bun run typecheck` and `bun test test/session/` (all green — the +adapter suites are `code-mode.test.ts`, 35 tests, and `code-mode-integration.test.ts`, +16 tests). + +### Wave 0 — scaffold (done) +- `packages/codemode` created from the experiments implementation: `src/{index,codemode,tool, + tool-error,tool-runtime}.ts`, README, AGENTS.md, tests. +- `package.json`: name `@opencode-ai/codemode`, deps `acorn@8.15.0`, `typescript: catalog:`, + `effect: catalog:` (both repos pin effect `4.0.0-beta.83`; opencode's effect patch only + touches `unstable/httpapi`, which this package doesn't use). +- Tests converted vitest → `bun:test`. Only src change from verbatim: the `CurrentToolCall` + Context.Service key string renamed to `@opencode-ai/codemode/CurrentToolCall`. + +### Wave 1a — forgiving JS semantics (done) +Ported from the old opencode rune work; `test/parity.test.ts` (24 tests) is the acceptance +spec. The seeded interpreter was deliberately strict; these behaviors replaced that: + +- **H1**: NaN/Infinity flow as in-sandbox values (`copyIn` admits them; `NaN`/`Infinity` are + bindable globals; `charCodeAt` returns real NaN). Normalized to `null` only at the data + boundary (`copyOut` — single chokepoint for final results AND tool-call arguments), matching + `JSON.stringify`. Guards like `Number.isNaN(x)` / `parseInt(x) || 0` work. +- **H2/H3**: unknown property reads on strings/numbers/arrays → `undefined` (incl. under + `?.`), instead of throwing. This was the real-transcript failure: models write + `result?.login ?? result` against JSON-string tool results. +- **H4**: `typeof undeclaredIdentifier` → `"undefined"` (short-circuits before resolution). +- **H5**: `Boolean`/`String`/`Number` accepted as array callbacks (`filter(Boolean)`). +- **H6**: `{...null}` / `{...undefined}` object spread is a no-op. Array spread of + null/undefined still throws (real JS throws too). + +### Wave 1b-i — stdlib value types: Date, RegExp, Map, Set (done) +`src/values.ts` holds `SandboxDate/SandboxRegExp/SandboxMap/SandboxSet` (own module so both +`codemode.ts` and `tool-runtime.ts` import without a cycle). Design: + +- Opaque-by-default: all four join `isRuntimeReference`, with explicit carve-outs (member + access allowlists, Date in binary/unary ops, Map/Set in spread/for...of, console formatting, + byte accounting in `runtimeValueBytes`, `containsOpaqueReference` for operator guards). +- **JSON semantics at every boundary and checkpoint**: Date → ISO string (invalid → null), + RegExp/Map/Set → `{}`. `copyIn` also converts host `Date`/`RegExp`/`Map`/`Set` instances the + same way (a host tool may legitimately return them). +- Date: `Date.now/parse/UTC`, `new Date(epoch|string|components)`, getters + UTC variants, + `end - start`, `a < b`, `+date`; `toString` is ISO for cross-host determinism. +- RegExp: literals + `new RegExp`, `test`/`exec` (stateful `lastIndex` for `g`), string + `match/matchAll/replace/replaceAll/split/search`. Match results are plain arrays carrying + `index`/named `groups` as own properties (enabled by a general array own-property read fix); + `input` omitted deliberately. Function replacers unsupported (clear error). Patterns run on + the host engine — catastrophic backtracking is bounded only by `timeoutMs` (accepted, in + README). +- Map/Set: full method sets; `keys/values/entries` return **arrays** (not iterators); + `for...of` + spread work; `Object.fromEntries(map)`, `Array.from(map|set)`; SameValueZero + keys (NaN findable); mutations maintain incremental byte totals and enforce + `maxCollectionLength`/`maxDataBytes`. +- Rode along, same spirit: `typeof` never throws for any value (`typeof fn` → `"function"`), + `!` works on any value, `for...of` over strings, `{...sandboxValue}` no-op, template + interpolation renders `/regex/` and ISO dates directly. + +### Wave 2 — API layer (done) +The package's public contract, reshaped for the Wave 3 adapter. 101 tests / 0 fail after this +wave; both packages typecheck clean. + +- **`Tool.make` schema flexibility** (`src/tool.ts`): `input`/`output` each accept an Effect + Schema (validating, decoded both directions as before) OR a raw JSON Schema document + (render-only — no validation, values pass through; rendering handles `$defs`/`definitions` + + `$ref`). `output` is **optional** → signature renders `Promise` and the host + result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from + `tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/ + `jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there + anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty + `Schema.Struct({})` renders as `{ } | Array` (effect's JSON Schema emission) — + cosmetic, fixed in Wave 4. +- **`output.*` API deleted**: `OutputItem`(+Schema), result `output` fields, the `output` + global/namespace dispatch, `invokeOutput`/`outputItem`/helpers, interpreter output fields, + instructions line, README section, seeded tests. AGENTS.md keeps a rephrased + future-design note (channel name stays `output` if it ever returns). +- **Hooks**: `CurrentToolCall` removed entirely (class, provideService, `Services` Exclude + special-casing, index export). `onToolCall` → `onToolCallStart({ index, name, input })` + + `onToolCallEnd({ index, name, input, durationMs, outcome: "success"|"failure", message? })`. + End fires symmetrically via `Effect.tap`/`tapError` around the settling portion (host run + + output decode + boundary copy; search too — its post-record body is wrapped in `Effect.try` + so failures are typed and observable). `message` is the model-safe failure message + (`ToolError`/`ToolRuntimeError` message, else "Tool execution failed"). Interrupted calls + fire no end event (timeout kills the whole execution anyway). +- **Limits collapse**: public `ExecutionLimits` = `{ timeoutMs?, maxToolCalls?, + maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). All other knobs are internal defaults + (unchanged values) on `ResolvedExecutionLimits`, still validated + reachable through the + `@internal` `InternalExecutionLimits` type exported from `src/codemode.ts` only — tests pass + internal knobs via typed variables (assignment from a wider variable skips excess-property + checks). +- **`maxOutputBytes` truncation** (CodeMode-owned, never fails): applied via `boundOutput` in + a final `Effect.map` over every result path (success/timeout/normalized failure). Oversized + serialized values become truncated text + ` [result truncated: N bytes exceeds the M-byte + output limit; return a smaller value]`; logs keep leading lines within the remaining budget + + `[logs truncated: showing K of N lines]`; result gains `truncated: true` (also added to + `ExecuteResultSchema`). UTF-8-safe truncation (no split code points). The in-sandbox + `maxDataBytes` check on the final result still throws first if the raw value exceeds it. +- **Search polish**: default limit 12 → **10** (`defaultSearchLimit`); exact-path lookup — a + trimmed query equal to one tool path (optionally `tools.`-prefixed) returns that tool alone + (`total: 1`), bypassing ranking. Tokenization/ranking/shape unchanged. + +### Wave 3 — OpenCode MCP adapter (done) +`packages/opencode/src/session/code-mode.ts` rewritten as a thin adapter over this package; +the vendored rune interpreter is gone. Same `define(mcpTools, mcpDefs, servers)` signature, so +`tools.ts` gating (flag on + MCP tools exist → single `execute` tool, early-return suppresses +per-MCP registration; MCP resource tools unaffected) is unchanged. + +- **Tool tree**: `groupByServer` (longest-sanitized-prefix, ported) groups flat `server_tool` + keys into `CatalogEntry`s carrying the raw MCP `inputSchema`/`outputSchema` as render-only + JSON Schema; `toolTree` turns each into `Tool.make({ description, input, output?, run })` + under `tools..`. The agent-facing description is + `CodeMode.make({ tools }).instructions()` over a preview tree (placeholder runs, never + invoked) — so signature rendering, the inline-vs-search switch, and `$codemode.search` + availability all come from this package and stay consistent with execution. +- **`run` path**: per-child permission ask first (`ctx.ask({ permission: entry.key, patterns: + ["*"], always: ["*"] })`, exactly the old gating; approving `execute` approves no child). + Denials and host failures are mapped to `toolError(message)` so they surface as safe, + catchable in-program failures (MCP `isError` text propagates as `e.message`; without this + they'd be sanitized to "Tool execution failed"). Dispatch reuses the ai-sdk wrapper from + `catalog.convertTool` (`entry.tool.execute!`), which owns callTool timeouts/progress-reset. +- **Result shaping** (`toSandboxResult`): prefer `structuredContent`; else joined text + content; media (image/audio/resource blob/resource_link) NEVER enters the sandbox — blocks + are stripped into a per-execution `Attachment[]` accumulator, and a media-only result + becomes a marker payload (`"[1 image attached to the result]"`, noun/count adjusted). An + MCP-shaped result with nothing extractable becomes `null`; non-MCP values pass through. + No handles, no `Result` envelope, no base64 in the sandbox, no raised `maxDataBytes`. +- **Execute result**: `{ output: formatValue(value) + trailing "Logs:" section (success AND + error — logs are plain pre-formatted lines now), attachments: accumulated }` through the + existing `Tool.ExecuteResult.attachments` → `message-v2.ts` vision plumbing; attachments + ride on both success and error results. Diagnostic `suggestions` not already contained in + the message are appended to error output. Native outer truncation stays on (adapter never + sets `metadata.truncated`); CodeMode's own `maxOutputBytes` (32 KB default) cuts first. + Limits: `{ timeoutMs: 30_000 }` (matches the default MCP request timeout), rest defaults. +- **Progress**: `onToolCallStart`/`onToolCallEnd` → `ctx.metadata({ toolCalls })` with + `{ tool, status: running|completed|error, input? }` per call index — the exact shape the + TUI `Execute` component (`packages/tui/src/routes/session/index.tsx`) already renders. + `$codemode.search` calls stream through the same channel. +- **Deletions/deps**: `src/session/rune/` (all five files) and + `test/session/rune-parity.test.ts` (superseded by this package's `test/parity.test.ts`) + deleted; `acorn` removed from opencode deps, `typescript` moved back to devDependencies, + `"@opencode-ai/codemode": "workspace:*"` added; `bun install` run (lockfile updated). +- **Tests**: both opencode suites rewritten against the adapter design — + `code-mode.test.ts` (34: grouping, description/signature rendering incl. the large-catalog + search fallback, execution, permission flow + denial, metadata streaming, attachment + accumulation + media-only marker, logs on success/error, truncation marker, + `toSandboxResult`/`formatValue`/`withLogs` units) and `code-mode-integration.test.ts` + (16: real in-memory MCP server; native structured results, attachment accumulation, isError + propagation, logs, permissions, live metadata). Old envelope/attachment-handle/`$rune` + describe/`renderType`/`rankTools` tests died with the old design (58+17+24 → 34+16). + +### Wave 4 — instructions/prompting + polish (done) +Instructions are now the budgeted-catalog + prompting-guidance form; verified e2e against a +real MCP config. Package still 101 tests / 0 fail; opencode adapter suites still 34 + 16; both +packages typecheck clean. + +- **Budgeted catalog** (`discoveryPlan` in `tool-runtime.ts`): the all-or-nothing + inline/search modes are gone — `DiscoveryMode` deleted, `DiscoveryOptions` is just + `{ maxInlineCatalogBytes? }` (default 16,000 UTF-8 bytes). Port of the old opencode + `describe()` `PREVIEW_BUDGET` algorithm, adapted to `ToolDescription`: every namespace is + ALWAYS listed with its tool count; full signature lines + (` - // `) are inlined + cheapest-first (line byte length, path tiebreak) within each namespace, namespaces processed + alphabetically; once one line does not fit, inlining stops for every remaining namespace + (counts only), exactly like the ported algorithm. The header states comprehensiveness + precisely: "Available tools (COMPLETE list — …)" vs "Available tools (PARTIAL — N of M + shown; find the rest with tools.$codemode.search)"; namespace labels are `(N tools)` / + `(N tools, K shown)` / `(N tools, none shown)`. An empty tree renders "No tools are + currently available." +- **Search always registered** (documented decision): `DiscoveryPlan.searchIndex` is required + and built unconditionally (new exported `ToolRuntime.searchIndex(tools)`; `SearchEntry` type + exported); `CodeMode.execute` (one-shot) passes it too, preserving the + `execute`≡`make().execute` law. A speculative `tools.$codemode.search` call on a small + catalog now succeeds instead of `UnknownTool`, and unknown-tool suggestions always point at + search. Search is *advertised* in the instructions only when the inlined list is PARTIAL, + keeping small-catalog instructions tight. +- **Prompting content** in `instructions()`, mapping 1:1 to the §5 transcript failures: + parse-string-results-as-JSON, return-small, console-for-intermediates, and + read-the-description-before-calling guidance. (The flat prose layout this wave produced + was later replaced wholesale by the markdown-section restructure — see Post-wave fixes — + which also deleted this wave's worked example.) +- **Cosmetic renderer fixes** (`renderSchema` in `tool.ts`): an object schema with no + properties renders `{}` (was `{ }`), and the empty `Schema.Struct({})` emission + (`anyOf: [{ type: "object" }, { type: "array" }]`, no properties/items) collapses to `{}` + (was `{ } | Array`). +- **Tests**: 4 package discovery tests rewritten for the budgeted behavior (COMPLETE small + catalog + search-still-registered; PARTIAL at budget 0; cheapest-first selection + + per-namespace labels + budget-exhaustion stopping later namespaces; mode-validation + assertion dropped); 3 opencode description assertions updated (COMPLETE/PARTIAL headers, + namespace labels, `(input: {})` rendering, cheapest-first op_0 shown / op_149 not). +- **E2E (verified, headless)**: from the repo root with `OPENCODE_EXPERIMENTAL_CODE_MODE=1`, + the scratch `.opencode/opencode.jsonc` (context7, github, playwright, sentry, memory, + sequential-thinking; left uncommitted/as-is), and `bun packages/opencode/src/index.ts run + --dangerously-skip-permissions -m opencode/claude-sonnet-4-5 "..."`. Confirmed: a single + `execute` tool registered alongside core tools (per-MCP registration suppressed; MCP + resource tools unaffected); the live description read back as "Available tools (PARTIAL — + 56 of 88 shown; find the rest with tools.$codemode.search):" with correct per-namespace + labels (context7/github/memory fully shown; playwright/sentry/sequential-thinking "none + shown" — alphabetical exhaustion); programs executed with in-program `$codemode.search` + calls and returned the correct answer. NOT verified e2e (headless only; covered by + unit/integration tests instead): TUI child-call rendering, attachments becoming visible + images, output truncation. + +### Wave 5 — Promise generalization (done) +First-class promise values in the interpreter; the direct-tool-call-only `Promise.all` +restriction (and its bespoke AST checks) is gone. Package suite is 136 tests / 0 fail (35 new +in `test/promise.test.ts`); adapter suites and both typechecks unchanged/green; the opencode +adapter needed **no changes**. + +- **Decision: eager fork** (`const p = tools.a.b(x)` starts the call immediately on a + supervised child fiber; `await p` observes its settlement). Chosen over lazy because: + (1) it's spec-faithful — JS promise work starts at call time, so + `const a = t1(); const b = t2(); return [await a, await b]` gets real parallelism instead of + silently sequential awaits; (2) run-once is free — a fiber settles exactly once and + `Fiber.await` is idempotent, so `await p` twice or `Promise.all([p, p])` can never re-invoke + the tool (lazy needs a deferred/latch to match); (3) effect's structured concurrency does the + hard part — `Effect.forkChild` children are auto-supervised (interrupted when the parent + fiber exits) and `Effect.timeoutOrElse` is `raceFirst`, which runs the program on its own + raced fiber, so forked calls cannot escape the timeout (tested: in-flight forks are + interrupted, awaited or abandoned, direct or inside `Promise.all`). +- **Mechanics**: `SandboxPromise` in `values.ts` (fiber-backed for tool calls; fiberless + `immediate` effect for `Promise.resolve`/`reject`). Forks run + `semaphore.withPermit(invoke)` with `startImmediately: true` — a per-execution + `Semaphore.makeUnsafe(maxConcurrency)` (8) caps live calls (the "Effect.all or equivalent" + cap lives where the work is, so combinator joins can be sequential without losing + parallelism), and the budget/audit charge (`recordCall`) plus `onToolCallStart` fire at the + call site before any await. `await` of a non-promise is a passthrough no-op; a returned + top-level promise resolves like an async-function return (`return tools.a.b(x)` works + without await). +- **Promise combinators are normal functions over values**: `Promise.all`/`allSettled`/`race` + accept any array (or spreadable collection) mixing promises and plain data — inline, built + beforehand, spread, nested in variables. `allSettled` yields + `{ status: "fulfilled", value } | { status: "rejected", reason }` with reasons produced by + the same `caughtErrorValue` helper the `catch` binding uses (factored out of + `evaluateTryStatement`). `race` resolves/rejects with the first settlement and interrupts + losing in-flight calls; awaiting an interrupted loser afterwards is a catchable program + failure ("interrupted because another value settled a Promise.race first"), while any other + interrupt-only settlement keeps propagating as interruption (preserving the + host-interruption law). `Promise.resolve` flattens promises; `Promise.reject` rejects with + the reason via `ProgramThrow`. +- **Opaqueness/boundaries**: promises are runtime references — `typeof` → `"object"` (real JS), + operators reject them, `copyIn` raises an await-hinting `InvalidDataValue` ("contains an + un-awaited Promise; await tool calls (…) before using their results") for results, tool + arguments, and `JSON.stringify` instead of `{}`. Property access on a promise is a + deliberate error (not the forgiving `undefined`): `.then/.catch/.finally` → + `UnsupportedSyntax` pointing at `await` + try/catch; anything else → "await it first". + `new Promise(...)` → UnsupportedSyntax ("tool calls already return promises"); + `Promise.` lists the five available statics. `console.log(p)` prints + `[Promise (await it to get its value)]`. +- **Program-end drain**: on successful completion the interpreter awaits still-running + un-awaited fibers (like a runtime waiting on in-flight I/O at exit), so fire-and-forget + calls complete deterministically; a failure nobody could have handled surfaces as an + "Unhandled rejection from an un-awaited tool call: …" diagnostic (kind preserved, + suggestion says to await) — keeping pre-wave failure visibility for un-awaited + statement-position calls. Settlement observation (await/all/allSettled/race) marks a + promise handled; failed executions skip the drain and children are interrupted by + supervision. +- **Deletions/updates**: `evaluatePromiseAll`, `evaluateParallelMap`, `isToolCallExpression`, + `isToolPath`, `forkForParallelCallback`, and `PromiseAllReference` deleted + (`PromiseMethodReference` over `all/allSettled/race/resolve/reject` replaces it); + `supportedSyntaxMessage`, the two instructions lines in `tool-runtime.ts`, and README + "Supported Programs" rewritten for the new surface. +- **Known divergences (deliberate)**: `p === q` on promises throws the operators-need-data + diagnostic instead of comparing identity; `{...promise}` errors instead of JS's silent `{}`; + a per-iteration `await` inside `items.map(async (i) => await tools.x(i))` runs sequentially + (interpreter callbacks compose synchronously) — the parallel idiom is mapping to un-awaited + calls and awaiting `Promise.all`, which the instructions show. + +### Post-wave fixes + +- **Key enumeration: `Object.keys(tools)` + `for...in` (done).** Motivating transcript: a + model tried to enumerate tool namespaces with `Object.keys(tools)` (failed with the generic + "Object.keys input must contain plain objects only." — `tools` is a `ToolReference`, not + plain data) and then `for (const key in tools)` ("Syntax 'ForInStatement' is not + supported"), and had to fall back to guessing namespace names from the instructions — + defeating discovery. Fixes, all in this package: + - `ToolRuntime.make` now returns a `keys(path)` capability (`namespaceKeys` in + `tool-runtime.ts`) threaded into the `Interpreter` alongside `invoke` — the interpreter + still never holds the host tool tree. `Object.keys(tools)` yields the top-level namespace + names (never `$codemode`, which is virtual — but `Object.keys(tools.$codemode)` yields + `["search"]`), `Object.keys(tools.ns)` the names at that node; a callable tool leaf + enumerates as `[]` (like `Object.keys` of a JS function); an unknown path throws an + `UnknownTool` diagnostic suggesting `Object.keys(tools)` and `$codemode.search` (matching + call-time unknown-tool behavior rather than silently returning `[]`). + - `Object.values`/`Object.entries` (and every other `Object.*` helper) on a tool reference + now fail with "…not plain data. Use Object.keys(tools) for names, or + tools.$codemode.search({ query }) for signatures." instead of the generic message. + - `Object.keys(array)` returns index strings (`["0", "1", ...]`) like real JS (was a + Backlog item). + - `for...in` (ForInStatement) iterates own enumerable string keys of plain objects, index + strings of arrays, and namespace/tool names of tool references — sharing the interpreter's + `enumerableKeys` helper with the `Object.keys` tool path. const/let declarations and bare + identifiers bind the key; break/continue work; iterations charge the operation budget like + the other loops. Anything else (strings, Map/Set, numbers, null, ...) is a clear error + suggesting `for...of` or `Object.keys` — deliberately smaller than real JS (which yields + indices for strings and zero iterations for Maps/Sets/null). + - `supportedSyntaxMessage`, the instructions loops line, and README "Supported Programs" + mention the new surface; tests in `test/enumeration.test.ts` (15, incl. the exact + transcript program) plus one adapter-level assertion that `Object.keys(tools)` returns + MCP server names. + +- **Search ranking, namespace scoping, prefixed result paths (done).** + Motivation: the Wave 4 e2e run showed a model retrying calls because search-result paths + lacked the `tools.` prefix (a Backlog item), and the word-set ranker missed + parameter-name and partial-word queries. Fixes: + - **Ranking ported from the pre-rebuild implementation** (the `searchTextFor`/`tokenize`/ + `rankTools` algorithm in `packages/opencode/src/session/code-mode.ts` at git HEAD), + replacing the word-set ranker in `tool-runtime.ts`. Searchable text per tool = path + + description + input-schema property names + their `description` strings — extracted by + the new `inputProperties` helper in `tool.ts` (Effect Schemas via + `Schema.toJsonSchemaDocument`, the same emission signature rendering uses; JSON Schemas + read `properties` directly, resolving a trivial top-level `$ref`; try/catch falls back to + path + description). Queries tokenize on camelCase boundaries + non-alphanumeric + separators (empties and `*` dropped). Additive per-term scoring: exact path or + path-segment match 20, path substring 8, description substring 4, searchable-text + substring 2; summed across terms, filtered to score > 0, sorted score desc then path asc. + An empty query now browses ALPHABETICALLY by path (was declaration order). Kept: + `{ path, description, signature }` result items, default limit 10, exact-path instant + lookup, input validation errors. + - **Namespace scoping**: `tools.$codemode.search({ query?, namespace?, limit? })` — + `namespace` (validated as a string when provided) filters `SearchEntry`s to one top-level + namespace before ranking; `{ query: "", namespace: "github" }` lists that namespace + alphabetically. `searchSignature` updated. + - **Prefixed result paths**: search-result `path`s are `tools.github.list_issues` style, + directly usable as the call site. Internal `ToolDescription.path` stays unprefixed; only + the search RESULT items are prefixed. Exact-path queries accept both forms, as before. + - **Instructions** (`discoveryPlan`): an explicit calling-convention line and a browse + hint on the search advertisement (both since absorbed into the `## Rules` section by + the instructions restructure below). + - **Tests**: package search/discovery tests updated (prefixed paths, alphabetical browse) + plus new coverage for namespace scoping, parameter-name matching, partial-word substring + matching, alphabetical empty-query order, and prefixed exact-path lookup; one adapter + assertion updated to the prefixed path (suites stay 35 + 16, green). + +- **Instructions restructure: markdown sections, placeholder-only call forms (done).** + The flat prose instructions (which mixed a real catalog tool with fabricated result + fields in the worked example) are replaced by structured markdown in `discoveryPlan`, + ordered so the workflow sits at the top (the least likely part of a long description to + be truncated or skimmed away) and the catalog at the bottom: + - **Intro** (2 lines): "Write a CodeMode program… Return code only." + "Execute + JavaScript in a confined runtime with access to the tools listed below under + `tools.*`." (the second line drops the tools clause when the tree is empty). + - **`## Workflow`**: numbered steps — find a tool via `tools.$codemode.search` → read + the `{ path, description, signature }` matches → call by path → `typeof res === + "string" ? JSON.parse(res) : res` → return only the needed fields. When the catalog is + COMPLETE the search/read steps collapse into "Pick a tool from the list under + `## Available tools`" and the steps renumber (4 instead of 5). + - **`## Rules`**: call-by-exact-path; TEXT-is-JSON → JSON.parse; return small (never raw + payloads); filter/aggregate large collections in code instead of per-item round-trips; + console.log/warn/error/dir/table for intermediates; `Promise.all` parallelism (no + .then/.catch — await + try/catch); `Object.keys(tools)`/`for...in` enumeration; + browse-one-namespace via search (PARTIAL only); and host-side media handling (files/ + images never enter the program; a media-only call yields a small text marker — wording + verified against the adapter's `toSandboxResult`/`mediaMarker`). + - **`## Syntax`**: the dense syntax lines unchanged, minus the Promise.all and console + lines (moved into Rules) and the `for (const ns in tools)` fragment (redundant with + the enumeration rule). + - **`## Available tools`**: the budgeted catalog unchanged, with the COMPLETE/PARTIAL + header merged into the section heading (no trailing colon); the search-signature + advertisement follows when PARTIAL (its description-reading and browse clauses moved + to Workflow/Rules). + - Every call form in Workflow/Rules uses explicit `.`/`` + placeholders — the example builder that derived a worked example from the first inlined + catalog tool (`exampleArguments` + the example-selection machinery) is DELETED, so no + real catalog tool is cherry-picked into examples and no fabricated names or fields + appear anywhere in the instructions. Zero tools keep "No tools are currently + available." under minimal sections (intro + Syntax + Available tools). + - **Tests**: the package worked-example test replaced by section-structure/placeholder + assertions (section order; JSON.parse + return-small rules present; no + `total_count`/`list_issues`/real-tool example lines; browse hint only when PARTIAL; + zero-tool minimal sections) — 156 pass / 0 fail; adapter suites gain the same + assertions on the built description (still 35 + 16, green). + +--- + +## 4. Remaining work (detailed TODO) + +### Backlog / loose ends (non-blocking, any order) +- [ ] Medium-tier JS parity items deferred from the original audit: caught errors are plain + `{ name, message }` objects, not `instanceof Error` (and `Error` isn't a value — + `x instanceof Error` is unsupported syntax); `splice` (still a + "rewrite using map/filter" hint) and array `entries()/keys()/values()`; + `localeCompare`/`normalize`/`trimLeft`/`trimRight`; friendlier regex-y error messages. +- [ ] `console.log(NaN)` prints `"null"` (goes through the boundary chokepoint) — could + special-case number formatting in `formatConsoleArgument`. +- [ ] Sandbox values nested inside logged containers print `[CodeMode reference]` + (`console.log({ m: map })`) — could deep-format instead. +- [ ] `Date`/`Map`/`Set`/`RegExp` values passing through `Object.*` helpers and coercion + checkpoints take their JSON forms (e.g. `Object.values({ d: date })` yields the ISO + string, not the Date). Deliberate (documented in README); revisit only if it bites. +- [ ] Decide whether OpenCode's outer native truncation gets disabled for `execute` once + `maxOutputBytes` exists (issue says CodeMode reimplements it; "maybe we kill \[the outer + one\] later"). +- [ ] Two timing-based tests in `test/promise.test.ts` (a `< 150ms` parallelism bound and + 100ms-timeout interruption) could flake on a very slow CI host; primary assertions are + counter-based, but loosen margins if they ever flake. +- [ ] Interactive e2e still unverified (Wave 4 verified headless only): TUI child-call + rendering via `metadata.toolCalls`, and stripped media becoming visible images through + `Tool.ExecuteResult.attachments`. Run one interactive session before merging. +- [x] Commit hygiene: Waves 0–5 + post-wave fixes committed on `codemode-v2` as two units + (the generic package; the OpenCode integration). Future work: commit only when + explicitly asked; push with `--no-verify` per repo convention. The scratch + `.opencode/opencode.jsonc` stays uncommitted. + +--- + +## 5. Context and gotchas for whoever picks this up + +- **Motivating failure (why forgiving semantics + prompting matter):** in a real transcript, + the model wrote `me.result?.login ?? me.result` where the tool result was a JSON *string* — + the old strict interpreter threw (`String property 'login' is not available`); then the + model returned a raw 105KB payload, which native truncation dumped to a file, costing a + subagent round-trip to extract one number. Interpreter forgiveness stops the crashes; + Wave 4 prompting stops the payload dumping. Both are needed. +- Realistically **all MCP tools render `Promise`** (no outputSchema), so the + instructions prose is the only lever for result-shape behavior in the dominant case. +- **`copyIn` has two roles**: host↔sandbox boundary AND intra-sandbox data checkpoint + (`boundedData`). Sandbox value types are converted to JSON forms wherever it runs — that's + the documented model. If you add a new value type, follow the Wave 1b-i pattern: class in + `values.ts`, opaque-by-default via `isRuntimeReference`, explicit carve-outs, real byte + accounting in `runtimeValueBytes`, JSON form in `copyIn`, console formatting, tests. +- The interpreter throws synchronously inside `Effect.gen`/`Effect.sync` freely; everything is + normalized by `catchCause` → `normalizeError` into `Diagnostic` data. Program failures are + **data, never Effect failures**; only interruption propagates. +- `parseProgram` wraps source in `async function __codemode__() { ... }`, transpiles TS, then + slices between the first `{` and last `}` — line/col diagnostics are offset accordingly + (`sourceLocation`). Don't inject prologue code; it breaks the offsets. +- OpenCode wraps every tool's output with auto-truncation (`Tool.define` wrapper, + `truncate.output`, 2000 lines / 50KB, saves full output to disk and appends a hint) unless + `metadata.truncated` is set. The `execute` tool currently rides that for free. +- Effect version: both repos pin `effect@4.0.0-beta.83` via bun catalogs. This package uses + v4-only APIs (`Schema.Decoder`, `Schema.toJsonSchemaDocument`, `Context.Service`, + `Cause.hasInterruptsOnly`, `Effect.timeoutOrElse`). The effect-smol checkout referenced in + the workspace is the implementation source of truth for v4 behavior questions. +- File map (this package): `src/codemode.ts` — types/limits/parser/Interpreter/execute/make; + `src/tool-runtime.ts` — tool tree, `copyIn`/`copyOut`, search/discovery, invoke path; + `src/tool.ts` — `Tool.make` + JSON-Schema→TS rendering; `src/values.ts` — sandbox value + types; `src/tool-error.ts` — `ToolError`; tests in `test/{codemode,parity,stdlib}.test.ts`. +- OpenCode file map (integration points): `src/session/code-mode.ts` (adapter, rewritten in + Wave 3); `src/session/tools.ts:93-115,406` (gating/registration); `src/mcp/index.ts` + (`MCP.tools()`/`MCP.defs()`); `src/mcp/catalog.ts` (`convertTool`, `server_tool` naming); + `src/tool/tool.ts` (`ExecuteResult.attachments`, truncation wrapper); + `src/session/message-v2.ts` (attachments → vision); `packages/tui/src/routes/session/index.tsx` + (`Execute` progress component); `src/effect/runtime-flags.ts` (feature flag). diff --git a/packages/codemode/package.json b/packages/codemode/package.json new file mode 100644 index 000000000000..b2d9ec3ea255 --- /dev/null +++ b/packages/codemode/package.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/codemode", + "version": "0.0.1", + "description": "Effect-native confined code execution over schema-described tools", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "test": "bun test" + }, + "dependencies": { + "acorn": "8.15.0", + "effect": "catalog:", + "typescript": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts new file mode 100644 index 000000000000..4eafc10f7841 --- /dev/null +++ b/packages/codemode/src/codemode.ts @@ -0,0 +1,3879 @@ +import { parse } from "acorn" +import { Cause, Effect, Exit, Fiber, Schema, Semaphore } from "effect" +import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" +import { + copyIn, + copyOut, + dataByteLength, + isBlockedMember, + ToolReference, + ToolRuntime, + ToolRuntimeError, + type HostTools, + type SafeObject, + type ToolCall, + type ToolDescription, + type Services, +} from "./tool-runtime.js" +import type { Definition } from "./tool.js" +import { ToolError } from "./tool-error.js" +import { isSandboxValue, SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js" + +/** A tool call admitted during an execution. */ +export type { ToolCall, ToolCallStarted, ToolDescription } from "./tool-runtime.js" +export { ToolError, toolError } from "./tool-error.js" + +/** Resource budgets enforced independently during each CodeMode program execution. */ +export type ExecutionLimits = { + /** Maximum wall-clock execution time in milliseconds. */ + readonly timeoutMs?: number + /** Maximum number of tool calls admitted by the runtime. */ + readonly maxToolCalls?: number + /** + * Maximum UTF-8 bytes of model-facing output: the serialized result value plus captured + * logs. Excess output is truncated with an explanatory marker instead of failing. + */ + readonly maxOutputBytes?: number +} + +/** + * Internal execution knobs kept as fixed defaults behind the public limits. + * Not part of the public contract; exposed only for tests and embedders that must tune + * interpreter internals. + * + * @internal + */ +export type InternalExecutionLimits = ExecutionLimits & { + /** Maximum interpreter work, including collection and string traversal. */ + readonly maxOperations?: number + /** Maximum number of tool calls executing simultaneously. */ + readonly maxConcurrency?: number + /** Maximum UTF-8 source size. */ + readonly maxSourceBytes?: number + /** Maximum size of program values and the final result. */ + readonly maxDataBytes?: number + /** Maximum retained tool-call audit data. */ + readonly maxAuditBytes?: number + /** Maximum nesting depth for arrays and objects crossing data boundaries. */ + readonly maxValueDepth?: number + /** Maximum array length or object field count. */ + readonly maxCollectionLength?: number +} + +/** Controls how much of the tool catalog is inlined in agent instructions. */ +export type DiscoveryOptions = { + /** + * Byte budget for inlined full tool signatures in agent instructions. Every namespace is + * always listed with its tool count; as many full signatures as fit this budget are inlined + * (cheapest-first within a namespace, namespaces alphabetical), and the instructions state + * whether the list is COMPLETE or PARTIAL. `tools.$codemode.search` is always registered. + */ + readonly maxInlineCatalogBytes?: number +} + +type ToolTree = { + readonly [name: string]: Definition | ToolTree +} + +type ResolvedExecutionLimits = { + readonly timeoutMs: number + readonly maxToolCalls: number + readonly maxOutputBytes: number + readonly maxOperations: number + readonly maxConcurrency: number + readonly maxSourceBytes: number + readonly maxDataBytes: number + readonly maxAuditBytes: number + readonly maxValueDepth: number + readonly maxCollectionLength: number +} + +/** Options for one CodeMode execution. */ +export type ExecuteOptions = {}> = { + /** Source for one program in the supported JavaScript subset. */ + code: string + /** Explicit tool tree exposed to the program as `tools`. */ + tools?: Tools & ToolTree + /** Per-execution overrides for the default resource limits. */ + limits?: ExecutionLimits + /** Observes decoded tool input immediately before tool execution. */ + onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect> + /** Observes each admitted tool call as it settles, with outcome and duration. */ + onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect> +} + +/** A normalized program diagnostic safe to return across an agent tool boundary. */ +export type Diagnostic = { + readonly kind: DiagnosticKind + readonly message: string + readonly location?: { readonly line: number; readonly column: number } + readonly suggestions?: ReadonlyArray +} + +/** A JSON value that can cross the confined interpreter boundary. */ +export type DataValue = Schema.Json + +/** Successful execution after the result has crossed the plain-data boundary. */ +export type ExecuteSuccess = { + readonly ok: true + readonly value: DataValue + readonly logs?: ReadonlyArray + /** Present when the value or logs were truncated to fit `maxOutputBytes`. */ + readonly truncated?: boolean + readonly toolCalls: ReadonlyArray +} + +/** Failed execution with calls admitted before the diagnostic was produced. */ +export type ExecuteFailure = { + readonly ok: false + readonly error: Diagnostic + readonly logs?: ReadonlyArray + /** Present when the logs were truncated to fit `maxOutputBytes`. */ + readonly truncated?: boolean + readonly toolCalls: ReadonlyArray +} + +/** Result of executing a CodeMode program. Program failures are data, not Effect failures. */ +export type ExecuteResult = ExecuteSuccess | ExecuteFailure + +/** Reusable CodeMode configuration shared by `execute` and `agentTool`. */ +export type CodeModeOptions = {}> = Omit, "code"> & { + /** Progressive-disclosure configuration for the agent-facing tool catalog. */ + readonly discovery?: DiscoveryOptions +} + +/** Input schema for the single agent-facing tool produced by `runtime.agentTool()`. */ +export const ExecuteInputSchema = Schema.Struct({ code: Schema.String }) + +const DiagnosticKindSchema = Schema.Literals([ + "ParseError", "UnsupportedSyntax", "UnknownTool", "InvalidToolInput", "InvalidToolOutput", "InvalidDataValue", + "OperationLimitExceeded", "ToolCallLimitExceeded", "AuditLimitExceeded", "TimeoutExceeded", + "ToolFailure", "ExecutionFailure", +]) + +/** Structured success or diagnostic result schema returned by CodeMode execution. */ +export const ExecuteResultSchema = Schema.Union([ + Schema.Struct({ + ok: Schema.Literal(true), + value: Schema.Json, + logs: Schema.optionalKey(Schema.Array(Schema.String)), + truncated: Schema.optionalKey(Schema.Boolean), + toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })), + }), + Schema.Struct({ + ok: Schema.Literal(false), + error: Schema.Struct({ + kind: DiagnosticKindSchema, + message: Schema.String, + location: Schema.optionalKey(Schema.Struct({ line: Schema.Number, column: Schema.Number })), + suggestions: Schema.optionalKey(Schema.Array(Schema.String)), + }), + logs: Schema.optionalKey(Schema.Array(Schema.String)), + truncated: Schema.optionalKey(Schema.Boolean), + toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })), + }), +]) + +/** Agent-facing projection of a configured CodeMode runtime. */ +export type AgentToolDefinition = { + readonly name: "code" + readonly description: string + readonly input: typeof ExecuteInputSchema + readonly output: typeof ExecuteResultSchema + readonly execute: (input: { readonly code: string }) => Effect.Effect +} + +/** Reusable confined runtime over one explicit tool tree. */ +export type CodeModeRuntime = { + /** Lists schema-described tool paths provided by the host. */ + readonly catalog: () => ReadonlyArray + /** Builds model-facing syntax guidance and visible tool signatures. */ + readonly instructions: () => string + /** Projects the configured runtime as one agent-facing `code` tool. */ + readonly agentTool: () => AgentToolDefinition + /** Executes a program using this runtime's configured host tools. */ + readonly execute: (code: string) => Effect.Effect +} + +type SourcePosition = { + line: number + column: number +} + +type SourceLocation = { + start: SourcePosition + end: SourcePosition +} + +type AstNode = { + type: string + loc?: SourceLocation + [key: string]: unknown +} + +type ProgramNode = AstNode & { + type: "Program" + body: Array +} + +type Binding = { + mutable: boolean + value: unknown + // Absent means initialized. `false` marks a parameter binding seeded into its scope but not + // yet bound, so a default that forward-references a later parameter sees a TDZ error (as in JS) + // rather than silently resolving to an outer binding of the same name. + initialized?: boolean +} + +type StatementResult = + | { kind: "none" } + | { kind: "value"; value: unknown } + | { kind: "return"; value: unknown } + | { kind: "break" } + | { kind: "continue" } + +type MemberReference = { + target: SafeObject | Array + key: string | number +} + +class CodeModeFunction { + constructor( + readonly parameters: ReadonlyArray, + readonly body: AstNode, + readonly capturedScopes: ReadonlyArray>, + ) {} +} + +class IntrinsicReference { + constructor( + readonly receiver: unknown, + readonly name: string, + ) {} +} + +// A read-only computed member (e.g. `str.length`, a character index) — not assignable. +class ComputedValue { + constructor(readonly value: unknown) {} +} + +class PromiseNamespace {} + +type PromiseMethodName = "all" | "allSettled" | "race" | "resolve" | "reject" + +class PromiseMethodReference { + constructor(readonly name: PromiseMethodName) {} +} + +// A built-in global namespace (`Object`, `Math`, `JSON`, `Array`, ...); members resolve to a +// GlobalMethodReference, except known constants (e.g. `Math.PI`) which resolve to a value. +type GlobalNamespaceName = "Object" | "Math" | "JSON" | "Array" | "console" | "Date" | "RegExp" | "Map" | "Set" + +class GlobalNamespace { + constructor(readonly name: GlobalNamespaceName) {} +} + +class GlobalMethodReference { + constructor(readonly namespace: GlobalNamespaceName | "Number" | "String", readonly name: string) {} +} + +// A built-in callable global (`Number`, `String`, `Boolean`, `parseInt`, `parseFloat`). +class CoercionFunction { + constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {} +} + +class ProgramThrow { + constructor(readonly value: unknown) {} +} + +/** Stable categories produced by program, schema, tool, and budget failures. */ +export type DiagnosticKind = + | "ParseError" + | "UnsupportedSyntax" + | "UnknownTool" + | "InvalidToolInput" + | "InvalidToolOutput" + | "InvalidDataValue" + | "OperationLimitExceeded" + | "ToolCallLimitExceeded" + | "AuditLimitExceeded" + | "TimeoutExceeded" + | "ToolFailure" + | "ExecutionFailure" + +const arrayMethods = new Set([ + "map", "filter", "find", "findIndex", "findLast", "findLastIndex", "some", "every", "includes", "join", + "reduce", "reduceRight", "flatMap", "forEach", "sort", "toSorted", "slice", "concat", "indexOf", "lastIndexOf", + "at", "flat", "reverse", "toReversed", "with", "push", "pop", "shift", "unshift", +]) +const retryableArrayMethods = new Set(["splice", "fill", "copyWithin", "keys", "values", "entries"]) + +/** + * Array methods whose cost is O(1) (or bounded by the argument count), so they must + * NOT be charged the receiver's length. Charging `push` per element would make an + * accumulation loop quadratic in the operation budget and trip it on legitimate code. + */ +const cheapArrayMethods = new Set(["push", "pop", "at"]) + +const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"]) + +const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"]) + +const stringMethods = new Set([ + "toLowerCase", "toUpperCase", "trim", "trimStart", "trimEnd", "split", "slice", "substring", "substr", + "includes", "startsWith", "endsWith", "indexOf", "lastIndexOf", "replace", "replaceAll", + "repeat", "padStart", "padEnd", "charAt", "charCodeAt", "codePointAt", "at", "concat", "toString", + "match", "matchAll", "search", +]) + +const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"]) + +const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"]) + +const stringStatics = new Set(["fromCharCode", "fromCodePoint"]) + +const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"]) + +const promiseStatics = new Set(["all", "allSettled", "race", "resolve", "reject"]) + +const errorConstructors = new Set(["Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError", "EvalError", "URIError"]) + +const valueConstructors = new Set(["Date", "RegExp", "Map", "Set"]) + +const dateMethods = new Set([ + "getTime", "valueOf", "toISOString", "toJSON", "toString", + "getFullYear", "getMonth", "getDate", "getDay", "getHours", "getMinutes", "getSeconds", "getMilliseconds", + "getUTCFullYear", "getUTCMonth", "getUTCDate", "getUTCDay", "getUTCHours", "getUTCMinutes", "getUTCSeconds", "getUTCMilliseconds", + "getTimezoneOffset", +]) +const dateStatics = new Set(["now", "parse", "UTC"]) + +const regexpMethods = new Set(["test", "exec", "toString"]) +// Read-only host regex fields surfaced as plain values. +const regexpProperties = new Set(["source", "flags", "lastIndex", "global", "ignoreCase", "multiline", "sticky", "unicode", "dotAll"]) + +const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"]) +const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"]) + +const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit") + +const supportedSyntaxMessage = + "Supported orchestration syntax: tools.* calls (they return promises — resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported — use await with try/catch)." + +const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError => + new InterpreterRuntimeError(`Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`, node, "UnsupportedSyntax", [supportedSyntaxMessage]) + +const defaultExecutionLimits = (): ResolvedExecutionLimits => ({ + timeoutMs: 10_000, + maxToolCalls: 100, + maxOutputBytes: 32_000, + maxOperations: 100_000, + maxConcurrency: 8, + maxSourceBytes: 32_000, + maxDataBytes: 256_000, + maxAuditBytes: 1_000_000, + maxValueDepth: 32, + maxCollectionLength: 10_000, +}) + +const resolveLimit = (name: keyof InternalExecutionLimits, value: number | undefined, fallback: number, minimum: number): number => { + if (value === undefined) return fallback + if (!Number.isSafeInteger(value) || value < minimum) { + throw new RangeError(`${name} must be a safe integer greater than or equal to ${minimum}.`) + } + return value +} + +const resolveExecutionLimits = (publicLimits?: ExecutionLimits): ResolvedExecutionLimits => { + const defaults = defaultExecutionLimits() + // The public contract is the three documented knobs; the remaining internal knobs are still + // read (and validated) when supplied through InternalExecutionLimits, primarily by tests. + const limits = publicLimits as InternalExecutionLimits | undefined + return { + timeoutMs: resolveLimit("timeoutMs", limits?.timeoutMs, defaults.timeoutMs, 1), + maxToolCalls: resolveLimit("maxToolCalls", limits?.maxToolCalls, defaults.maxToolCalls, 0), + maxOutputBytes: resolveLimit("maxOutputBytes", limits?.maxOutputBytes, defaults.maxOutputBytes, 0), + maxOperations: resolveLimit("maxOperations", limits?.maxOperations, defaults.maxOperations, 0), + maxConcurrency: resolveLimit("maxConcurrency", limits?.maxConcurrency, defaults.maxConcurrency, 1), + maxSourceBytes: resolveLimit("maxSourceBytes", limits?.maxSourceBytes, defaults.maxSourceBytes, 0), + maxDataBytes: resolveLimit("maxDataBytes", limits?.maxDataBytes, defaults.maxDataBytes, 0), + maxAuditBytes: resolveLimit("maxAuditBytes", limits?.maxAuditBytes, defaults.maxAuditBytes, 0), + maxValueDepth: resolveLimit("maxValueDepth", limits?.maxValueDepth, defaults.maxValueDepth, 0), + maxCollectionLength: resolveLimit("maxCollectionLength", limits?.maxCollectionLength, defaults.maxCollectionLength, 0), + } +} + +class InterpreterRuntimeError extends Error { + readonly node?: AstNode + + constructor(message: string, node?: AstNode, readonly kind: DiagnosticKind = "ExecutionFailure", readonly suggestions?: ReadonlyArray) { + super(message) + this.name = "InterpreterRuntimeError" + + if (node) { + this.node = node + } + } +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null + +const asNode = (value: unknown, context: string): AstNode => { + if (!isRecord(value) || typeof value.type !== "string") { + throw new InterpreterRuntimeError(`Invalid AST node while reading ${context}.`) + } + + return value as AstNode +} + +const getArray = (node: AstNode, key: string): Array => { + const value = node[key] + if (!Array.isArray(value)) { + throw new InterpreterRuntimeError(`Expected '${key}' to be an array.`, node) + } + + return value +} + +const getString = (node: AstNode, key: string): string => { + const value = node[key] + if (typeof value !== "string") { + throw new InterpreterRuntimeError(`Expected '${key}' to be a string.`, node) + } + + return value +} + +const getBoolean = (node: AstNode, key: string): boolean => { + const value = node[key] + if (typeof value !== "boolean") { + throw new InterpreterRuntimeError(`Expected '${key}' to be a boolean.`, node) + } + + return value +} + +const getOptionalNode = (node: AstNode, key: string): AstNode | undefined => { + const value = node[key] + if (value === undefined || value === null) { + return undefined + } + + return asNode(value, key) +} + +const getNode = (node: AstNode, key: string): AstNode => { + const value = node[key] + return asNode(value, key) +} + +const parseProgram = (code: string): ProgramNode => { + const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, { + reportDiagnostics: true, + compilerOptions: { + target: ScriptTarget.ESNext, + module: ModuleKind.ESNext, + }, + }) + const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error) + + if (diagnostic) { + throw new InterpreterRuntimeError( + `Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`, + undefined, + "ParseError", + ) + } + + const bodyStart = transpiled.outputText.indexOf("{") + 1 + const bodyEnd = transpiled.outputText.lastIndexOf("}") + const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd) + const parsed = parse(executableCode, { + ecmaVersion: "latest", + sourceType: "script", + allowReturnOutsideFunction: true, + allowAwaitOutsideFunction: true, + locations: true, + }) as unknown + + if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) { + throw new InterpreterRuntimeError("Failed to parse script as a Program node.") + } + + return parsed as ProgramNode +} + +const formatLocation = (node?: AstNode): string => { + if (!node || !node.loc) { + return "" + } + + const location = sourceLocation(node) + return ` (line ${location.line}, col ${location.column})` +} + +const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({ + line: Math.max(1, (node.loc?.start.line ?? 2) - 1), + column: Math.max(1, (node.loc?.start.column ?? 4) - 3), +}) + +const publicErrorMessage = (message: string): string => + message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "") + +const normalizeError = (error: unknown): Diagnostic => { + if (error instanceof InterpreterRuntimeError) { + return { + kind: error.kind, + message: `${error.message}${formatLocation(error.node)}`, + ...(error.node?.loc ? { location: sourceLocation(error.node) } : {}), + ...(error.suggestions ? { suggestions: error.suggestions } : {}), + } + } + + if (error instanceof ToolRuntimeError) { + return { + kind: error.kind, + message: error.message, + ...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}), + } + } + + if (error instanceof ToolError) { + return { kind: "ToolFailure", message: publicErrorMessage(error.message) } + } + + if (error instanceof ProgramThrow) { + const value = error.value + let message: string + if (containsRuntimeReference(value)) { + // A thrown tool/function reference must not leak its internal structure. + message = "a non-data value" + } else if (typeof value === "string") { + message = value + } else if (value !== null && typeof value === "object" && typeof (value as { message?: unknown }).message === "string") { + message = (value as { message: string }).message + } else { + try { + message = JSON.stringify(copyOut(value)) ?? String(value) + } catch { + message = String(value) + } + } + return { kind: "ExecutionFailure", message: `Uncaught: ${message}` } + } + + if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) { + return { + kind: "ExecutionFailure", + message: "Execution exceeded the maximum nesting depth.", + } + } + + if (error instanceof Error) { + return { + kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure", + message: publicErrorMessage(error.message), + } + } + + // A non-Error thrown by a host tool (raw string / number / Symbol) still routes through + // path redaction so filesystem paths can never leak through the catch-all branch. + return { + kind: "ExecutionFailure", + message: publicErrorMessage(String(error)), + } +} + +// The plain value a program observes for a settled failure — shared by `catch` bindings, +// `Promise.allSettled` rejection reasons, and race-loser diagnostics. A thrown program value +// passes through as-is (so `throw new Error(m)` yields its `{ name, message }` object); every +// other failure becomes a plain `{ message }` object. Interpreter errors use their raw message +// so the program never sees the transpiled-source "(line N, col N)" coordinates that +// normalizeError appends — without disturbing a host/tool message that legitimately ends that +// way. Other error kinds carry no appended location, so normalizeError is used as-is. +const caughtErrorValue = (thrown: unknown): unknown => + thrown instanceof ProgramThrow + ? thrown.value + : Object.assign(Object.create(null) as SafeObject, { + message: thrown instanceof InterpreterRuntimeError ? thrown.message : normalizeError(thrown).message, + }) + +// ── Built-in method/global implementations ─────────────────────────────────── +// These mirror the corresponding JavaScript operations over Data Values. They are +// pure (string/Object/Math/JSON/coercion) and so live as free functions; array +// Methods that run CodeMode callbacks live on the interpreter (they need invokeFunction). + +const boundedData = (value: unknown, label: string, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + const copied = copyIn(value, label, limits) + if (dataByteLength(copied) > limits.maxDataBytes) { + throw new InterpreterRuntimeError(`${label} exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, node, "InvalidDataValue") + } + return copied +} + +const isRuntimeReference = (value: unknown): boolean => + value instanceof CodeModeFunction || value instanceof ToolReference || value instanceof IntrinsicReference || + value instanceof GlobalNamespace || value instanceof GlobalMethodReference || value instanceof PromiseNamespace || + value instanceof PromiseMethodReference || value instanceof SandboxPromise || value instanceof CoercionFunction || + isSandboxValue(value) + +const containsRuntimeReference = (value: unknown, seen = new Set()): boolean => { + if (isRuntimeReference(value)) return true + if (value === null || typeof value !== "object") return false + if (seen.has(value)) return false + seen.add(value) + const contains = Array.isArray(value) + ? value.some((item) => containsRuntimeReference(item, seen)) + : Object.values(value).some((item) => containsRuntimeReference(item, seen)) + seen.delete(value) + return contains +} + +// Like containsRuntimeReference, but sandbox value types (Date/RegExp/Map/Set) count as data: +// operators and switch treat them as ordinary object operands (identity equality, ToPrimitive +// coercion) rather than rejecting them as opaque interpreter machinery. +const containsOpaqueReference = (value: unknown, seen = new Set()): boolean => { + if (isSandboxValue(value)) return false + if (isRuntimeReference(value)) return true + if (value === null || typeof value !== "object") return false + if (seen.has(value)) return false + seen.add(value) + const contains = Array.isArray(value) + ? value.some((item) => containsOpaqueReference(item, seen)) + : Object.values(value).some((item) => containsOpaqueReference(item, seen)) + seen.delete(value) + return contains +} + +// `typeof` never throws in JS; map every interpreter value to its JS-visible category. +// A SandboxPromise falls through to the final `typeof value` and reports "object", exactly +// like a real JS promise. +const typeofValue = (value: unknown): string => { + if ( + value instanceof CodeModeFunction || value instanceof CoercionFunction || value instanceof IntrinsicReference || + value instanceof GlobalMethodReference || value instanceof PromiseMethodReference || value instanceof PromiseNamespace + ) return "function" + if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object" + if (value instanceof GlobalNamespace) { + return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function" + } + return typeof value +} + +const runtimeValueBytes = ( + value: unknown, + label: string, + node: AstNode, + limits: ResolvedExecutionLimits, + depth = 0, + seen = new Set(), +): number => { + if (depth > limits.maxValueDepth) { + throw new InterpreterRuntimeError(`${label} exceeds the maximum value depth of ${limits.maxValueDepth}.`, node, "InvalidDataValue") + } + // Sandbox values are charged real sizes (a Map/Set can hold arbitrary program data, so a + // zero-cost reference would be an unaccounted-memory hole), and their walks are cycle-checked. + if (value instanceof SandboxDate) return 26 + if (value instanceof SandboxRegExp) return dataByteLength(value.regex.source) + value.regex.flags.length + 2 + if (value instanceof SandboxMap || value instanceof SandboxSet) { + if (seen.has(value)) throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") + seen.add(value) + const entries = value instanceof SandboxMap + ? Array.from(value.map.entries()) + : Array.from(value.set.values(), (item): [unknown, unknown] => [item, null]) + if (entries.length > limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + let bytes = 2 + for (const [key, item] of entries) { + bytes += runtimeValueBytes(key, label, node, limits, depth + 1, seen) + runtimeValueBytes(item, label, node, limits, depth + 1, seen) + 2 + } + seen.delete(value) + return bytes + } + if (isRuntimeReference(value)) return 0 + if (value === null || value === undefined || typeof value === "string" || typeof value === "boolean" || typeof value === "number") { + return dataByteLength(value) + } + if (typeof value !== "object") { + throw new InterpreterRuntimeError(`${label} must contain data or CodeMode references only.`, node, "InvalidDataValue") + } + if (seen.has(value)) throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") + seen.add(value) + let bytes = 2 + if (Array.isArray(value)) { + if (value.length > limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + for (const item of value) bytes += runtimeValueBytes(item, label, node, limits, depth + 1, seen) + 1 + } else { + const entries = Object.entries(value) + if (entries.length > limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + for (const [key, item] of entries) { + if (isBlockedMember(key)) throw new InterpreterRuntimeError(`${label} contains blocked property '${key}'.`, node, "InvalidDataValue") + bytes += dataByteLength(key) + runtimeValueBytes(item, label, node, limits, depth + 1, seen) + 1 + } + } + seen.delete(value) + return bytes +} + +const boundedProgramValue = (value: unknown, label: string, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + if (runtimeValueBytes(value, label, node, limits) > limits.maxDataBytes) { + throw new InterpreterRuntimeError(`${label} exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, node, "InvalidDataValue") + } + return value +} + +// A cheap proxy for the work an O(n) built-in performed, used to charge the operation budget. +const workUnits = (value: unknown): number => { + if (typeof value === "string" || Array.isArray(value)) return value.length + if (value !== null && typeof value === "object") return Object.keys(value).length + return 1 +} + +// A string method's pattern argument as a host regex: a sandbox regex passes its own host +// instance through (so `g` lastIndex semantics follow the spec across calls); a string becomes +// a pattern, exactly as String.prototype.match/matchAll/search do (`extraFlags` adds matchAll's +// implicit `g`). Invalid patterns fail as catchable program errors. +const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => { + if (arg instanceof SandboxRegExp) return arg.regex + if (typeof arg === "string") { + try { + return new RegExp(arg, extraFlags) + } catch (error) { + throw new InterpreterRuntimeError(`Invalid regular expression in String.${method}: ${error instanceof Error ? error.message : String(error)}`, node) + } + } + throw new InterpreterRuntimeError(`String.${method} expects a regular expression or string pattern.`, node) +} + +// A host match result as a sandbox value: a plain array of the full match and captures, with +// `index` and named `groups` attached as own array properties (readable, and dropped at data +// boundaries exactly like JSON.stringify drops them in JS). `input` is omitted — it duplicates +// the whole subject string per match. +const matchToValue = (match: RegExpMatchArray): Array => { + const result: Array = Array.from(match, (group) => group) + if (match.index !== undefined) (result as Record & Array).index = match.index + if (match.groups) { + const groups: SafeObject = Object.create(null) as SafeObject + for (const [key, group] of Object.entries(match.groups)) { + if (!isBlockedMember(key)) groups[key] = group + } + ;(result as Record & Array).groups = groups + } + return result +} + +const invokeStringMethod = (value: string, name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + const str = (index: number): string => { + const arg = args[index] + if (typeof arg !== "string") throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node) + return arg + } + const num = (index: number): number => { + const arg = args[index] + if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node) + return arg + } + const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index)) + const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index)) + const byteLength = (text: string): number => new TextEncoder().encode(text).byteLength + const limitString = (bytes: number): void => { + if (bytes > limits.maxDataBytes) { + throw new InterpreterRuntimeError(`String.${name} exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, node, "InvalidDataValue") + } + } + const replacementCount = (search: string): number => { + if (search === "") return value.length + 1 + let count = 0 + let offset = 0 + while ((offset = value.indexOf(search, offset)) !== -1) { + count += 1 + offset += search.length + } + return count + } + + let result: unknown + switch (name) { + case "toLowerCase": result = value.toLowerCase(); break + case "toUpperCase": result = value.toUpperCase(); break + case "trim": result = value.trim(); break + case "trimStart": result = value.trimStart(); break + case "trimEnd": result = value.trimEnd(); break + case "split": { + if (args.length === 0) { + result = [value] + break + } + if (args[0] instanceof SandboxRegExp) { + const parts = value.split((args[0] as SandboxRegExp).regex, optNum(1)) + if (parts.length > limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`String.split exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + result = parts + break + } + const separator = str(0) + const requestedLimit = optNum(1) + const effectiveLimit = requestedLimit === undefined ? undefined : requestedLimit >>> 0 + const maximumParts = separator === "" ? value.length : replacementCount(separator) + 1 + const parts = effectiveLimit === undefined ? maximumParts : Math.min(maximumParts, effectiveLimit) + if (parts > limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`String.split exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + result = value.split(separator, effectiveLimit) + break + } + case "slice": result = value.slice(optNum(0), optNum(1)); break + case "includes": result = value.includes(str(0), optNum(1)); break + case "startsWith": result = value.startsWith(str(0), optNum(1)); break + case "endsWith": result = value.endsWith(str(0), optNum(1)); break + case "indexOf": result = value.indexOf(str(0), optNum(1)); break + case "lastIndexOf": result = value.lastIndexOf(str(0), optNum(1)); break + case "replace": + case "replaceAll": { + if (args[0] instanceof CodeModeFunction || args[1] instanceof CodeModeFunction) { + throw new InterpreterRuntimeError(`String.${name} does not support function replacers in CodeMode; use match/matchAll and rebuild the string instead.`, node, "UnsupportedSyntax", [supportedSyntaxMessage]) + } + if (args[0] instanceof SandboxRegExp) { + const pattern = (args[0] as SandboxRegExp).regex + const replacement = str(1) + if (name === "replaceAll" && !pattern.global) { + throw new InterpreterRuntimeError("String.replaceAll requires a regular expression with the global (g) flag.", node) + } + // Growth cannot be pre-computed for patterns; the input and replacement are already + // byte-bounded, so bound the produced string instead. + const replaced = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement) + limitString(byteLength(replaced)) + result = replaced + break + } + if (name === "replace") { + result = value.replace(str(0), str(1)) + break + } + const search = str(0) + const replacement = str(1) + const growth = Math.max(0, byteLength(replacement) - byteLength(search)) + limitString(byteLength(value) + replacementCount(search) * growth) + result = value.replaceAll(search, replacement) + break + } + case "match": { + const pattern = toHostRegex(args[0], name, node) + const matched = value.match(pattern) + if (matched === null) return null + // A global match is a plain array of matched strings; a non-global match carries + // index/groups own properties, so bypass the copying data checkpoint to keep them. + if (pattern.global) return boundedData(matched, "String.match result", node, limits) + return boundedProgramValue(matchToValue(matched), "String.match result", node, limits) + } + case "matchAll": { + const pattern = toHostRegex(args[0], name, node, "g") + if (!pattern.global) { + throw new InterpreterRuntimeError("String.matchAll requires a regular expression with the global (g) flag.", node) + } + // Materialized as an array (not an iterator); each entry is a match array with + // index/groups own properties. Match count is bounded by the subject length. + const matches = Array.from(value.matchAll(pattern), matchToValue) + if (matches.length > limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`String.matchAll exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + return boundedProgramValue(matches, "String.matchAll result", node, limits) + } + case "search": { + result = value.search(toHostRegex(args[0], name, node)) + break + } + case "repeat": { + const count = num(0) + if (!Number.isFinite(count) || count < 0) throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node) + limitString(byteLength(value) * Math.floor(count)) + result = value.repeat(count) + break + } + case "padStart": { + const length = num(0) + limitString(Math.max(0, length)) + result = value.padStart(length, optStr(1)) + break + } + case "padEnd": { + const length = num(0) + limitString(Math.max(0, length)) + result = value.padEnd(length, optStr(1)) + break + } + case "charAt": result = value.charAt(optNum(0) ?? 0); break + case "at": result = value.at(optNum(0) ?? 0); break + case "substring": result = value.substring(optNum(0) ?? 0, optNum(1)); break + case "substr": result = value.substr(optNum(0) ?? 0, optNum(1)); break + // JS charCodeAt returns NaN out of range; NaN flows as an ordinary in-sandbox value + // (normalized to null only at the data boundary — see copyOut), so return it as-is. + case "charCodeAt": result = value.charCodeAt(optNum(0) ?? 0); break + case "codePointAt": result = value.codePointAt(optNum(0) ?? 0); break + case "toString": result = value; break + case "concat": { + const pieces = args.map((_, index) => str(index)) + limitString(byteLength(value) + pieces.reduce((size, piece) => size + byteLength(piece), 0)) + result = value.concat(...pieces) + break + } + default: throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node) + } + return boundedData(result, `String.${name} result`, node, limits) +} + +const invokeNumberMethod = (value: number, name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + const optNum = (index: number): number | undefined => { + const arg = args[index] + if (arg === undefined) return undefined + if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node) + return arg + } + let result: unknown + switch (name) { + case "toFixed": result = value.toFixed(optNum(0)); break + case "toExponential": result = value.toExponential(optNum(0)); break + case "toPrecision": { + const digits = optNum(0) + result = digits === undefined ? value.toString() : value.toPrecision(digits) + break + } + case "toString": { + const radix = optNum(0) + if (radix !== undefined && (radix < 2 || radix > 36)) { + throw new InterpreterRuntimeError("Number.toString radix must be between 2 and 36.", node) + } + result = value.toString(radix) + break + } + default: throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node) + } + return boundedData(result, `Number.${name} result`, node, limits) +} + +// JavaScript's String(...) without tripping over CodeMode's null-prototype data objects. +const coerceToString = (value: unknown): string => { + if (value === null) return "null" + if (value === undefined) return "undefined" + // Sandbox values stringify deterministically: Date as ISO (not the host's locale/timezone + // toString), RegExp as its literal form, Map/Set with their JS Object.prototype tags. + if (value instanceof SandboxDate) return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date" + if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}` + if (value instanceof SandboxMap) return "[object Map]" + if (value instanceof SandboxSet) return "[object Set]" + if (typeof value === "object") { + return Array.isArray(value) + ? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",") + : "[object Object]" + } + return String(value) +} + +const coerceToNumber = (value: unknown): number => { + if (value instanceof SandboxDate) return value.time + if (isSandboxValue(value)) return Number.NaN + return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value) +} + +const invokeCoercion = (ref: CoercionFunction, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + // Sandbox values coerce before the data checkpoint (which would JSON-serialize them): + // Number(date) is its time value, String(date) its ISO form, Boolean(x) is true. + const raw = args[0] + if (isSandboxValue(raw)) { + if (ref.name === "Boolean") return true + if (ref.name === "Number") return coerceToNumber(raw) + if (ref.name === "String") return coerceToString(raw) + if (ref.name === "parseInt") return parseInt(coerceToString(raw)) + return parseFloat(coerceToString(raw)) + } + const value = boundedData(args[0], `${ref.name} input`, node, limits) + if (ref.name === "Number") return coerceToNumber(value) + if (ref.name === "Boolean") return Boolean(value) + if (ref.name === "parseInt") { + const radix = args[1] + if (radix !== undefined && typeof radix !== "number") throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node) + return parseInt(coerceToString(value), radix) + } + if (ref.name === "parseFloat") return parseFloat(coerceToString(value)) + return coerceToString(value) +} + +const invokeObjectMethod = (name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + const requireObject = (): Record => { + const value = boundedData(args[0], `Object.${name} input`, node, limits) + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node) + } + return value as Record + } + const guardedSet = (out: Record, key: string, item: unknown): void => { + if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node) + out[key] = item + } + switch (name) { + case "keys": { + // Object.keys(array) yields index strings (["0", "1", ...]) exactly as in JS; objects + // yield their own enumerable keys. (Tool references never reach here — the interpreter + // resolves them against the host tool tree first.) + const value = boundedData(args[0], "Object.keys input", node, limits) + if (Array.isArray(value)) return Object.keys(value) + if (value === null || typeof value !== "object") { + throw new InterpreterRuntimeError("Object.keys expects a data object or array.", node) + } + return Object.keys(value) + } + case "values": return Object.values(requireObject()) + case "entries": return Object.entries(requireObject()).map(([key, item]) => [key, item]) + case "hasOwn": return Object.hasOwn(requireObject(), String(args[1])) + case "assign": { + const out: Record = Object.create(null) + for (const source of args) { + if (source === null || source === undefined) continue + const value = boundedData(source, "Object.assign input", node, limits) + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new InterpreterRuntimeError("Object.assign expects data objects.", node) + for (const [key, item] of Object.entries(value)) guardedSet(out, key, item) + } + return out + } + case "fromEntries": { + // A Map is the idiomatic fromEntries source; use its entries directly (the data + // checkpoint would serialize a Map to {}). + if (args[0] instanceof SandboxMap) { + const out: Record = Object.create(null) + for (const [key, item] of (args[0] as SandboxMap).map.entries()) guardedSet(out, coerceToString(key), item) + return out + } + const pairs = boundedData(args[0], "Object.fromEntries input", node, limits) + if (!Array.isArray(pairs)) throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) + const out: Record = Object.create(null) + for (const pair of pairs) { + if (!Array.isArray(pair)) throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node) + guardedSet(out, String(pair[0]), pair[1]) + } + return out + } + default: throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node) + } +} + +const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { + const nums = args.map((arg) => { + if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node) + return arg + }) + const [a = Number.NaN, b = Number.NaN] = nums + switch (name) { + case "max": return Math.max(...nums) + case "min": return Math.min(...nums) + case "abs": return Math.abs(a) + case "floor": return Math.floor(a) + case "ceil": return Math.ceil(a) + case "round": return Math.round(a) + case "trunc": return Math.trunc(a) + case "sign": return Math.sign(a) + case "sqrt": return Math.sqrt(a) + case "cbrt": return Math.cbrt(a) + case "pow": return Math.pow(a, b) + case "hypot": return Math.hypot(...nums) + case "log": return Math.log(a) + case "log2": return Math.log2(a) + case "log10": return Math.log10(a) + case "exp": return Math.exp(a) + default: throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) + } +} + +const invokeJsonMethod = (name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + switch (name) { + case "stringify": { + const replacer = args[1] + if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) { + throw new InterpreterRuntimeError("JSON.stringify replacers are not supported in CodeMode.", node, "UnsupportedSyntax", [supportedSyntaxMessage]) + } + const space = args[2] + const indent = typeof space === "number" || typeof space === "string" ? space : undefined + // copyIn first so only Data Values serialize, never a CodeModeFunction/ToolReference. + return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value", limits)), null, indent) + } + case "parse": { + const text = args[0] + if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node) + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + throw new InterpreterRuntimeError("JSON.parse received invalid JSON.", node) + } + return copyIn(parsed, "JSON.parse result", limits) + } + default: throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node) + } +} + +const invokeArrayStatic = (name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + switch (name) { + case "isArray": + return Array.isArray(args[0]) + case "of": + return [...args] + case "from": { + if (args.length > 1) { + throw new InterpreterRuntimeError( + "Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + // Map/Set materialize directly (the data checkpoint would serialize them to {}). + if (args[0] instanceof SandboxMap) return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item]) + if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values()) + const source = boundedData(args[0], "Array.from input", node, limits) + if (typeof source === "string") return Array.from(source) + if (Array.isArray(source)) return [...source] + if (source !== null && typeof source === "object" && typeof (source as { length?: unknown }).length === "number") { + return Array.from(source as ArrayLike) + } + throw new InterpreterRuntimeError("Array.from expects an array, string, Map, Set, or array-like value.", node) + } + default: + throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node) + } +} + +const invokeNumberStatic = (name: string, args: Array, node: AstNode): unknown => { + const value = args[0] + switch (name) { + case "isInteger": return Number.isInteger(value) + case "isFinite": return Number.isFinite(value) + case "isNaN": return Number.isNaN(value) + case "isSafeInteger": return Number.isSafeInteger(value) + case "parseInt": { + const radix = args[1] + if (radix !== undefined && typeof radix !== "number") throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node) + return parseInt(coerceToString(value), radix) + } + case "parseFloat": return parseFloat(coerceToString(value)) + default: throw new InterpreterRuntimeError(`Number.${name} is not available in CodeMode.`, node) + } +} + +const invokeStringStatic = (name: string, args: Array, node: AstNode): unknown => { + const codes = args.map((arg) => { + if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node) + return arg + }) + switch (name) { + case "fromCharCode": return String.fromCharCode(...codes) + case "fromCodePoint": return String.fromCodePoint(...codes) + default: throw new InterpreterRuntimeError(`String.${name} is not available in CodeMode.`, node) + } +} + +const invokeDateStatic = (name: string, args: Array, node: AstNode): number => { + switch (name) { + case "now": return Date.now() + case "parse": return Date.parse(coerceToString(args[0])) + case "UTC": { + const parts = args.map((arg) => coerceToNumber(arg)) + return Date.UTC(...(parts as Parameters)) + } + default: throw new InterpreterRuntimeError(`Date.${name} is not available in CodeMode.`, node) + } +} + +const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unknown => { + const hosted = new Date(value.time) + switch (name) { + case "getTime": case "valueOf": return value.time + case "toISOString": { + if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node) + return hosted.toISOString() + } + // toJSON of an invalid date is null in JS (never a throw); toString stays ISO for + // determinism across host timezones/locales. + case "toJSON": return Number.isFinite(value.time) ? hosted.toISOString() : null + case "toString": return coerceToString(value) + case "getFullYear": return hosted.getFullYear() + case "getMonth": return hosted.getMonth() + case "getDate": return hosted.getDate() + case "getDay": return hosted.getDay() + case "getHours": return hosted.getHours() + case "getMinutes": return hosted.getMinutes() + case "getSeconds": return hosted.getSeconds() + case "getMilliseconds": return hosted.getMilliseconds() + case "getUTCFullYear": return hosted.getUTCFullYear() + case "getUTCMonth": return hosted.getUTCMonth() + case "getUTCDate": return hosted.getUTCDate() + case "getUTCDay": return hosted.getUTCDay() + case "getUTCHours": return hosted.getUTCHours() + case "getUTCMinutes": return hosted.getUTCMinutes() + case "getUTCSeconds": return hosted.getUTCSeconds() + case "getUTCMilliseconds": return hosted.getUTCMilliseconds() + case "getTimezoneOffset": return hosted.getTimezoneOffset() + default: throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node) + } +} + +const invokeRegExpMethod = (value: SandboxRegExp, name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + switch (name) { + // test/exec run on the sandbox regex's own host instance, so `g`-flag lastIndex advances + // across calls per the spec. + case "test": return value.regex.test(coerceToString(args[0])) + case "exec": { + const matched = value.regex.exec(coerceToString(args[0])) + if (matched === null) return null + return boundedProgramValue(matchToValue(matched), "RegExp.exec result", node, limits) + } + case "toString": return coerceToString(value) + default: throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node) + } +} + +const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { + if (ref.namespace === "console") throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node) + if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node, limits) + if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node) + if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node, limits) + if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node) + if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node) + if (ref.namespace === "Date") { + if (!dateStatics.has(ref.name)) throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node) + return invokeDateStatic(ref.name, args, node) + } + if (ref.namespace === "RegExp" || ref.namespace === "Map" || ref.namespace === "Set") { + throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node) + } + return invokeJsonMethod(ref.name, args, node, limits) +} + +// Iterable spread sources: arrays, strings (code points), Maps (entry pairs), and Sets (values). +const spreadItems = (spread: unknown): Array | undefined => { + if (Array.isArray(spread)) return spread + if (typeof spread === "string") return Array.from(spread) + if (spread instanceof SandboxMap) return Array.from(spread.map.entries(), ([key, item]): Array => [key, item]) + if (spread instanceof SandboxSet) return Array.from(spread.set.values()) + return undefined +} + +// Every identifier a parameter pattern binds, used to seed TDZ slots before defaults run. +const collectPatternNames = (pattern: AstNode, out: Array = []): Array => { + switch (pattern.type) { + case "Identifier": + out.push(getString(pattern, "name")) + break + case "AssignmentPattern": + collectPatternNames(getNode(pattern, "left"), out) + break + case "RestElement": + collectPatternNames(getNode(pattern, "argument"), out) + break + case "ArrayPattern": + for (const element of getArray(pattern, "elements")) { + if (element !== null) collectPatternNames(asNode(element, "elements"), out) + } + break + case "ObjectPattern": + for (const property of getArray(pattern, "properties")) { + const prop = asNode(property, "properties") + collectPatternNames(prop.type === "RestElement" ? getNode(prop, "argument") : getNode(prop, "value"), out) + } + break + } + return out +} + +class Interpreter { + private scopes: Array> + private readonly limits: ResolvedExecutionLimits + private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect + // Enumerable namespace/tool names at a node of the host tool tree, threaded from + // ToolRuntime.make like invokeTool: the interpreter never holds the tree itself. + private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray + private readonly budget: { operations: number } + private readonly logs: Array + private readonly logBudget: { bytes: number } + private lastValue: unknown + // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency limit). + private readonly callPermits: Semaphore.Semaphore + // Fiber-backed promises whose settlement no program construct has observed yet. Successful + // program completion drains these (like a runtime waiting on in-flight work at exit) and + // surfaces a never-awaited failure as an unhandled-rejection diagnostic. + private readonly pendingSettlements = new Set() + // Cached byte size (and, for objects, key count) of each live container, maintained incrementally + // by the mutation helpers so appending in a loop is O(1)/op rather than re-walking the whole + // container each time (which made push/index-assign/key-assign loops O(n^2) — a CPU DoS). These + // are a fast path under the authoritative copyIn/copyOut boundary checks, never a replacement. + private readonly containerSizes = new WeakMap() + private readonly objectCounts = new WeakMap() + + constructor( + limits: ResolvedExecutionLimits, + invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, + toolKeys: (path: ReadonlyArray) => ReadonlyArray, + budget: { operations: number } = { operations: 0 }, + logs: Array = [], + logBudget: { bytes: number } = { bytes: 0 }, + ) { + const globalScope = new Map() + this.scopes = [globalScope] + this.limits = limits + this.invokeTool = invokeTool + this.toolKeys = toolKeys + this.budget = budget + this.logs = logs + this.logBudget = logBudget + this.lastValue = undefined + this.callPermits = Semaphore.makeUnsafe(limits.maxConcurrency) + globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) + globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) + globalScope.set("undefined", { mutable: false, value: undefined }) + globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") }) + globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") }) + globalScope.set("JSON", { mutable: false, value: new GlobalNamespace("JSON") }) + globalScope.set("Number", { mutable: false, value: new CoercionFunction("Number") }) + globalScope.set("String", { mutable: false, value: new CoercionFunction("String") }) + globalScope.set("Boolean", { mutable: false, value: new CoercionFunction("Boolean") }) + globalScope.set("Array", { mutable: false, value: new GlobalNamespace("Array") }) + globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") }) + globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") }) + globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") }) + globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") }) + globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") }) + globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") }) + globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") }) + // NaN/Infinity flow as ordinary in-sandbox values (normalized to null only at the data + // boundary — see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`. + globalScope.set("NaN", { mutable: false, value: NaN }) + globalScope.set("Infinity", { mutable: false, value: Infinity }) + } + + run(program: ProgramNode): Effect.Effect { + const self = this + // Run the program body in its own module scope on top of the builtin global scope, so + // top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like + // JS module scope, instead of colliding with the seeded globals. + this.pushScope() + return Effect.gen(function*() { + self.hoistFunctions(program.body) + let value: unknown = undefined + let returned = false + for (const statement of program.body) { + const result = yield* self.evaluateStatement(statement) + + if (result.kind === "return") { + value = result.value + returned = true + break + } + + if (result.kind === "break" || result.kind === "continue") { + throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement) + } + + if (result.kind === "value") { + self.lastValue = result.value + } + } + if (!returned) value = self.lastValue + + // The program body runs inside an implicit async function, so a returned promise + // resolves before crossing the data boundary — `return tools.ns.tool(...)` works + // without an explicit await, exactly as in JS. + if (value instanceof SandboxPromise) value = yield* self.settlePromise(value) + yield* self.drainPendingSettlements() + return value + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + // Awaits every fiber-backed promise the program abandoned (fire-and-forget tool calls), so + // their work completes before the execution ends — mirroring a JS runtime waiting on + // in-flight I/O at exit. A failure nobody could have handled becomes an unhandled-rejection + // diagnostic (interrupted calls, e.g. Promise.race losers, are ignored). + private drainPendingSettlements(): Effect.Effect { + const self = this + return Effect.gen(function*() { + for (const promise of [...self.pendingSettlements]) { + const exit = yield* self.observePromise(promise) + if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue + const failure = normalizeError(Cause.squash(exit.cause)) + throw new InterpreterRuntimeError( + `Unhandled rejection from an un-awaited tool call: ${failure.message}`, + undefined, + failure.kind, + ["Await tool calls — `const result = await tools.ns.tool(...)` — so failures can be caught and handled."], + ) + } + }) + } + + // Eagerly starts a tool call on a supervised child fiber (so the execution timeout and + // scope teardown interrupt it) gated by the concurrency semaphore, and wraps the fiber in a + // first-class promise value. `startImmediately` makes the runtime admit the call — charging + // the tool-call budget and firing onToolCallStart — at the call site, before any await. + private createToolCallPromise(path: ReadonlyArray, args: Array): Effect.Effect { + const self = this + return Effect.map( + Effect.forkChild( + this.callPermits.withPermit(Effect.suspend(() => self.invokeTool(path, args))), + { startImmediately: true }, + ), + (fiber) => { + const promise = new SandboxPromise(fiber) + self.pendingSettlements.add(promise) + return promise + }, + ) + } + + // The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking. + // Fiber settlement is idempotent, so observing the same promise repeatedly (await twice, + // Promise.all([p, p])) never re-runs the underlying call. + private observePromise(promise: SandboxPromise): Effect.Effect> { + this.pendingSettlements.delete(promise) + return promise.fiber !== undefined ? Fiber.await(promise.fiber) : Effect.exit(promise.immediate ?? Effect.void) + } + + // `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch + // observes it exactly like a synchronous throw at the await site. + private settlePromise(promise: SandboxPromise, node?: AstNode): Effect.Effect { + const self = this + return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node)) + } + + private unwrapPromiseExit(promise: SandboxPromise | undefined, exit: Exit.Exit, node?: AstNode): Effect.Effect { + if (Exit.isSuccess(exit)) return Effect.succeed(exit.value) + // A call Promise.race interrupted after losing settles as a catchable program failure; + // any other interruption is execution teardown (timeout/host) and must keep propagating + // as interruption rather than becoming program-visible data. + if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) { + return Effect.fail(new InterpreterRuntimeError("This tool call was interrupted because another value settled a Promise.race first.", node)) + } + return Effect.failCause(exit.cause) + } + + private evaluateStatement(node: AstNode): Effect.Effect { + this.recordOperation(node) + + switch (node.type) { + case "ExpressionStatement": + return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value })) + case "VariableDeclaration": + return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" })) + case "ReturnStatement": { + const argumentNode = getOptionalNode(node, "argument") + return argumentNode + ? Effect.map(this.evaluateExpression(argumentNode), (value) => ({ kind: "return", value })) + : Effect.succeed({ kind: "return", value: undefined }) + } + case "BlockStatement": + return this.evaluateBlock(node) + case "IfStatement": + return this.evaluateIfStatement(node) + case "SwitchStatement": + return this.evaluateSwitchStatement(node) + case "WhileStatement": + return this.evaluateWhileStatement(node) + case "DoWhileStatement": + return this.evaluateDoWhileStatement(node) + case "ForStatement": + return this.evaluateForStatement(node) + case "ForOfStatement": + return this.evaluateForOfStatement(node) + case "ForInStatement": + return this.evaluateForInStatement(node) + case "BreakStatement": + return Effect.succeed(this.evaluateBreakStatement(node)) + case "ContinueStatement": + return Effect.succeed(this.evaluateContinueStatement(node)) + case "ThrowStatement": + return this.evaluateThrowStatement(node) + case "TryStatement": + return this.evaluateTryStatement(node) + case "EmptyStatement": + return Effect.succeed({ kind: "none" }) + case "FunctionDeclaration": + return Effect.succeed({ kind: "none" }) // bound ahead of time by hoistFunctions + default: + throw unsupportedSyntax(node.type, node) + } + } + + private evaluateBlock(node: AstNode): Effect.Effect { + this.pushScope() + const self = this + return Effect.gen(function*() { + const body = getArray(node, "body") + self.hoistFunctions(body) + + for (const statementValue of body) { + const statement = asNode(statementValue, "body") + const result = yield* self.evaluateStatement(statement) + + if (result.kind === "value") { + self.lastValue = result.value + continue + } + + if (result.kind !== "none") { + return result + } + } + + return { kind: "none" } satisfies StatementResult + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private createFunction(node: AstNode): CodeModeFunction { + if (node.generator === true) { + throw new InterpreterRuntimeError("Generator functions are not supported in CodeMode.", node, "UnsupportedSyntax", [supportedSyntaxMessage]) + } + return new CodeModeFunction( + getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)), + getNode(node, "body"), + this.scopes.slice(), + ) + } + + // Function declarations are hoisted: bound in their scope before the body runs, so a + // program can call a helper defined further down (matching JavaScript). + private hoistFunctions(statements: Array): void { + for (const statementValue of statements) { + if (!isRecord(statementValue) || statementValue.type !== "FunctionDeclaration") continue + const node = statementValue as AstNode + this.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node) + } + } + + private evaluateIfStatement(node: AstNode): Effect.Effect { + const testNode = getNode(node, "test") + const consequentNode = getNode(node, "consequent") + const alternateNode = getOptionalNode(node, "alternate") + + return Effect.flatMap(this.evaluateExpression(testNode), (test) => + test ? this.evaluateStatement(consequentNode) : alternateNode ? this.evaluateStatement(alternateNode) : Effect.succeed({ kind: "none" })) + } + + private evaluateSwitchStatement(node: AstNode): Effect.Effect { + const self = this + this.pushScope() + return Effect.gen(function*() { + const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant")) + if (containsOpaqueReference(discriminant)) { + throw new InterpreterRuntimeError("Switch discriminants must be data values in CodeMode.", node, "InvalidDataValue") + } + const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`)) + let defaultIndex: number | undefined + let selected: number | undefined + for (const [index, branch] of cases.entries()) { + const test = getOptionalNode(branch, "test") + if (!test) { + defaultIndex = index + continue + } + const candidate = yield* self.evaluateExpression(test) + if (containsOpaqueReference(candidate)) { + throw new InterpreterRuntimeError("Switch case values must be data values in CodeMode.", test, "InvalidDataValue") + } + if (candidate === discriminant) { + selected = index + break + } + } + const start = selected ?? defaultIndex + if (start === undefined) return { kind: "none" } satisfies StatementResult + for (let index = start; index < cases.length; index += 1) { + for (const statementValue of getArray(cases[index]!, "consequent")) { + const result = yield* self.evaluateStatement(asNode(statementValue, "consequent")) + if (result.kind === "break") return { kind: "none" } satisfies StatementResult + if (result.kind === "return" || result.kind === "continue") return result + if (result.kind === "value") self.lastValue = result.value + } + } + return { kind: "none" } satisfies StatementResult + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private evaluateWhileStatement(node: AstNode): Effect.Effect { + const testNode = getNode(node, "test") + const bodyNode = getNode(node, "body") + + const self = this + return Effect.gen(function*() { + while (yield* self.evaluateExpression(testNode)) { + const result = yield* self.evaluateStatement(bodyNode) + + if (result.kind === "continue") { + continue + } + + if (result.kind === "break") { + return { kind: "none" } satisfies StatementResult + } + + if (result.kind === "return") { + return result + } + + if (result.kind === "value") { + self.lastValue = result.value + } + } + + return { kind: "none" } satisfies StatementResult + }) + } + + private evaluateDoWhileStatement(node: AstNode): Effect.Effect { + const bodyNode = getNode(node, "body") + const testNode = getNode(node, "test") + + const self = this + return Effect.gen(function*() { + do { + const result = yield* self.evaluateStatement(bodyNode) + + if (result.kind === "continue") { + continue + } + + if (result.kind === "break") { + return { kind: "none" } satisfies StatementResult + } + + if (result.kind === "return") { + return result + } + + if (result.kind === "value") { + self.lastValue = result.value + } + } while (yield* self.evaluateExpression(testNode)) + + return { kind: "none" } satisfies StatementResult + }) + } + + private evaluateForStatement(node: AstNode): Effect.Effect { + this.pushScope() + const self = this + return Effect.gen(function*() { + const initNode = getOptionalNode(node, "init") + const testNode = getOptionalNode(node, "test") + const updateNode = getOptionalNode(node, "update") + const bodyNode = getNode(node, "body") + + if (initNode) { + if (initNode.type === "VariableDeclaration") { + yield* self.evaluateVariableDeclaration(initNode) + } else { + yield* self.evaluateExpression(initNode) + } + } + + const perIterationBindings = initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var" + ? Array.from(self.currentScope().keys()) + : [] + + while (testNode ? yield* self.evaluateExpression(testNode) : true) { + let iterationScope: Map | undefined + if (perIterationBindings.length > 0) { + iterationScope = new Map(perIterationBindings.map((name) => { + const binding = self.currentScope().get(name)! + return [name, { ...binding }] + })) + self.scopes.push(iterationScope) + } + const result = yield* self.evaluateStatement(bodyNode).pipe( + Effect.ensuring(Effect.sync(() => { + if (iterationScope) self.popScope() + })), + ) + + if (result.kind === "return") { + return result + } + + if (result.kind === "break") { + return { kind: "none" } satisfies StatementResult + } + + if (result.kind === "value") { + self.lastValue = result.value + } + + if (iterationScope) { + const loopScope = self.currentScope() + for (const name of perIterationBindings) { + loopScope.set(name, { ...iterationScope.get(name)! }) + } + } + + if (updateNode) { + yield* self.evaluateExpression(updateNode) + } + + if (result.kind === "continue") { + continue + } + } + + return { kind: "none" } satisfies StatementResult + }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) + } + + private evaluateForOfStatement(node: AstNode): Effect.Effect { + if (getBoolean(node, "await")) { + throw new InterpreterRuntimeError("for await...of is not supported.", node) + } + + const self = this + return Effect.gen(function*() { + const left = getNode(node, "left") + const right = yield* self.evaluateExpression(getNode(node, "right")) + const body = getNode(node, "body") + + // Arrays iterate in place; strings iterate code points; Maps iterate [key, value] + // pairs and Sets iterate values over a snapshot (mutation during iteration is safe). + const iterable = Array.isArray(right) ? right : spreadItems(right) + if (iterable === undefined) { + throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node) + } + if (iterable !== right) self.recordWork(iterable.length, node) + + let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined + let assignmentName: string | undefined + + if (left.type === "VariableDeclaration") { + const declarations = getArray(left, "declarations") + if (declarations.length !== 1) { + throw new InterpreterRuntimeError("for...of supports one declared binding.", left) + } + + const declarator = asNode(declarations[0], "declarations[0]") + declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" } + } else if (left.type === "Identifier") { + assignmentName = getString(left, "name") + } else { + throw new InterpreterRuntimeError("Unsupported for...of binding.", left) + } + + for (const value of iterable) { + if (declaration) { + self.pushScope() + yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left) + } else if (assignmentName) { + self.setIdentifierValue(assignmentName, value, left) + } + + const result = yield* self.evaluateStatement(body).pipe( + Effect.ensuring(Effect.sync(() => { + if (declaration) self.popScope() + })), + ) + + if (result.kind === "return") { + return result + } + + if (result.kind === "break") { + return { kind: "none" } + } + + if (result.kind === "value") { + self.lastValue = result.value + } + + if (result.kind === "continue") { + continue + } + } + + return { kind: "none" } + }) + } + + // Own enumerable string keys of a value, shared by `for...in` and `Object.keys` over tool + // references: plain data objects enumerate their own keys, arrays their index strings (plus + // any own non-index properties, e.g. match results' index/groups — exactly Object.keys in + // JS), and a tool reference the namespace/tool names at its path in the host tool tree. + // Returns undefined for everything else so callers can raise a contextual error. + private enumerableKeys(value: unknown, node: AstNode): Array | undefined { + if (value instanceof ToolReference) { + const keys = [...this.toolKeys(value.path)] + this.recordWork(keys.length, node) + return keys + } + if (Array.isArray(value)) { + this.recordWork(value.length, node) + return Object.keys(value) + } + if (value !== null && typeof value === "object" && !isRuntimeReference(value)) { + const keys = Object.keys(value) + this.recordWork(keys.length, node) + return keys + } + return undefined + } + + private evaluateForInStatement(node: AstNode): Effect.Effect { + const self = this + return Effect.gen(function*() { + const left = getNode(node, "left") + const right = yield* self.evaluateExpression(getNode(node, "right")) + const body = getNode(node, "body") + + // Keys are snapshotted up front (mutation during iteration is safe): plain objects + // enumerate their own keys, arrays their index strings, and tool references the + // namespace/tool names at that node — the same enumeration Object.keys performs. + // Anything else (strings, Maps, Sets, numbers, null, ...) is a deliberate error rather + // than real JS's surprising behavior (indices for strings, zero iterations for + // Maps/Sets/null): the hint points at the constructs that do what the program means. + const keys = self.enumerableKeys(right, node) + if (keys === undefined) { + throw new InterpreterRuntimeError( + "for...in requires a plain object, array, or tools reference in CodeMode. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.", + node, + ) + } + + let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined + let assignmentName: string | undefined + + if (left.type === "VariableDeclaration") { + const declarations = getArray(left, "declarations") + if (declarations.length !== 1) { + throw new InterpreterRuntimeError("for...in supports one declared binding.", left) + } + + const declarator = asNode(declarations[0], "declarations[0]") + declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" } + } else if (left.type === "Identifier") { + assignmentName = getString(left, "name") + } else { + throw new InterpreterRuntimeError("Unsupported for...in binding.", left) + } + + for (const key of keys) { + if (declaration) { + self.pushScope() + yield* self.declarePattern(declaration.pattern, key, declaration.mutable, left) + } else if (assignmentName) { + self.setIdentifierValue(assignmentName, key, left) + } + + const result = yield* self.evaluateStatement(body).pipe( + Effect.ensuring(Effect.sync(() => { + if (declaration) self.popScope() + })), + ) + + if (result.kind === "return") { + return result + } + + if (result.kind === "break") { + return { kind: "none" } + } + + if (result.kind === "value") { + self.lastValue = result.value + } + + if (result.kind === "continue") { + continue + } + } + + return { kind: "none" } + }) + } + + private evaluateBreakStatement(node: AstNode): StatementResult { + const labelNode = getOptionalNode(node, "label") + + if (labelNode) { + throw new InterpreterRuntimeError("Labeled break is not supported in v1.", node) + } + + return { kind: "break" } + } + + private evaluateContinueStatement(node: AstNode): StatementResult { + const labelNode = getOptionalNode(node, "label") + + if (labelNode) { + throw new InterpreterRuntimeError("Labeled continue is not supported in v1.", node) + } + + return { kind: "continue" } + } + + private evaluateThrowStatement(node: AstNode): Effect.Effect { + const argument = getNode(node, "argument") + return Effect.flatMap(this.evaluateExpression(argument), (value) => Effect.fail(new ProgramThrow(value))) + } + + private evaluateTryStatement(node: AstNode): Effect.Effect { + const body = getNode(node, "block") + const handler = getOptionalNode(node, "handler") + const finalizer = getOptionalNode(node, "finalizer") + const self = this + + const attempted = Effect.matchCauseEffect(this.evaluateStatement(body), { + onFailure: (cause) => { + if (cause.reasons.some(Cause.isInterruptReason) || !handler) { + return Effect.failCause(cause) + } + + // The program sees a plain { message } error (or the thrown value itself) — see + // caughtErrorValue, shared with Promise.allSettled rejection reasons. + const caught = caughtErrorValue(Cause.squash(cause)) + const parameter = getOptionalNode(handler, "param") + self.pushScope() + return Effect.gen(function*() { + if (parameter) yield* self.declarePattern(parameter, caught, true, handler) + return yield* self.evaluateStatement(getNode(handler, "body")) + }).pipe( + Effect.ensuring(Effect.sync(() => self.popScope())), + ) + }, + onSuccess: Effect.succeed, + }) + + if (!finalizer) return attempted + + const isAbrupt = (result: StatementResult): boolean => + result.kind === "return" || result.kind === "break" || result.kind === "continue" + + return Effect.matchCauseEffect(attempted, { + onFailure: (cause) => + cause.reasons.some(Cause.isInterruptReason) + ? Effect.failCause(cause) + : Effect.flatMap(this.evaluateStatement(finalizer), (final) => + isAbrupt(final) ? Effect.succeed(final) : Effect.failCause(cause)), + onSuccess: (result) => + Effect.flatMap(this.evaluateStatement(finalizer), (final) => + isAbrupt(final) ? Effect.succeed(final) : Effect.succeed(result)), + }) + } + + private evaluateVariableDeclaration(node: AstNode): Effect.Effect { + const kind = getString(node, "kind") + const declarations = getArray(node, "declarations") + const self = this + return Effect.gen(function*() { + for (const declarationValue of declarations) { + const declaration = asNode(declarationValue, "declarations") + + if (declaration.type !== "VariableDeclarator") { + throw new InterpreterRuntimeError("Unsupported variable declaration shape.", declaration) + } + + const init = getOptionalNode(declaration, "init") + const value = init ? yield* self.evaluateExpression(init) : undefined + yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration) + } + }) + } + + private declarePattern(pattern: AstNode, value: unknown, mutable: boolean, node: AstNode): Effect.Effect { + const self = this + return Effect.gen(function*() { + if (pattern.type === "Identifier") { + self.declare(getString(pattern, "name"), value, mutable, node) + return + } + + // Default values: `x = expr` / `{ a = 1 }` — the default is evaluated only when the value is undefined. + if (pattern.type === "AssignmentPattern") { + const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value + yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node) + return + } + + if (pattern.type === "ObjectPattern") { + if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) { + throw new InterpreterRuntimeError("Object destructuring requires a data object value.", pattern, "InvalidDataValue") + } + + const consumed = new Set() + for (const propertyValue of getArray(pattern, "properties")) { + const property = asNode(propertyValue, "properties") + + // Object rest: `{ a, ...others }` — gather the not-yet-consumed own keys. + if (property.type === "RestElement") { + const rest: SafeObject = Object.create(null) as SafeObject + for (const [key, item] of Object.entries(value as SafeObject)) { + if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item + } + yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property) + continue + } + + if (property.type !== "Property" || getBoolean(property, "computed") || getString(property, "kind") !== "init") { + throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property) + } + + const keyNode = getNode(property, "key") + const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value) + if (isBlockedMember(key)) { + throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode) + } + consumed.add(key) + yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property) + } + return + } + + if (pattern.type === "ArrayPattern") { + if (!Array.isArray(value)) { + throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern) + } + + for (const [index, item] of getArray(pattern, "elements").entries()) { + if (item === null) continue + const element = asNode(item, `elements[${index}]`) + // Array rest: `[head, ...tail]` — binds the remaining elements (must be last). + if (element.type === "RestElement") { + yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element) + break + } + yield* self.declarePattern(element, value[index], mutable, pattern) + } + return + } + + throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern) + }) + } + + private evaluateExpression(node: AstNode): Effect.Effect { + this.recordOperation(node) + + switch (node.type) { + case "Literal": { + // A regex literal parses as a Literal node carrying { pattern, flags }; construct the + // sandbox regex from those (the host `value` instance is never exposed). + const regex = node.regex + if (isRecord(regex) && typeof regex.pattern === "string") { + return Effect.sync(() => this.constructRegExp([regex.pattern, typeof regex.flags === "string" ? regex.flags : ""], node)) + } + return Effect.sync(() => boundedData(node.value, "Literal", node, this.limits)) + } + case "Identifier": + return Effect.sync(() => this.getIdentifierValue(getString(node, "name"), node)) + case "BinaryExpression": + return this.evaluateBinaryExpression(node) + case "LogicalExpression": + return this.evaluateLogicalExpression(node) + case "UnaryExpression": + return this.evaluateUnaryExpression(node) + case "AssignmentExpression": + return this.evaluateAssignmentExpression(node) + case "CallExpression": + return this.evaluateCallExpression(node) + case "ArrowFunctionExpression": + case "FunctionExpression": + return Effect.sync(() => this.createFunction(node)) + case "MemberExpression": + return this.readMember(node) + case "ChainExpression": + return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => + value === OptionalShortCircuit ? undefined : value) + case "ObjectExpression": + return this.evaluateObjectExpression(node) + case "ArrayExpression": + return this.evaluateArrayExpression(node) + case "TemplateLiteral": + return this.evaluateTemplateLiteral(node) + case "ConditionalExpression": + return this.evaluateConditionalExpression(node) + case "UpdateExpression": + return this.evaluateUpdateExpression(node) + case "AwaitExpression": { + // `await` resolves a promise value; awaiting anything else is a passthrough no-op, + // matching real JS semantics for non-thenables. + const self = this + return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) => + value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value)) + } + case "NewExpression": + return this.evaluateNewExpression(node) + default: + throw unsupportedSyntax(node.type, node) + } + } + + private evaluateNewExpression(node: AstNode): Effect.Effect { + const callee = getNode(node, "callee") + if (callee.type !== "Identifier") { + throw unsupportedSyntax("NewExpression", node) + } + const name = getString(callee, "name") + const argNodes = getArray(node, "arguments") + const self = this + if (name === "Promise") { + throw new InterpreterRuntimeError( + "new Promise(...) is not supported in CodeMode; tool calls already return promises — call the tool and await the result.", + node, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + if (errorConstructors.has(name)) { + return Effect.gen(function*() { + const arg = argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined + const message = arg === undefined ? "" : coerceToString(arg) + const errorValue: SafeObject = Object.create(null) as SafeObject + errorValue.name = name + errorValue.message = message + return errorValue + }) + } + if (valueConstructors.has(name)) { + return Effect.gen(function*() { + const args = yield* self.evaluateCallArguments(argNodes) + switch (name) { + case "Date": return self.constructDate(args) + case "RegExp": return self.constructRegExp(args, node) + case "Map": return self.constructMap(args[0], node) + default: return self.constructSet(args[0], node) + } + }) + } + throw unsupportedSyntax("NewExpression", node) + } + + private constructDate(args: Array): SandboxDate { + if (args.length === 0) return new SandboxDate(Date.now()) + if (args.length === 1) { + const arg = args[0] + if (arg instanceof SandboxDate) return new SandboxDate(arg.time) + if (typeof arg === "number") return new SandboxDate(new Date(arg).getTime()) + if (typeof arg === "string") return new SandboxDate(Date.parse(arg)) + return new SandboxDate(Number.NaN) + } + // new Date(year, month, day?, hours?, ...) — local-time component form. + const parts = args.map((arg) => coerceToNumber(arg)) + return new SandboxDate(new Date(...(parts as [number, number])).getTime()) + } + + private constructRegExp(args: Array, node: AstNode): SandboxRegExp { + const first = args[0] + const pattern = first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first) + const flagsArg = args[1] + if (flagsArg !== undefined && typeof flagsArg !== "string") { + throw new InterpreterRuntimeError("RegExp flags must be a string.", node) + } + const flags = flagsArg ?? (first instanceof SandboxRegExp ? first.regex.flags : "") + boundedData(pattern, "RegExp pattern", node, this.limits) + this.recordWork(pattern.length, node) + try { + return new SandboxRegExp(pattern, flags) + } catch (error) { + throw new InterpreterRuntimeError(`Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`, node) + } + } + + private constructMap(init: unknown, node: AstNode): SandboxMap { + const target = new SandboxMap() + if (init === undefined || init === null) return target + const entries = Array.isArray(init) + ? init + : init instanceof SandboxMap + ? Array.from(init.map.entries(), ([key, item]): Array => [key, item]) + : undefined + if (entries === undefined) { + throw new InterpreterRuntimeError("new Map(...) expects an array of [key, value] pairs, a Map, or no argument.", node) + } + this.recordWork(entries.length, node) + for (const pair of entries) { + if (!Array.isArray(pair)) { + throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs.", node) + } + this.mapSet(target, pair[0], pair[1], node) + } + return target + } + + private constructSet(init: unknown, node: AstNode): SandboxSet { + const target = new SandboxSet() + if (init === undefined || init === null) return target + const items = Array.isArray(init) + ? init + : init instanceof SandboxSet + ? Array.from(init.set.values()) + : typeof init === "string" + ? Array.from(init) + : undefined + if (items === undefined) { + throw new InterpreterRuntimeError("new Set(...) expects an array, Set, string, or no argument.", node) + } + this.recordWork(items.length, node) + for (const item of items) this.setAdd(target, item, node) + return target + } + + // Map/Set mutations maintain the instance's incremental byte total (the same fast path the + // array/object mutation helpers use) and enforce collection-length and data-size limits + // before mutating; the authoritative copyIn/copyOut boundary checks remain underneath. + private mapSet(target: SandboxMap, key: unknown, value: unknown, node: AstNode): void { + const valueBytes = this.nestedValueBytes(value, "Map entry", node) + if (target.map.has(key)) { + const previous = this.nestedValueBytes(target.map.get(key), "Map entry", node) + const next = target.bytes - previous + valueBytes + if (next > this.limits.maxDataBytes) { + throw new InterpreterRuntimeError(`Map exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") + } + target.bytes = next + } else { + if (target.map.size >= this.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Map exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + const next = target.bytes + this.nestedValueBytes(key, "Map entry", node) + valueBytes + 2 + if (next > this.limits.maxDataBytes) { + throw new InterpreterRuntimeError(`Map exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") + } + target.bytes = next + } + target.map.set(key, value) + } + + private setAdd(target: SandboxSet, value: unknown, node: AstNode): void { + if (target.set.has(value)) return + if (target.set.size >= this.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Set exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + const next = target.bytes + this.nestedValueBytes(value, "Set entry", node) + 2 + if (next > this.limits.maxDataBytes) { + throw new InterpreterRuntimeError(`Set exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") + } + target.bytes = next + target.set.add(value) + } + + private evaluateBinaryExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + const self = this + return Effect.gen(function*() { + const lhs = (yield* self.evaluateExpression(getNode(node, "left"))) as any + const rhs = (yield* self.evaluateExpression(getNode(node, "right"))) as any + if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) { + throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue") + } + // Data objects/arrays are null-prototype, so JS's ToPrimitive throws an opaque host + // "No default value" TypeError when an operator coerces them. Coerce to their JS string + // form first (as String(x) / template literals do) so operators behave like JavaScript. + // A Date follows its ToPrimitive hints: string for `+` (concatenation), its time value + // for arithmetic and ordering — so `end - start` and `a < b` work as in JS. + // Identity (=== / !==) and the right operand of `in` keep their raw object value. + const coerceOperand = (operand: unknown): unknown => { + if (operand instanceof SandboxDate) return operator === "+" ? coerceToString(operand) : operand.time + return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand + } + const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object" + const l = coerceOperand(lhs) as any + const r = coerceOperand(rhs) as any + let result: unknown + switch (operator) { + case "+": result = l + r; break + case "-": result = l - r; break + case "*": result = l * r; break + case "/": result = l / r; break + case "%": result = l % r; break + case "**": result = l ** r; break + // Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces. + case "==": result = bothObjects ? lhs === rhs : l == r; break + case "===": result = lhs === rhs; break + case "!=": result = bothObjects ? lhs !== rhs : l != r; break + case "!==": result = lhs !== rhs; break + case "<": result = l < r; break + case "<=": result = l <= r; break + case ">": result = l > r; break + case ">=": result = l >= r; break + case "&": result = l & r; break + case "|": result = l | r; break + case "^": result = l ^ r; break + case "<<": result = l << r; break + case ">>": result = l >> r; break + case ">>>": result = l >>> r; break + case "in": + if (rhs === null || typeof rhs !== "object") { + throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node) + } + // Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...). + result = Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey); break + default: throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node) + } + return boundedData(result, "Binary expression result", node, self.limits) + }) + } + + private evaluateLogicalExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + return Effect.flatMap(this.evaluateExpression(getNode(node, "left")), (left) => { + if (operator === "&&") return left ? this.evaluateExpression(getNode(node, "right")) : Effect.succeed(left) + if (operator === "||") return left ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right")) + if (operator === "??") return left !== null && left !== undefined ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right")) + throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node) + }) + } + + private evaluateUnaryExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + const argument = getNode(node, "argument") + // `typeof undeclaredIdentifier` is `"undefined"` in JS (never a ReferenceError), so + // feature-detection guards like `typeof x !== "undefined"` don't crash. Short-circuit before + // evaluating the argument; a declared-but-TDZ binding still falls through to the normal throw. + if (operator === "typeof" && argument.type === "Identifier" && !this.resolveBinding(getString(argument, "name"))) { + return Effect.succeed("undefined") + } + return Effect.map(this.evaluateExpression(argument), (value) => { + // `typeof` and `!` never throw in JS — they observe any value (functions and runtime + // references included) without coercing it, so feature detection and negation work. + if (operator === "typeof") return typeofValue(value) + if (operator === "!") return !value + if (containsOpaqueReference(value)) { + throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue") + } + const rhs = value as any + // Numeric/bitwise unary operators ToPrimitive their operand; a Date yields its time value + // (`+date` is the epoch-ms idiom), other null-prototype data objects/arrays coerce to + // their JS string form first (see evaluateBinaryExpression). + const operand = rhs instanceof SandboxDate + ? (rhs.time as any) + : rhs !== null && typeof rhs === "object" + ? (coerceToString(rhs) as any) + : rhs + let result: unknown + switch (operator) { + case "+": result = +operand; break + case "-": result = -operand; break + case "~": result = ~operand; break + default: throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node) + } + return boundedData(result, "Unary expression result", node, this.limits) + }) + } + + private evaluateAssignmentExpression(node: AstNode): Effect.Effect { + const left = getNode(node, "left") + const operator = getString(node, "operator") + const self = this + return Effect.gen(function*() { + if (operator === "??=" || operator === "||=" || operator === "&&=") { + return yield* self.evaluateLogicalAssignment(node, left, operator) + } + const rightValue = yield* self.evaluateExpression(getNode(node, "right")) + if (left.type === "Identifier") { + const name = getString(left, "name") + if (operator === "=") return self.setIdentifierValue(name, rightValue, left) + const next = boundedData(self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node), "Assignment result", node, self.limits) + return self.setIdentifierValue(name, next, left) + } + if (left.type === "MemberExpression") { + if (operator === "=") return yield* self.writeMember(left, rightValue) + return yield* self.modifyMember(left, (current) => { + const next = boundedData(self.applyCompoundAssignment(operator, current, rightValue, node), "Assignment result", node, self.limits) + return Effect.succeed({ write: true, next, result: next }) + }) + } + throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left) + }) + } + + private evaluateLogicalAssignment(node: AstNode, left: AstNode, operator: string): Effect.Effect { + const self = this + const shouldAssign = (current: unknown): boolean => + operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current) + if (left.type === "Identifier") { + const name = getString(left, "name") + return Effect.gen(function*() { + const current = self.getIdentifierValue(name, left) + if (!shouldAssign(current)) return current + const rightValue = yield* self.evaluateExpression(getNode(node, "right")) + return self.setIdentifierValue(name, rightValue, left) + }) + } + if (left.type === "MemberExpression") { + // Resolve the member exactly once; evaluate the RHS only if we actually assign. + return self.modifyMember(left, (current) => + shouldAssign(current) + ? Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => ({ write: true, next: rightValue, result: rightValue })) + : Effect.succeed({ write: false, next: current, result: current })) + } + throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left) + } + + private evaluateUpdateExpression(node: AstNode): Effect.Effect { + const operator = getString(node, "operator") + const argument = getNode(node, "argument") + const prefix = getBoolean(node, "prefix") + + const increment = operator === "++" ? 1 : operator === "--" ? -1 : undefined + + if (increment === undefined) { + throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node) + } + + if (argument.type === "Identifier") { + return Effect.sync(() => { + const name = getString(argument, "name") + const current = Number(this.getIdentifierValue(name, argument)) + const next = boundedData(current + increment, "Update result", node, this.limits) as number + this.setIdentifierValue(name, next, argument) + return prefix ? next : current + }) + } + + if (argument.type === "MemberExpression") { + return this.modifyMember(argument, (current) => { + const value = Number(current) + const next = boundedData(value + increment, "Update result", node, this.limits) as number + return Effect.succeed({ write: true, next, result: prefix ? next : value }) + }) + } + + throw new InterpreterRuntimeError("Update target must be an Identifier or MemberExpression.", argument) + } + + private evaluateCallExpression(node: AstNode): Effect.Effect { + const callee = getNode(node, "callee") + const argNodes = getArray(node, "arguments") + + const self = this + return Effect.gen(function*() { + const callable = yield* self.evaluateExpression(callee) + if (callable === OptionalShortCircuit) return OptionalShortCircuit + if ((callable === null || callable === undefined) && node.optional === true) return OptionalShortCircuit + + const args = yield* self.evaluateCallArguments(argNodes) + + if (callable instanceof ToolReference) { + if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee) + // An un-awaited tool call is a first-class promise value; the call itself starts now. + return yield* self.createToolCallPromise(callable.path, args) + } + if (callable instanceof PromiseMethodReference) { + return yield* self.invokePromiseMethod(callable, args, node) + } + if (callable instanceof CodeModeFunction) { + return yield* self.invokeFunction(callable, args) + } + if (callable instanceof IntrinsicReference) { + return yield* self.invokeIntrinsic(callable, args, node) + } + if (callable instanceof GlobalMethodReference) { + if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node) + if (callable.namespace === "Object" && args[0] instanceof ToolReference) { + return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node) + } + const globalResult = invokeGlobalMethod(callable, args, node, self.limits) + self.recordWork(workUnits(globalResult), node) + return boundedData(globalResult, `${callable.namespace}.${callable.name} result`, node, self.limits) + } + if (callable instanceof CoercionFunction) { + const coercionResult = invokeCoercion(callable, args, node, self.limits) + self.recordWork(workUnits(coercionResult), node) + return boundedData(coercionResult, `${callable.name} result`, node, self.limits) + } + throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee) + }) + } + + // Object.* over a tool reference: `Object.keys(tools)` / `Object.keys(tools.ns)` enumerate + // namespace/tool names from the host tool tree — the discovery idiom a model reaches for + // first. Every other Object helper cannot produce data from a tool reference, so it fails + // with a pointer at the working idioms instead of the generic plain-objects-only message. + private invokeObjectMethodOnTools(name: string, ref: ToolReference, node: AstNode): unknown { + if (name === "keys") { + const keys = this.enumerableKeys(ref, node)! + return boundedData(keys, "Object.keys result", node, this.limits) + } + throw new InterpreterRuntimeError( + `Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`, + node, + "InvalidDataValue", + ) + } + + private invokeConsole(name: string, args: Array, node: AstNode): undefined { + if (!consoleMethods.has(name)) throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node) + const line = publicErrorMessage(this.formatConsoleMessage(name, args, node)) + const bytes = dataByteLength(line) + if (this.logBudget.bytes + bytes > this.limits.maxAuditBytes) { + throw new InterpreterRuntimeError(`Execution exceeds its log limit of ${this.limits.maxAuditBytes} bytes.`, node, "AuditLimitExceeded") + } + this.logBudget.bytes += bytes + this.logs.push(line) + return undefined + } + + private formatConsoleMessage(name: string, args: Array, node: AstNode): string { + if (name === "dir") return args.length === 0 ? "undefined" : this.formatConsoleArgument(args[0], node) + if (name === "table") return this.formatConsoleTable(args[0], args[1], node) + const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : "" + return `${prefix}${args.map((arg) => this.formatConsoleArgument(arg, node)).join(" ")}` + } + + private formatConsoleArgument(value: unknown, node: AstNode): string { + if (value === undefined) return "undefined" + // Sandbox values print debugger-friendly forms (their boundary JSON would lose the content). + if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]" + if (value instanceof SandboxDate) return coerceToString(value) + if (value instanceof SandboxRegExp) return coerceToString(value) + if (value instanceof SandboxMap) { + return `Map(${value.map.size}) ${this.formatConsoleArgument(Array.from(value.map.entries(), ([key, item]): Array => [key, item]), node)}` + } + if (value instanceof SandboxSet) { + return `Set(${value.set.size}) ${this.formatConsoleArgument(Array.from(value.set.values()), node)}` + } + if (containsRuntimeReference(value)) return "[CodeMode reference]" + const copied = copyOut(copyIn(value, "console argument", this.limits), true) + if (typeof copied === "string") return copied + if (copied === null || typeof copied === "number" || typeof copied === "boolean") return String(copied) + try { + return JSON.stringify(copied) ?? String(copied) + } catch { + throw new InterpreterRuntimeError("console argument must contain data only.", node, "InvalidDataValue") + } + } + + private formatConsoleTable(value: unknown, columnsArgument: unknown, node: AstNode): string { + if (value === undefined) return "undefined" + if (containsRuntimeReference(value)) return "[CodeMode reference]" + const data = copyOut(copyIn(value, "console.table argument", this.limits), true) + const columns = this.consoleTableColumns(columnsArgument, node) + const rows = this.consoleTableRows(data, columns) + const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values)))) + const header = ["(index)", ...keys].join("\t") + return [header, ...rows.map((row) => [row.index, ...keys.map((key) => this.formatConsoleTableCell(row.values[key]))].join("\t"))].join("\n") + } + + private consoleTableColumns(value: unknown, node: AstNode): ReadonlyArray | undefined { + if (value === undefined) return undefined + if (containsRuntimeReference(value)) return undefined + const columns = copyOut(copyIn(value, "console.table columns", this.limits), true) + return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined + } + + private consoleTableRows(data: unknown, columns: ReadonlyArray | undefined): Array<{ readonly index: string; readonly values: Record }> { + if (Array.isArray(data)) { + return data.map((item, index) => ({ index: String(index), values: this.consoleTableValues(item, columns) })) + } + if (data !== null && typeof data === "object") { + return Object.entries(data).map(([index, item]) => ({ index, values: this.consoleTableValues(item, columns) })) + } + return [{ index: "0", values: { Value: data } }] + } + + private consoleTableValues(value: unknown, columns: ReadonlyArray | undefined): Record { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + const source = value as Record + if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]])) + return Object.fromEntries(Object.entries(source)) + } + return { Value: value } + } + + private formatConsoleTableCell(value: unknown): string { + if (value === undefined) return "" + if (typeof value === "string") return value + if (value === null || typeof value === "number" || typeof value === "boolean") return String(value) + return JSON.stringify(value) ?? String(value) + } + + private evaluateCallArguments(argNodes: Array): Effect.Effect, unknown, R> { + const self = this + return Effect.gen(function*() { + const args: Array = [] + for (const [index, arg] of argNodes.entries()) { + const argNode = asNode(arg, `arguments[${index}]`) + if (argNode.type === "SpreadElement") { + const spread = yield* self.evaluateExpression(getNode(argNode, "argument")) + const items = spreadItems(spread) + if (items === undefined) throw new InterpreterRuntimeError("Spread arguments require an array, string, Map, or Set in CodeMode.", argNode) + if (args.length + items.length > self.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Call arguments exceed the maximum collection length of ${self.limits.maxCollectionLength}.`, argNode, "InvalidDataValue") + } + args.push(...items) + self.recordWork(items.length, argNode) + } else { + args.push(yield* self.evaluateExpression(argNode)) + if (args.length > self.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Call arguments exceed the maximum collection length of ${self.limits.maxCollectionLength}.`, argNode, "InvalidDataValue") + } + } + } + return args + }) + } + + // Promise.* over ordinary runtime values. Combinators accept ANY array (or spreadable + // collection) mixing promise values and plain data — built inline, beforehand, via spread, + // whatever — because tool calls already run eagerly on their own fibers; the combinators + // only observe settlements. Joining is therefore sequential (no extra fibers) without + // costing parallelism, and the concurrency cap stays where the work is: the fork semaphore. + private invokePromiseMethod(ref: PromiseMethodReference, args: Array, node: AstNode): Effect.Effect { + const self = this + if (ref.name === "resolve") { + // Promise.resolve of a promise is that promise (JS flattens); anything else is a + // promise already fulfilled with the value. + const value = args[0] + return Effect.succeed(value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value))) + } + if (ref.name === "reject") { + return Effect.sync(() => new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0])))) + } + + const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0]) + if (items === undefined) { + throw new InterpreterRuntimeError( + `Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`, + node, + ) + } + this.recordWork(items.length, node) + + switch (ref.name) { + case "all": { + // Mark every promise element observed up-front (Promise.all handles all of its + // members' failures, as in JS), then join in index order; the first failure rejects + // the whole call while unrelated in-flight members keep running. + const settles = items.map((item) => (item instanceof SandboxPromise ? this.settlePromise(item, node) : Effect.succeed(item))) + return Effect.gen(function*() { + const values: Array = [] + for (const settle of settles) values.push(yield* settle) + return boundedProgramValue(values, "Promise.all result", node, self.limits) + }) + } + case "allSettled": { + const observations = items.map((item) => + item instanceof SandboxPromise + ? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit })) + : Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) })) + return Effect.gen(function*() { + const outcomes: Array = [] + for (const observation of observations) { + const { exit, promise } = yield* observation + if (Exit.isSuccess(exit)) { + outcomes.push(Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value })) + continue + } + const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause) + if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) { + // Execution teardown (timeout/host interruption), not a program-level rejection. + return yield* Effect.failCause(exit.cause) + } + const thrown = raceInterrupted + ? new InterpreterRuntimeError("This tool call was interrupted because another value settled a Promise.race first.", node) + : Cause.squash(exit.cause) + outcomes.push(Object.assign(Object.create(null) as SafeObject, { status: "rejected", reason: caughtErrorValue(thrown) })) + } + return boundedProgramValue(outcomes, "Promise.allSettled result", node, self.limits) + }) + } + case "race": { + if (items.length === 0) { + throw new InterpreterRuntimeError("Promise.race([]) would never settle; provide at least one promise or value.", node) + } + const observations = items.map((item, index) => + item instanceof SandboxPromise + ? Effect.map(this.observePromise(item), (exit) => ({ index, exit })) + : Effect.succeed({ index, exit: Exit.succeed(item as unknown) })) + return Effect.gen(function*() { + // First settlement (fulfilled OR rejected) wins; the observations never fail, so + // racing them yields exactly that. Losing in-flight calls are then interrupted. + const winner = yield* Effect.raceAll(observations) + for (const [index, item] of items.entries()) { + if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue + item.interrupted = true + yield* Fiber.interrupt(item.fiber) + } + const winningItem = items[winner.index] + return yield* self.unwrapPromiseExit(winningItem instanceof SandboxPromise ? winningItem : undefined, winner.exit, node) + }) + } + } + } + + private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { + const self = this + return Effect.suspend(() => { + const savedScopes = self.scopes + self.scopes = [...fn.capturedScopes, new Map()] + const run = Effect.gen(function*() { + // Seed every parameter name into the scope as a TDZ slot first, so a default that + // references another parameter resolves to that (uninitialized) param rather than + // silently falling through to an outer binding of the same name — matching JS. + const paramScope = self.currentScope() + for (const parameter of fn.parameters) { + for (const name of collectPatternNames(parameter)) { + paramScope.set(name, { mutable: true, value: undefined, initialized: false }) + } + } + for (const [index, parameter] of fn.parameters.entries()) { + if (parameter.type === "RestElement") { + yield* self.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter) + break + } + yield* self.declarePattern(parameter, args[index], true, parameter) + } + + if (fn.body.type === "BlockStatement") { + const result = yield* self.evaluateStatement(fn.body) + return result.kind === "return" || result.kind === "value" ? result.value : undefined + } + + return yield* self.evaluateExpression(fn.body) + }) + return run.pipe(Effect.ensuring(Effect.sync(() => { self.scopes = savedScopes }))) + }) + } + + private invokeIntrinsic(ref: IntrinsicReference, args: Array, node: AstNode): Effect.Effect { + if (typeof ref.receiver === "string") { + this.recordWork(ref.receiver.length, node) + const result = invokeStringMethod(ref.receiver, ref.name, args, node, this.limits) + if (typeof result === "string") this.recordWork(result.length, node) + return Effect.succeed(result) + } + if (typeof ref.receiver === "number") { + return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node, this.limits)) + } + if (Array.isArray(ref.receiver)) { + if (!cheapArrayMethods.has(ref.name)) this.recordWork(ref.receiver.length, node) + const self = this + return Effect.map(this.invokeArrayMethod(ref.receiver, ref.name, args, node), (result) => { + if (Array.isArray(result)) self.recordWork(result.length, node) + return result + }) + } + if (ref.receiver instanceof SandboxDate) { + return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node)) + } + if (ref.receiver instanceof SandboxRegExp) { + // Regex work scales with the subject; the pattern itself runs on the host engine. + this.recordWork(typeof args[0] === "string" ? args[0].length : 1, node) + return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node, this.limits)) + } + if (ref.receiver instanceof SandboxMap) { + return this.invokeMapMethod(ref.receiver, ref.name, args, node) + } + if (ref.receiver instanceof SandboxSet) { + return this.invokeSetMethod(ref.receiver, ref.name, args, node) + } + throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node) + } + + // Runs a Map/Set callback (forEach) accepting a user function or a builtin coercion, + // mirroring the array-method callback contract. + private applyCollectionCallback(callback: unknown, name: string, node: AstNode): (args: Array) => Effect.Effect { + if (!(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction)) { + throw new InterpreterRuntimeError(`${name} expects a function callback.`, node) + } + return (callbackArgs) => + callback instanceof CoercionFunction + ? Effect.succeed(invokeCoercion(callback, callbackArgs, node, this.limits)) + : this.invokeFunction(callback, callbackArgs) + } + + private invokeMapMethod(target: SandboxMap, name: string, args: Array, node: AstNode): Effect.Effect { + const self = this + switch (name) { + case "get": return Effect.succeed(target.map.get(args[0])) + case "has": return Effect.succeed(target.map.has(args[0])) + case "set": return Effect.sync(() => { + this.recordOperation(node) + this.mapSet(target, args[0], args[1], node) + return target + }) + case "delete": return Effect.sync(() => { + if (!target.map.has(args[0])) return false + target.bytes -= this.nestedValueBytes(args[0], "Map entry", node) + this.nestedValueBytes(target.map.get(args[0]), "Map entry", node) + 2 + return target.map.delete(args[0]) + }) + case "clear": return Effect.sync(() => { + target.map.clear() + target.bytes = 2 + return undefined + }) + case "keys": return Effect.sync(() => { + this.recordWork(target.map.size, node) + return boundedProgramValue(Array.from(target.map.keys()), "Map.keys result", node, this.limits) + }) + case "values": return Effect.sync(() => { + this.recordWork(target.map.size, node) + return boundedProgramValue(Array.from(target.map.values()), "Map.values result", node, this.limits) + }) + case "entries": return Effect.sync(() => { + this.recordWork(target.map.size, node) + return boundedProgramValue(Array.from(target.map.entries(), ([key, item]): Array => [key, item]), "Map.entries result", node, this.limits) + }) + case "forEach": { + const apply = this.applyCollectionCallback(args[0], "Map.forEach", node) + return Effect.gen(function*() { + self.recordWork(target.map.size, node) + // Snapshot iteration, matching the array-method callback contract. + for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target]) + return undefined + }) + } + default: throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node) + } + } + + private invokeSetMethod(target: SandboxSet, name: string, args: Array, node: AstNode): Effect.Effect { + const self = this + switch (name) { + case "has": return Effect.succeed(target.set.has(args[0])) + case "add": return Effect.sync(() => { + this.recordOperation(node) + this.setAdd(target, args[0], node) + return target + }) + case "delete": return Effect.sync(() => { + if (!target.set.has(args[0])) return false + target.bytes -= this.nestedValueBytes(args[0], "Set entry", node) + 2 + return target.set.delete(args[0]) + }) + case "clear": return Effect.sync(() => { + target.set.clear() + target.bytes = 2 + return undefined + }) + case "keys": + case "values": return Effect.sync(() => { + this.recordWork(target.set.size, node) + return boundedProgramValue(Array.from(target.set.values()), `Set.${name} result`, node, this.limits) + }) + case "entries": return Effect.sync(() => { + this.recordWork(target.set.size, node) + return boundedProgramValue(Array.from(target.set.values(), (item): Array => [item, item]), "Set.entries result", node, this.limits) + }) + case "forEach": { + const apply = this.applyCollectionCallback(args[0], "Set.forEach", node) + return Effect.gen(function*() { + self.recordWork(target.set.size, node) + for (const item of Array.from(target.set.values())) yield* apply([item, item, target]) + return undefined + }) + } + default: throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node) + } + } + + private invokeArrayMethod(target: Array, name: string, args: Array, node: AstNode): Effect.Effect { + const boundedCollection = (items: Array): Array => { + if (items.length > this.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Array.${name} exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + return boundedProgramValue(items, `Array.${name} result`, node, this.limits) as Array + } + const optNumber = (value: unknown, label: string): number | undefined => { + if (value === undefined) return undefined + if (typeof value !== "number") throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node) + return value + } + switch (name) { + case "join": { + if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) { + throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node) + } + const input = boundedData(target, "Array.join input", node, this.limits) as Array + return Effect.succeed(boundedData(input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : args[0] as string), "Array.join result", node, this.limits)) + } + case "includes": + if (args.length === 0 || args.length > 2) throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node) + return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index"))) + case "indexOf": + return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index"))) + case "lastIndexOf": + return Effect.succeed(args[1] === undefined ? target.lastIndexOf(args[0]) : target.lastIndexOf(args[0], optNumber(args[1], "start index"))) + case "at": + return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0)) + case "slice": + return Effect.succeed(boundedCollection(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end")))) + case "concat": + return Effect.succeed(boundedCollection(target.concat(...args))) + case "flat": + return Effect.succeed(boundedCollection(target.flat(optNumber(args[0], "depth") ?? 1))) + case "reverse": + return Effect.succeed(boundedCollection([...target].reverse())) + case "sort": + case "toSorted": + return this.sortArray(target, args[0], node) + case "toReversed": + return Effect.succeed(boundedCollection([...target].reverse())) + case "with": { + const index = optNumber(args[0], "index") ?? 0 + const resolved = index < 0 ? target.length + index : index + if (resolved < 0 || resolved >= target.length) { + throw new InterpreterRuntimeError("Array.with index is out of range.", node) + } + const copied = [...target] + copied[resolved] = args[1] + return Effect.succeed(boundedCollection(copied)) + } + case "push": { + if (target.length + args.length > this.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Array.push exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + // Validate before mutating (so no rollback is needed) and charge only the new elements, + // keeping a push loop O(1)/element instead of re-walking the whole array each call. + let added = 0 + for (const item of args) { + this.rejectCircularInsertion(target, item, "Array.push result", node) + added += this.nestedValueBytes(item, "Array.push result", node) + 1 + } + this.growContainerBytes(target, added, node, "Array.push result") + target.push(...args) + return Effect.succeed(target.length) + } + case "unshift": { + if (target.length + args.length > this.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Array.unshift exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + let added = 0 + for (const item of args) { + this.rejectCircularInsertion(target, item, "Array.unshift result", node) + added += this.nestedValueBytes(item, "Array.unshift result", node) + 1 + } + this.growContainerBytes(target, added, node, "Array.unshift result") + target.unshift(...args) + return Effect.succeed(target.length) + } + // Removals only shrink the array; drop the cached size so the next growth recomputes it. + case "pop": + this.containerSizes.delete(target) + return Effect.succeed(target.pop()) + case "shift": + this.containerSizes.delete(target) + return Effect.succeed(target.shift()) + } + + const callback = args[0] + if (!(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction)) { + throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node) + } + const self = this + // Accept a user arrow function or a builtin coercion callable (Boolean/String/Number), so the + // idioms `filter(Boolean)` / `map(String)` / `map(Number)` work as in JS. Coercions are + // synchronous; only CodeModeFunctions can await tool calls. + const apply = (callbackArgs: Array): Effect.Effect => + callback instanceof CoercionFunction + ? Effect.succeed(invokeCoercion(callback, callbackArgs, node, self.limits)) + : self.invokeFunction(callback, callbackArgs) + return Effect.gen(function*() { + // Iterate a snapshot taken at call time so a callback that mutates the array can't + // self-extend the loop — matching JS, where elements appended during iteration are not visited. + const items = target.slice() + switch (name) { + case "map": { + const values: Array = [] + for (const [index, item] of items.entries()) values.push(yield* apply([item, index, items])) + return boundedCollection(values) + } + case "flatMap": { + const values: Array = [] + for (const [index, item] of items.entries()) { + const mapped = yield* apply([item, index, items]) + if (Array.isArray(mapped)) values.push(...mapped) + else values.push(mapped) + boundedCollection(values) + } + return boundedCollection(values) + } + case "filter": { + const values: Array = [] + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) values.push(item) + } + return boundedCollection(values) + } + case "find": + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) return item + } + return undefined + case "findIndex": + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) return index + } + return -1 + case "some": + for (const [index, item] of items.entries()) { + if (yield* apply([item, index, items])) return true + } + return false + case "every": + for (const [index, item] of items.entries()) { + if (!(yield* apply([item, index, items]))) return false + } + return true + case "forEach": + for (const [index, item] of items.entries()) yield* apply([item, index, items]) + return undefined + case "reduce": { + let accumulator: unknown + let start: number + if (args.length >= 2) { + accumulator = args[1] + start = 0 + } else { + if (items.length === 0) throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) + accumulator = items[0] + start = 1 + } + for (let index = start; index < items.length; index += 1) { + accumulator = yield* apply([accumulator, items[index], index, items]) + } + return accumulator + } + case "reduceRight": { + let accumulator: unknown + let start: number + if (args.length >= 2) { + accumulator = args[1] + start = items.length - 1 + } else { + if (items.length === 0) throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node) + accumulator = items[items.length - 1] + start = items.length - 2 + } + for (let index = start; index >= 0; index -= 1) { + accumulator = yield* apply([accumulator, items[index], index, items]) + } + return accumulator + } + case "findLast": + for (let index = items.length - 1; index >= 0; index -= 1) { + if (yield* apply([items[index], index, items])) return items[index] + } + return undefined + case "findLastIndex": + for (let index = items.length - 1; index >= 0; index -= 1) { + if (yield* apply([items[index], index, items])) return index + } + return -1 + } + throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node) + }) + } + + private sortArray(target: Array, comparator: unknown, node: AstNode): Effect.Effect, unknown, R> { + if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) { + throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node) + } + if (!(comparator instanceof CodeModeFunction)) { + return Effect.sync(() => boundedProgramValue( + [...target].sort((a, b) => { + const left = coerceToString(a) + const right = coerceToString(b) + return left < right ? -1 : left > right ? 1 : 0 + }), + "Array.sort result", + node, + this.limits, + ) as Array) + } + const self = this + const mergeSort = (items: Array): Effect.Effect, unknown, R> => { + if (items.length <= 1) return Effect.succeed(items) + const midpoint = Math.floor(items.length / 2) + return Effect.gen(function*() { + const left = yield* mergeSort(items.slice(0, midpoint)) + const right = yield* mergeSort(items.slice(midpoint)) + const merged: Array = [] + let leftIndex = 0 + let rightIndex = 0 + while (leftIndex < left.length && rightIndex < right.length) { + // Coerce the comparator's result like JS ToNumber (data objects -> NaN, never a host + // crash) and treat NaN as 0 — the spec's "no consistent order" → keep the left element. + const order = coerceToNumber(yield* self.invokeFunction(comparator, [left[leftIndex], right[rightIndex]])) + if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++]) + else merged.push(right[rightIndex++]) + } + return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)] + }) + } + // Per spec, undefined elements sort to the end and the comparator is never called on them. + const defined = target.filter((item) => item !== undefined) + const undefinedCount = target.length - defined.length + return Effect.map(mergeSort(defined), (items) => + boundedProgramValue([...items, ...Array(undefinedCount).fill(undefined)], "Array.sort result", node, this.limits) as Array) + } + + private evaluateObjectExpression(node: AstNode): Effect.Effect, unknown, R> { + const objectValue: Record = Object.create(null) as Record + const keys = new Set() + const properties = getArray(node, "properties") + const self = this + return Effect.gen(function*() { + for (const propertyValue of properties) { + const property = asNode(propertyValue, "properties") + + if (property.type === "SpreadElement") { + const spread = yield* self.evaluateExpression(getNode(property, "argument")) + // JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common + // `{ ...maybeOpts, override }` merge works when the operand is absent. Sandbox values + // (Date/RegExp/Map/Set) have no own enumerable properties in JS, so they are no-ops too. + if (spread === null || spread === undefined || isSandboxValue(spread)) continue + if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) { + throw new InterpreterRuntimeError("Object spread requires a data object in CodeMode.", property, "InvalidDataValue") + } + for (const [key, value] of Object.entries(spread)) { + if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property) + objectValue[key] = value + keys.add(key) + if (keys.size > self.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Object expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, property, "InvalidDataValue") + } + } + continue + } + + if (property.type !== "Property") { + throw new InterpreterRuntimeError("Only standard object properties are supported.", property) + } + + if (getString(property, "kind") !== "init") { + throw new InterpreterRuntimeError("Only init object properties are supported.", property) + } + + const keyNode = getNode(property, "key") + const valueNode = getNode(property, "value") + const computed = getBoolean(property, "computed") + + let key: PropertyKey + + if (computed) { + key = self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode) + } else if (keyNode.type === "Identifier") { + key = getString(keyNode, "name") + } else if (keyNode.type === "Literal") { + key = self.toPropertyKey(keyNode.value, keyNode) + } else { + throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode) + } + + if (isBlockedMember(String(key))) { + throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, keyNode) + } + objectValue[String(key)] = yield* self.evaluateExpression(valueNode) + keys.add(String(key)) + if (keys.size > self.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Object expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, property, "InvalidDataValue") + } + } + + return boundedProgramValue(objectValue, "Object expression result", node, self.limits) as Record + }) + } + + private evaluateArrayExpression(node: AstNode): Effect.Effect, unknown, R> { + const elements = getArray(node, "elements") + const values: Array = [] + + const self = this + return Effect.gen(function*() { + for (const elementValue of elements) { + if (elementValue === null) { + values.push(undefined) + if (values.length > self.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Array expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + continue + } + const element = asNode(elementValue, "elements") + if (element.type === "SpreadElement") { + const spread = yield* self.evaluateExpression(getNode(element, "argument")) + const items = spreadItems(spread) + if (items === undefined) throw new InterpreterRuntimeError("Array spread requires an array, string, Map, or Set in CodeMode.", element) + values.push(...items) + self.recordWork(items.length, element) + } else { + values.push(yield* self.evaluateExpression(element)) + } + if (values.length > self.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Array expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + } + return boundedProgramValue(values, "Array expression result", node, self.limits) as Array + }) + } + + private evaluateTemplateLiteral(node: AstNode): Effect.Effect { + const quasis = getArray(node, "quasis") + const expressions = getArray(node, "expressions") + + let output = "" + + const self = this + return Effect.gen(function*() { + for (let index = 0; index < quasis.length; index += 1) { + const quasi = asNode(quasis[index], "quasis") + const rawValue = quasi.value + + if (!isRecord(rawValue) || typeof rawValue.cooked !== "string") { + throw new InterpreterRuntimeError("Invalid template literal quasi.", quasi) + } + + output += rawValue.cooked + boundedData(output, "Template literal result", node, self.limits) + + if (index < expressions.length) { + const raw = yield* self.evaluateExpression(asNode(expressions[index], "expressions")) + // Sandbox values stringify directly (ISO date, /regex/ form) — the data checkpoint + // would JSON-serialize them first (a regex would render "[object Object]" via {}). + const value = isSandboxValue(raw) ? raw : boundedData(raw, "Template interpolation", node, self.limits) + output += coerceToString(value) + boundedData(output, "Template literal result", node, self.limits) + } + } + + return output + }) + } + + private evaluateConditionalExpression(node: AstNode): Effect.Effect { + return Effect.flatMap(this.evaluateExpression(getNode(node, "test")), (test) => + this.evaluateExpression(getNode(node, test ? "consequent" : "alternate"))) + } + + private applyCompoundAssignment( + operator: string, + current: unknown, + incoming: unknown, + node: AstNode, + ): unknown { + const lhs = current as any + const rhs = incoming as any + + switch (operator) { + case "+=": + return lhs + rhs + case "-=": + return lhs - rhs + case "*=": + return lhs * rhs + case "/=": + return lhs / rhs + case "%=": + return lhs % rhs + case "**=": + return lhs ** rhs + case "&=": + return lhs & rhs + case "|=": + return lhs | rhs + case "^=": + return lhs ^ rhs + case "<<=": + return lhs << rhs + case ">>=": + return lhs >> rhs + case ">>>=": + return lhs >>> rhs + default: + throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node) + } + } + + private getMemberReference(node: AstNode): Effect.Effect { + const objectNode = getNode(node, "object") + const propertyNode = getNode(node, "property") + const computed = getBoolean(node, "computed") + const optional = node.optional === true + const self = this + return Effect.gen(function*() { + const objectValue = yield* self.evaluateExpression(objectNode) + if (objectValue === OptionalShortCircuit) return OptionalShortCircuit + if ((objectValue === null || objectValue === undefined) && optional) return OptionalShortCircuit + + const key = computed + ? self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode) + : propertyNode.type === "Identifier" + ? getString(propertyNode, "name") + : self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode) + + if (objectValue instanceof ToolReference) { + if (typeof key !== "string" || isBlockedMember(key)) { + throw new InterpreterRuntimeError("Tool paths must use safe string property names.", propertyNode) + } + return new ToolReference([...objectValue.path, key]) + } + + if (objectValue instanceof PromiseNamespace) { + if (typeof key === "string" && promiseStatics.has(key as PromiseMethodName)) { + return new PromiseMethodReference(key as PromiseMethodName) + } + throw new InterpreterRuntimeError( + `Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.resolve, and Promise.reject; consume promises with await.`, + propertyNode, + ) + } + + if (objectValue instanceof GlobalNamespace) { + if (typeof key !== "string" || isBlockedMember(key)) { + throw new InterpreterRuntimeError(`${objectValue.name}.${String(key)} is not available in CodeMode.`, propertyNode) + } + if (objectValue.name === "Math" && mathConstants.has(key)) { + return new ComputedValue((Math as unknown as Record)[key]) + } + return new GlobalMethodReference(objectValue.name, key) + } + + if (typeof objectValue === "string") { + if (key === "length") return new ComputedValue(objectValue.length) + if (typeof key === "number") return new ComputedValue(objectValue[key]) + if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)]) + if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key) + // Unknown property on a string reads as `undefined`, matching JS (`"x".foo === undefined`), + // instead of throwing — so defensive access like `result?.login ?? result` on a JSON-string + // tool result doesn't crash. (Optional chaining only guards null/undefined receivers, so a + // real string still reaches here.) Only the method allowlist above yields callables. + return new ComputedValue(undefined) + } + + if (typeof objectValue === "number") { + if (typeof key === "string" && numberMethods.has(key)) return new IntrinsicReference(objectValue, key) + // Unknown property on a number reads as `undefined`, matching JS, rather than throwing. + return new ComputedValue(undefined) + } + + // Number / String expose a small allowlist of statics; everything else stays opaque. + if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) { + if (objectValue.name === "Number" && numberConstants.has(key)) { + return new ComputedValue((Number as unknown as Record)[key]) + } + if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key) + if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key) + } + + // Sandbox value types expose their method/property allowlists; any other key reads as + // `undefined`, consistent with unknown-property reads on strings/numbers/arrays. + if (objectValue instanceof SandboxDate) { + if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxRegExp) { + if (typeof key === "string" && regexpProperties.has(key)) { + return new ComputedValue((objectValue.regex as unknown as Record)[key]) + } + if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxMap) { + if (key === "size") return new ComputedValue(objectValue.map.size) + if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + if (objectValue instanceof SandboxSet) { + if (key === "size") return new ComputedValue(objectValue.set.size) + if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key) + return new ComputedValue(undefined) + } + + // Any property access on a promise is a confused program (`p.then(...)`, `p.value`); + // reading `undefined` here would hide the missing await, so both paths get an explicit, + // await-hinting error instead of the forgiving unknown-property fallthrough. + if (objectValue instanceof SandboxPromise) { + if (key === "then" || key === "catch" || key === "finally") { + throw new InterpreterRuntimeError( + `Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) — e.g. \`const result = await tools.ns.tool(...)\`.`, + propertyNode, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + throw new InterpreterRuntimeError( + "This value is an un-awaited Promise and has no readable properties; await it first — e.g. `const result = await tools.ns.tool(...)`.", + objectNode, + "InvalidDataValue", + ) + } + + if (isRuntimeReference(objectValue)) { + throw new InterpreterRuntimeError("CodeMode runtime references are opaque and do not expose properties.", objectNode, "InvalidDataValue") + } + + if (typeof objectValue !== "object" || objectValue === null) { + throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode) + } + + if (typeof key === "string" && isBlockedMember(key)) { + throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, propertyNode) + } + + if (Array.isArray(objectValue)) { + if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && (typeof key !== "number" && !/^\d+$/.test(key))) { + // Own non-index properties read through (match results carry index/groups); like JS, + // they are readable in place and dropped by JSON at data boundaries. + if (typeof key === "string" && Object.hasOwn(objectValue, key)) { + return new ComputedValue((objectValue as Record & Array)[key]) + } + if (typeof key === "string" && retryableArrayMethods.has(key)) { + throw new InterpreterRuntimeError( + `Array.${key}(...) is not supported in CodeMode. Rewrite using map/filter/find/some/every/includes/join or a for...of loop.`, + propertyNode, + "UnsupportedSyntax", + [supportedSyntaxMessage], + ) + } + // Unknown property on an array reads as `undefined`, matching JS (`[1,2].foo === undefined`), + // instead of throwing — so defensive access under optional chaining behaves as expected. The + // retryable-method hint above still fires for real methods we don't support (e.g. splice). + return new ComputedValue(undefined) + } + return { target: objectValue, key } + } + + return { target: objectValue as SafeObject, key } + }) + } + + private readMember(node: AstNode): Effect.Effect { + return Effect.map(this.getMemberReference(node), (reference) => { + if (reference === OptionalShortCircuit) return OptionalShortCircuit + if (reference instanceof ComputedValue) return reference.value + if ( + reference === undefined || + reference instanceof ToolReference || + reference instanceof PromiseMethodReference || + reference instanceof IntrinsicReference || + reference instanceof GlobalMethodReference + ) return reference + if (Array.isArray(reference.target)) { + if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { + return new IntrinsicReference(reference.target, reference.key) + } + return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)] + } + return reference.target[String(reference.key)] + }) + } + + private writeMember(node: AstNode, value: unknown): Effect.Effect { + return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value })) + } + + // Resolves the member reference EXACTLY ONCE (so a side-effecting object/key expression + // runs once), then lets `compute` decide whether to write — enabling compound assignment, + // updates, plain writes, and short-circuiting logical assignment to share one safe path. + private modifyMember( + node: AstNode, + compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>, + ): Effect.Effect { + const self = this + return Effect.gen(function*() { + const reference = yield* self.getMemberReference(node) + if ( + reference === OptionalShortCircuit || + reference instanceof ComputedValue || + reference === undefined || + reference instanceof ToolReference || + reference instanceof PromiseMethodReference || + reference instanceof IntrinsicReference || + reference instanceof GlobalMethodReference + ) { + throw new InterpreterRuntimeError("Only data fields may be assigned in CodeMode.", node) + } + if (Array.isArray(reference.target)) { + if (reference.key === "length") throw new InterpreterRuntimeError("Array length cannot be assigned in CodeMode.", node) + if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { + throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node) + } + } + const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key) + const current = (reference.target as Record)[key] + const { write, next, result } = yield* compute(current) + if (write) self.assignToReference(reference, key, next, node) + return result + }) + } + + // Writes `next` to a resolved member, enforcing index/capacity/byte limits and rolling + // back the mutation if the bound is exceeded (so a caught error can't leave it grown). + // Byte size of a container, cached after the first walk and maintained incrementally by the + // mutation helpers. O(1) on a cache hit; O(container) once on the first touch. + private cachedContainerBytes(container: object, node: AstNode): number { + const cached = this.containerSizes.get(container) + if (cached !== undefined) return cached + const bytes = runtimeValueBytes(container, "value", node, this.limits) + this.recordWork(workUnits(container), node) + this.containerSizes.set(container, bytes) + return bytes + } + + // Bytes a value contributes when nested one level inside a container; also enforces that the + // nested value's depth stays within maxValueDepth. O(value), independent of the container size. + private nestedValueBytes(value: unknown, label: string, node: AstNode): number { + return runtimeValueBytes(value, label, node, this.limits, 1) + } + + private rejectCircularInsertion(container: object, value: unknown, label: string, node: AstNode, seen = new Set()): void { + if (value === container) throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") + if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return + seen.add(value) + const items = Array.isArray(value) ? value : Object.values(value) + for (const item of items) this.rejectCircularInsertion(container, item, label, node, seen) + seen.delete(value) + } + + // Add `addedBytes` of new entries to a container, rejecting (before any mutation) if that would + // exceed maxDataBytes, then record the container's new cached size. + private growContainerBytes(container: object, addedBytes: number, node: AstNode, label: string): void { + const next = this.cachedContainerBytes(container, node) + addedBytes + if (next > this.limits.maxDataBytes) { + throw new InterpreterRuntimeError(`${label} exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") + } + this.containerSizes.set(container, next) + } + + private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void { + if (Array.isArray(reference.target)) { + const target = reference.target + const index = key as number + if (!Number.isInteger(index) || index < 0 || index >= this.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Array assignment index must be between 0 and ${this.limits.maxCollectionLength - 1}.`, node, "InvalidDataValue") + } + this.rejectCircularInsertion(target, next, "Array assignment result", node) + const addedBytes = this.nestedValueBytes(next, "Array assignment result", node) + if (index === target.length) { + // Append — the hot path; O(1) incremental size update (this is the O(n^2)-loop fix). + this.growContainerBytes(target, addedBytes + 1, node, "Array assignment result") + target[index] = next + } else if (index < target.length) { + // Replace an existing slot (value or hole): adjust by the byte delta. + const oldBytes = this.nestedValueBytes(target[index], "Array assignment result", node) + const nextSize = this.cachedContainerBytes(target, node) + addedBytes - oldBytes + if (nextSize > this.limits.maxDataBytes) { + throw new InterpreterRuntimeError(`Array assignment result exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") + } + this.containerSizes.set(target, nextSize) + target[index] = next + } else { + // index > length introduces holes; fall back to a full revalidation and reset the cache. + const previousLength = target.length + target[index] = next + try { + boundedProgramValue(target, "Array assignment result", node, this.limits) + this.containerSizes.set(target, runtimeValueBytes(target, "value", node, this.limits)) + } catch (error) { + delete target[index] + target.length = previousLength + throw error + } + } + return + } + const target = reference.target as SafeObject + const objectKey = key as string + this.rejectCircularInsertion(target, next, "Object assignment result", node) + const addedBytes = this.nestedValueBytes(next, "Object assignment result", node) + if (Object.hasOwn(target, objectKey)) { + const oldBytes = this.nestedValueBytes(target[objectKey], "Object assignment result", node) + const nextSize = this.cachedContainerBytes(target, node) + addedBytes - oldBytes + if (nextSize > this.limits.maxDataBytes) { + throw new InterpreterRuntimeError(`Object assignment result exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") + } + this.containerSizes.set(target, nextSize) + target[objectKey] = next + return + } + const count = (this.objectCounts.get(target) ?? Object.keys(target).length) + 1 + if (count > this.limits.maxCollectionLength) { + throw new InterpreterRuntimeError(`Object assignment exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") + } + this.growContainerBytes(target, dataByteLength(objectKey) + addedBytes + 1, node, "Object assignment result") + this.objectCounts.set(target, count) + target[objectKey] = next + } + + private toPropertyKey(value: unknown, node: AstNode): string | number { + if (typeof value === "string" || typeof value === "number") { + return value + } + + throw new InterpreterRuntimeError("Property key must be a string or number.", node) + } + + private declare(name: string, value: unknown, mutable: boolean, node: AstNode): void { + const scope = this.currentScope() + + // A pre-seeded parameter slot (initialized === false) is being bound for the first time; + // anything else already present is a genuine duplicate declaration. + const existing = scope.get(name) + if (existing && existing.initialized !== false) { + throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node) + } + + scope.set(name, { mutable, value, initialized: true }) + } + + private getIdentifierValue(name: string, node: AstNode): unknown { + const binding = this.resolveBinding(name) + + if (!binding) { + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node) + } + + // A parameter default that forward-references a later (not-yet-bound) parameter — JS TDZ. + if (binding.initialized === false) { + throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node) + } + + return binding.value + } + + private setIdentifierValue(name: string, value: unknown, node: AstNode): unknown { + const binding = this.resolveBinding(name) + + if (!binding) { + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node) + } + + if (!binding.mutable) { + throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node) + } + + binding.value = value + return value + } + + private resolveBinding(name: string): Binding | undefined { + for (let index = this.scopes.length - 1; index >= 0; index -= 1) { + const scope = this.scopes[index] + const binding = scope?.get(name) + + if (binding) { + return binding + } + } + + return undefined + } + + private currentScope(): Map { + const scope = this.scopes[this.scopes.length - 1] + + if (!scope) { + throw new InterpreterRuntimeError("Interpreter scope stack is empty.") + } + + return scope + } + + private pushScope(): void { + this.scopes.push(new Map()) + } + + private popScope(): void { + this.scopes.pop() + } + + private recordOperation(node: AstNode): void { + this.recordWork(1, node) + } + + // Charge `units` of work to the operation budget so O(n) built-ins (collection/string + // walks and spreads) are bounded by maxOperations, not only by the wall-clock timeout. + private recordWork(units: number, node?: AstNode): void { + this.budget.operations += Math.max(1, Math.ceil(units)) + + if (this.budget.operations > this.limits.maxOperations) { + throw new InterpreterRuntimeError(`Execution exceeded its operation limit of ${this.limits.maxOperations}.`, node, "OperationLimitExceeded") + } + } +} + +/** + * Executes one Effect-native CodeMode program without constructing a reusable runtime. + * + * @example + * ```ts + * const result = yield* CodeMode.execute({ + * tools: { lookup }, + * code: `return await tools.lookup({ id: "order_42" })`, + * }) + * ``` + */ +const executeWithLimits = >( + options: ExecuteOptions, + limits: ResolvedExecutionLimits, + searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"], +): Effect.Effect> => { + const hooks = { + ...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }), + ...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }), + } + const tools = ToolRuntime.make((options.tools ?? {}) as HostTools>, limits.maxToolCalls, limits, hooks, searchIndex) + const logs: Array = [] + const logged = () => logs.length > 0 ? { logs: [...logs] } : {} + + if (new TextEncoder().encode(options.code).byteLength > limits.maxSourceBytes) { + return Effect.succeed({ + ok: false, + error: { kind: "InvalidDataValue", message: `Code exceeds the maximum source size of ${limits.maxSourceBytes} bytes.` }, + toolCalls: tools.calls, + }) + } + + if (options.code.trim().length === 0) { + return Effect.succeed({ + ok: false, + error: { kind: "ParseError", message: "Code cannot be empty." }, + toolCalls: tools.calls, + }) + } + + const operation = Effect.gen(function*() { + const program = parseProgram(options.code) + const interpreter = new Interpreter>(limits, tools.invoke, tools.keys, { operations: 0 }, logs, { bytes: 0 }) + const value = yield* interpreter.run(program) + const result = copyOut(copyIn(value, "Execution result", limits), true) as DataValue + if (dataByteLength(result) > limits.maxDataBytes) { + throw new InterpreterRuntimeError(`Execution result exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, undefined, "InvalidDataValue") + } + return { + ok: true, + value: result, + ...logged(), + toolCalls: tools.calls, + } satisfies ExecuteResult + }).pipe( + Effect.timeoutOrElse({ + duration: limits.timeoutMs, + orElse: () => Effect.succeed({ + ok: false, + error: { kind: "TimeoutExceeded", message: `Execution timed out after ${limits.timeoutMs}ms.` }, + ...logged(), + toolCalls: tools.calls, + } satisfies ExecuteResult), + }), + ) + + return operation.pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.succeed({ + ok: false, + error: normalizeError(Cause.squash(cause)), + ...logged(), + toolCalls: tools.calls, + } satisfies ExecuteResult), + ), + Effect.map((result) => boundOutput(result, limits.maxOutputBytes)), + ) +} + +const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength + +// Truncates to a UTF-8 byte budget without splitting a code point (a split multi-byte +// sequence decodes to a replacement character, which is dropped). +const utf8Truncate = (value: string, maxBytes: number): string => { + const bytes = new TextEncoder().encode(value) + if (bytes.byteLength <= maxBytes) return value + const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes))) + return text.endsWith("\uFFFD") ? text.slice(0, -1) : text +} + +/** + * Bounds the model-facing output (serialized result value plus logs) to `maxOutputBytes`. + * Oversized values are replaced by their truncated serialized text with an explanatory marker, + * and logs are kept from the start until the remaining budget is exhausted. Truncation never + * fails the execution; `truncated: true` marks affected results. + */ +const boundOutput = (result: ExecuteResult, maxOutputBytes: number): ExecuteResult => { + let truncated = false + + let value: DataValue = null + let valueBytes = 0 + if (result.ok) { + const serialized = JSON.stringify(result.value) ?? "null" + const bytes = utf8ByteLength(serialized) + if (bytes > maxOutputBytes) { + truncated = true + value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]` + valueBytes = maxOutputBytes + } else { + value = result.value + valueBytes = bytes + } + } + + const logs = result.logs ?? [] + const kept: Array = [] + const logBudget = Math.max(0, maxOutputBytes - valueBytes) + let logBytes = 0 + for (const line of logs) { + const lineBytes = utf8ByteLength(line) + 1 + if (logBytes + lineBytes > logBudget) break + logBytes += lineBytes + kept.push(line) + } + if (kept.length < logs.length) { + truncated = true + kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`) + } + + if (!truncated) return result + const logsPart = kept.length > 0 ? { logs: kept } : {} + return result.ok + ? { ok: true, value, ...logsPart, truncated: true, toolCalls: result.toolCalls } + : { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls } +} + +export const execute = >(options: ExecuteOptions): Effect.Effect> => { + const tools = (options.tools ?? {}) as HostTools> + ToolRuntime.assertValidTools(tools) + return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools)) +} + +/** + * Creates an Effect-native runtime over explicit, schema-described tools. + * + * Use `execute` for host-driven execution or `agentTool` to expose one confined code tool to an + * agent framework. Tool requirements remain in the returned Effect environment. + * + * @example + * ```ts + * const runtime = CodeMode.make({ tools: { orders: { lookup } } }) + * const code = runtime.agentTool() + * ``` + */ +export const make = = {}>(options: CodeModeOptions = {} as CodeModeOptions): CodeModeRuntime> => { + const tools = (options.tools ?? {}) as HostTools> + ToolRuntime.assertValidTools(tools) + const limits = resolveExecutionLimits(options.limits) + const discovery = ToolRuntime.discoveryPlan(tools, options.discovery?.maxInlineCatalogBytes) + const executeProgram = (code: string) => executeWithLimits({ ...options, code }, limits, discovery.searchIndex) + const catalog = discovery.catalog + const instructions = discovery.instructions + + return { + catalog: () => catalog, + instructions: () => instructions, + agentTool: () => ({ + name: "code", + description: instructions, + input: ExecuteInputSchema, + output: ExecuteResultSchema, + execute: ({ code }) => executeProgram(code), + }), + execute: executeProgram, + } +} + +/** Constructors for one-shot and reusable CodeMode execution. */ +export const CodeMode = { make, execute } diff --git a/packages/codemode/src/index.ts b/packages/codemode/src/index.ts new file mode 100644 index 000000000000..ce33d603ecf9 --- /dev/null +++ b/packages/codemode/src/index.ts @@ -0,0 +1,27 @@ +export { + ToolError, + CodeMode, + ExecuteInputSchema, + ExecuteResultSchema, + toolError, +} from "./codemode.js" +export { Tool } from "./tool.js" +export type { Definition as ToolDefinition, JsonSchema, ToolSchema } from "./tool.js" +export type { ToolCallEnded, ToolCallHooks } from "./tool-runtime.js" +export type { + AgentToolDefinition, + CodeModeOptions, + CodeModeRuntime, + DataValue, + Diagnostic, + DiagnosticKind, + DiscoveryOptions, + ExecuteFailure, + ExecuteOptions, + ExecuteResult, + ExecuteSuccess, + ExecutionLimits, + ToolCall, + ToolCallStarted, + ToolDescription, +} from "./codemode.js" diff --git a/packages/codemode/src/tool-error.ts b/packages/codemode/src/tool-error.ts new file mode 100644 index 000000000000..16460a019ae1 --- /dev/null +++ b/packages/codemode/src/tool-error.ts @@ -0,0 +1,11 @@ +import { Schema } from "effect" + +/** Safe operational refusal from a standard tool pack, reported as `ToolFailure`. */ +export class ToolError extends Schema.TaggedErrorClass()("ToolError", { + message: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), +}) {} + +/** Creates a tool refusal whose message is safe to include in an execution diagnostic. */ +export const toolError = (message: string, cause?: unknown): ToolError => + new ToolError({ message, ...(cause === undefined ? {} : { cause }) }) diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts new file mode 100644 index 000000000000..c693cc477b0a --- /dev/null +++ b/packages/codemode/src/tool-runtime.ts @@ -0,0 +1,687 @@ +import { Cause, Effect } from "effect" +import { ToolError, toolError } from "./tool-error.js" +import { + decodeInput as decodeToolInput, + decodeOutput as decodeToolOutput, + inputProperties, + inputTypeScript, + isDefinition as isToolDefinition, + outputTypeScript, + type Definition, +} from "./tool.js" +import { SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js" + +export type HostTool = (...args: Array) => Effect.Effect + +export type HostTools = { + [name: string]: HostTool | Definition | HostTools +} + +export type Services = Tools extends (...args: Array) => Effect.Effect + ? R + : Tools extends { readonly _tag: "CodeModeTool"; readonly run: (input: unknown) => Effect.Effect } + ? R + : Tools extends object + ? string extends keyof Tools ? never : Services + : never + +/** Minimal audit record retained for each admitted tool call. */ +export type ToolCall = { + readonly name: string +} + +/** Decoded tool call observed immediately before tool execution. */ +export type ToolCallStarted = { + readonly index: number + readonly name: string + readonly input: unknown +} + +/** Completed tool call observed immediately after tool execution settles. */ +export type ToolCallEnded = { + readonly index: number + readonly name: string + readonly input: unknown + readonly durationMs: number + readonly outcome: "success" | "failure" + /** Model-safe failure message; present only when `outcome` is `"failure"`. */ + readonly message?: string +} + +/** Non-throwing observation hooks fired around each admitted tool call. */ +export type ToolCallHooks = { + readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect) | undefined + readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect) | undefined +} + +/** Model-visible description of one schema-backed tool. */ +export type ToolDescription = { + readonly path: string + readonly description: string + readonly signature: string +} + +export type SafeObject = Record + +const reservedNamespace = "$codemode" +const defaultMaxInlineCatalogBytes = 16_000 +const defaultSearchLimit = 10 +const searchSignature = "tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>" + +export class ToolReference { + constructor(readonly path: ReadonlyArray) {} +} + +export type DataLimits = { + readonly maxValueDepth: number + readonly maxCollectionLength: number + readonly maxDataBytes: number + readonly maxAuditBytes: number +} + +export class ToolRuntimeError extends Error { + constructor( + readonly kind: "UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded" | "AuditLimitExceeded", + message: string, + readonly suggestions: ReadonlyArray = [], + ) { + super(message) + this.name = "ToolRuntimeError" + } +} + +const isDefinition = (value: HostTool | Definition | HostTools): value is Definition => + isToolDefinition(value) + +const runHost = (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt + const error = Cause.squash(cause) + return Effect.fail(error instanceof ToolError ? error : toolError("Tool execution failed", error)) + }), + ) + +const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"]) + +export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name) + +export const copyIn = (value: unknown, label: string, limits?: DataLimits, depth = 0, seen = new Set()): unknown => { + if (limits && depth > limits.maxValueDepth) { + throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${limits.maxValueDepth}.`) + } + if ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "boolean" || + // NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real + // engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are + // normalized to `null` when the value leaves the sandbox — see copyOut — exactly as + // JSON.stringify already does at any tool boundary. + typeof value === "number" + ) { + return value + } + + if (typeof value !== "object") { + throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`) + } + + // An un-awaited promise never crosses a data boundary as `{}`; the diagnostic tells the + // model exactly how to fix the program instead. + if (value instanceof SandboxPromise) { + throw new ToolRuntimeError( + "InvalidDataValue", + `${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`, + ) + } + + // Sandbox value types (and their host counterparts, which a host tool may legitimately + // return) serialize exactly as JSON.stringify would at every data checkpoint: a Date is its + // toJSON() ISO string (invalid -> null), and RegExp/Map/Set have no JSON form beyond {}. + if (value instanceof SandboxDate) { + return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null + } + if (value instanceof Date) { + return Number.isFinite(value.getTime()) ? value.toISOString() : null + } + if ( + value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet || + value instanceof RegExp || value instanceof Map || value instanceof Set + ) { + return Object.create(null) as SafeObject + } + + if (seen.has(value)) { + throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`) + } + + seen.add(value) + + if (Array.isArray(value)) { + if (limits && value.length > limits.maxCollectionLength) { + throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`) + } + const copied = value.map((item) => copyIn(item, label, limits, depth + 1, seen)) + seen.delete(value) + return copied + } + + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) { + throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`) + } + + const copied: SafeObject = Object.create(null) as SafeObject + const entries = Object.entries(value) + if (limits && entries.length > limits.maxCollectionLength) { + throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`) + } + for (const [key, item] of entries) { + if (isBlockedMember(key)) { + throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`) + } + copied[key] = copyIn(item, label, limits, depth + 1, seen) + } + seen.delete(value) + return copied +} + +export const copyOut = (value: unknown, undefinedAsNull = false): unknown => { + if (value === undefined && undefinedAsNull) return null + // Normalize non-finite numbers to null as the value crosses out of the sandbox (final return + // and tool-call arguments both funnel through here), matching JSON semantics — NaN/Infinity + // have no JSON representation, so JSON.stringify would produce null anyway. + if (typeof value === "number" && !Number.isFinite(value)) { + return null + } + if (Array.isArray(value)) { + return value.map((item) => copyOut(item, undefinedAsNull)) + } + + if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyOut(item, undefinedAsNull)])) + } + + return value +} + +const definitions = (tools: HostTools, path: ReadonlyArray = []): Array<{ path: string; definition: Definition }> => { + const entries: Array<{ path: string; definition: Definition }> = [] + for (const [name, value] of Object.entries(tools)) { + const next = [...path, name] + if (isDefinition(value)) entries.push({ path: next.join("."), definition: value }) + else if (typeof value !== "function") entries.push(...definitions(value, next)) + } + return entries +} + +const describeDefinition = (path: string, definition: Definition): ToolDescription => ({ + path, + description: definition.description, + signature: `tools.${path}(input: ${inputTypeScript(definition)}): Promise<${outputTypeScript(definition)}>`, + }) + +const visibleDefinitions = (tools: HostTools) => + definitions(tools).flatMap(({ path, definition }) => { + const description = describeDefinition(path, definition) + return [{ path, definition, description }] + }) + +export const catalog = (tools: HostTools): ReadonlyArray => + visibleDefinitions(tools).map(({ description }) => description) + +export type DiscoveryPlan = { + readonly catalog: ReadonlyArray + readonly instructions: string + readonly searchIndex: ReadonlyArray +} + +export type SearchEntry = { + readonly description: ToolDescription + /** Top-level namespace (first path segment), matched by the search `namespace` option. */ + readonly namespace: string + /** Lowercased path + description + input property names/descriptions, for substring matching. */ + readonly searchText: string +} + +const utf8ByteLength = (value: string) => new TextEncoder().encode(value).byteLength + +/** + * Split a query into lowercased search terms. camelCase boundaries are split + * (`resolveLibrary` -> `resolve library`) and every non-alphanumeric character is a + * separator, so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all + * tokenize alike. Empties and the `*` wildcard are dropped. + */ +const tokenize = (query: string): Array => + query + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((term) => term.length > 0 && term !== "*") + +const firstLine = (text: string) => text.split("\n", 1)[0]!.trim() + +/** One-line description used on inline catalog lines; the full text stays in search results. */ +const brief = (text: string, max = 120) => { + const line = firstLine(text) + return line.length > max ? line.slice(0, max - 1) + "…" : line +} + +const catalogLine = (tool: ToolDescription) => { + const description = brief(tool.description) + return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}` +} + +const toSearchEntry = (path: string, definition: Definition, description: ToolDescription): SearchEntry => ({ + description, + namespace: path.split(".", 1)[0]!, + searchText: [ + path, + definition.description, + ...inputProperties(definition).flatMap(({ name, description: property }) => + property === undefined ? [name] : [name, property]), + ].join("\n").toLowerCase(), +}) + +/** The runtime search index over every described tool. Search is always registered. */ +export const searchIndex = (tools: HostTools): ReadonlyArray => + visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description)) + +export const assertValidTools = (tools: HostTools): void => { + if (Object.hasOwn(tools, reservedNamespace)) { + throw new Error(`Tool namespace '${reservedNamespace}' is reserved for CodeMode discovery tools.`) + } +} + +/** + * Budgeted catalog: every namespace is always listed with its tool count; full call + * signatures are inlined cheapest-first within each namespace (namespaces processed + * alphabetically) until `maxInlineCatalogBytes` is used, and the section states exactly + * how comprehensive it is — overall (COMPLETE vs PARTIAL) and per namespace. + */ +export const discoveryPlan = ( + tools: HostTools, + maxInlineCatalogBytes = defaultMaxInlineCatalogBytes, +): DiscoveryPlan => { + if (!Number.isSafeInteger(maxInlineCatalogBytes) || maxInlineCatalogBytes < 0) { + throw new RangeError("discovery.maxInlineCatalogBytes must be a non-negative safe integer") + } + const visible = visibleDefinitions(tools) + const described = visible.map(({ description }) => description) + + const namespaces = new Map>() + for (const tool of described) { + const [namespace = tool.path] = tool.path.split(".") + const group = namespaces.get(namespace) ?? [] + group.push(tool) + namespaces.set(namespace, group) + } + const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right)) + + // Select which signatures fit the budget (cheapest first within each namespace, + // namespaces alphabetical) before emitting, so the list can state exactly how + // comprehensive it is. Once one line does not fit, inlining stops for every + // remaining namespace — later namespaces show counts only. + const shown = new Map>() + let used = 0 + let budgetLeft = true + let totalShown = 0 + for (const [namespace, group] of ordered) { + const picked = new Set() + if (budgetLeft) { + const cheapestFirst = [...group].sort( + (left, right) => utf8ByteLength(catalogLine(left)) - utf8ByteLength(catalogLine(right)) || left.path.localeCompare(right.path), + ) + for (const tool of cheapestFirst) { + const cost = utf8ByteLength(catalogLine(tool)) + 1 + if (used + cost > maxInlineCatalogBytes) { + budgetLeft = false + break + } + picked.add(tool) + used += cost + } + } + shown.set(namespace, picked) + totalShown += picked.size + } + const complete = totalShown === described.length + + const empty = described.length === 0 + + // Section order is deliberate: workflow first (the top is the least likely part of a long + // description to be truncated or skimmed away), then rules, then syntax, with the budgeted + // catalog at the bottom. Every call form uses explicit `.` placeholders — + // never a real or fabricated tool name. + const intro = [ + "Write a CodeMode program to answer the request. Return code only.", + empty + ? "Execute JavaScript in a confined runtime." + : "Execute JavaScript in a confined runtime with access to the tools listed below under `tools.*`.", + ] + + // The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE + // catalog already shows every signature, so step 1 picks from the list instead. + const workflow = empty + ? [] + : [ + "", + "## Workflow", + "", + ...(complete + ? [ + "1. Pick a tool from the list under `## Available tools` — each line is the exact call signature, followed by the tool's description.", + "2. Call it by path: `const res = await tools..(input)`", + '3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res`', + "4. Return only what you need: `return { : data. }`", + ] + : [ + '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "" })`', + "2. Read the matches: each item is `{ path, description, signature }` — the signature is the exact call form; read the description before using an unfamiliar tool.", + "3. Call it by path: `const res = await tools..(input)`", + '4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res`', + "5. Return only what you need: `return { : data. }`", + ]), + ] + + const rules = empty + ? [] + : [ + "", + "## Rules", + "", + complete + ? "- Call a tool by its path: `await tools..(input)`. The signatures listed below are exact — use them as-is rather than guessing segments." + : "- Call a tool by its path: `await tools..(input)`. The `path` in search results is exact — use it as-is rather than guessing segments.", + "- Most tools return TEXT that is actually JSON — if a result is a string, JSON.parse it before reading fields.", + "- Return small: extract only the fields you need. Do NOT return raw or large tool payloads — they get truncated and waste context.", + "- Filter, aggregate, and transform large collections in code instead of returning them or calling per-item tools one message at a time.", + "- Inspect intermediate values with console.log/warn/error/dir/table — logs come back with the result; `return` only the final, minimal answer.", + "- Run independent calls in parallel: `await Promise.all(items.map((item) => tools..(item)))` (allSettled/race/resolve/reject also work). No .then/.catch — use await with try/catch.", + "- `Object.keys(tools)` lists namespaces; `Object.keys(tools.)` lists its tools; `for...in` works on both.", + ...(complete ? [] : ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.']), + "- Files/images produced by tools never enter the program — they are attached to the final result automatically; a call that returns only media yields a small text marker instead, and your returned value plus logs is what the model reads.", + ] + + const syntax = [ + "", + "## Syntax", + "", + "Common syntax: arrow functions and `function` declarations (hoisted) with closures, default/rest parameters, destructuring (incl. rest/defaults), optional chaining, template literals, conditionals, switch, loops (for...of over arrays/strings/Maps/Sets, for...in over object keys), spread (arrays/objects/strings/Maps/Sets), try/catch, ternary, the `in` operator, logical assignment (??=/||=/&&=), and bitwise operators (& | ^ ~ << >> >>>). Signal failure with `throw` (any value) or `throw new Error(message)`.", + "Transform data with array methods (map/filter/reduce/reduceRight/flatMap/forEach/find/findIndex/findLast/findLastIndex/sort/toSorted/slice/concat/indexOf/at/flat/reverse/toReversed/with/includes/join, plus push/pop/shift/unshift for accumulation), string methods (toLowerCase/toUpperCase/trim/split/slice/substring/replace/replaceAll/includes/startsWith/endsWith/indexOf/padStart/padEnd/repeat/charCodeAt/match/matchAll/search), number methods (toFixed/toString(radix)/toPrecision), Object.keys/values/entries/fromEntries/hasOwn, Math.* (incl. PI/E), JSON.parse/stringify, Array.from/isArray/of, Number.isInteger/isNaN/parseInt, String.fromCharCode, parseInt/parseFloat, and Number/String/Boolean.", + "Also available: Date (Date.now(), new Date(...), getTime/toISOString/getFullYear/..., date arithmetic and comparison), regular expressions (/literals/ and new RegExp(...), with test/exec and string match/matchAll/replace/replaceAll/split/search), and Map/Set (new Map()/new Set(), get/set/add/has/delete/size/forEach; keys/values/entries return arrays). Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to {} like JSON.stringify.", + ] + + const toolSection: Array = [""] + if (empty) { + toolSection.push("## Available tools", "", "No tools are currently available.") + } else { + toolSection.push( + complete + ? "## Available tools (COMPLETE list — every tool is shown below with its full call signature)" + : `## Available tools (PARTIAL — ${totalShown} of ${described.length} shown; find the rest with tools.$codemode.search)`, + "", + ) + for (const [namespace, group] of ordered) { + const picked = shown.get(namespace)! + const count = `${group.length} tool${group.length === 1 ? "" : "s"}` + // Annotate only when a namespace is not fully shown, so a comprehensive + // namespace reads cleanly and a truncated one is unambiguous. + const label = picked.size === group.length ? count : picked.size === 0 ? `${count}, none shown` : `${count}, ${picked.size} shown` + toolSection.push(`- ${namespace} (${label})`) + for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool)) + } + if (!complete) { + toolSection.push( + "", + "Search returns complete callable signatures:", + `- ${searchSignature}`, + ) + } + } + + const lines = [ + ...intro, + ...workflow, + ...rules, + ...syntax, + ...toolSection, + ] + return { + catalog: described, + instructions: lines.join("\n"), + searchIndex: visible.map(({ path, definition, description }) => toSearchEntry(path, definition, description)), + } +} + +/** + * The enumerable names at one node of the host tool tree — namespace names at the root, + * tool/namespace names below — powering `Object.keys(tools)` and `for...in` over tool + * references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a + * function in JS). An unknown path is an `UnknownTool` error pointing at the working + * discovery idioms, mirroring how calling an unknown tool fails. + */ +const namespaceKeys = (tools: HostTools, path: ReadonlyArray, searchEnabled: boolean): ReadonlyArray => { + // The reserved discovery namespace is virtual (never present in the host tree); enumerate + // it explicitly so `Object.keys(tools.$codemode)` matches the callable surface. + if (searchEnabled && path.length === 1 && path[0] === reservedNamespace) return ["search"] + let value: HostTool | Definition | HostTools = tools + for (const segment of path) { + if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) { + throw new ToolRuntimeError( + "UnknownTool", + `Unknown tool namespace '${path.join(".")}'.`, + searchEnabled + ? ["Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools."] + : ["Object.keys(tools) lists the available namespaces."], + ) + } + value = value[segment] as HostTool | Definition | HostTools + } + if (typeof value === "function" || isDefinition(value)) return [] + return Object.keys(value) +} + +const resolve = (tools: HostTools, path: ReadonlyArray, searchEnabled: boolean): HostTool | Definition => { + let value: HostTool | Definition | HostTools = tools + + for (const segment of path) { + if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) { + throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, searchEnabled ? ["Use tools.$codemode.search({ query }) to find available described tools."] : []) + } + value = value[segment] as HostTool | Definition | HostTools + } + + if (typeof value !== "function" && !isDefinition(value)) { + throw new ToolRuntimeError("UnknownTool", `Tool '${path.join(".")}' is not callable.`) + } + + return value +} + +export type ToolRuntime = { + readonly root: ToolReference + readonly calls: Array + readonly invoke: (path: ReadonlyArray, args: Array) => Effect.Effect + /** Enumerable namespace/tool names at one node of the host tool tree; see `namespaceKeys`. */ + readonly keys: (path: ReadonlyArray) => ReadonlyArray +} + +export const dataByteLength = (value: unknown): number => + new TextEncoder().encode(JSON.stringify(value) ?? "").byteLength + +const failureMessage = (error: unknown): string => + error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed" + +export const make = ( + tools: HostTools, + maxToolCalls: number, + dataLimits: DataLimits, + hooks?: ToolCallHooks, + searchIndex?: ReadonlyArray, +): ToolRuntime => { + const calls: Array = [] + let auditBytes = 0 + const searchEnabled = searchIndex !== undefined + + // Wraps the settling portion of a tool call so onToolCallEnd observes success and failure + // symmetrically. Interruption (e.g. the execution timeout) fires neither outcome. + const observeEnd = (effect: Effect.Effect, call: ToolCallStarted): Effect.Effect => { + const onEnd = hooks?.onToolCallEnd + if (onEnd === undefined) return effect + const startedAt = Date.now() + return effect.pipe( + Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })), + Effect.tapError((error) => + onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "failure", message: failureMessage(error) })), + ) + } + + const checkedCopyIn = (value: unknown, label: string): unknown => { + const copied = copyIn(value, label, dataLimits) + if (dataByteLength(copied) > dataLimits.maxDataBytes) { + throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds ${dataLimits.maxDataBytes} bytes.`) + } + return copied + } + + const decodeOutput = (value: unknown, name: string) => + Effect.try({ + try: () => checkedCopyIn(value, `Result from tool '${name}'`), + catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`), + }) + + const recordCall = (call: ToolCall): void => { + if (calls.length >= maxToolCalls) { + throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`) + } + const auditEntryBytes = dataByteLength(call) + if (auditBytes + auditEntryBytes > dataLimits.maxAuditBytes) { + throw new ToolRuntimeError("AuditLimitExceeded", `Execution exceeds its audit-trail limit of ${dataLimits.maxAuditBytes} bytes.`) + } + auditBytes += auditEntryBytes + calls.push(call) + } + + return { + root: new ToolReference([]), + calls, + keys: (path) => namespaceKeys(tools, path, searchEnabled), + invoke: (path, args) => + Effect.gen(function*() { + const name = path.join(".") + const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`, dataLimits))) + const argumentBytes = dataByteLength(externalArgs) + if (argumentBytes > dataLimits.maxDataBytes) { + throw new ToolRuntimeError("InvalidDataValue", `Arguments for tool '${name}' exceed ${dataLimits.maxDataBytes} bytes.`) + } + const call = { name } + const recordAndObserve = (input: unknown) => + Effect.sync(() => { + recordCall(call) + return calls.length - 1 + }).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void)) + if (name === "$codemode.search") { + if (!searchEnabled) throw new ToolRuntimeError("UnknownTool", `Unknown tool '${name}'.`) + const input = externalArgs[0] + if (externalArgs.length !== 1 || input === null || typeof input !== "object" || Array.isArray(input)) { + throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search expects { query?: string; namespace?: string; limit?: number }.") + } + const request = input as { query?: unknown; namespace?: unknown; limit?: unknown } + if (request.query !== undefined && typeof request.query !== "string") { + throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search query must be a string when provided.") + } + if (request.namespace !== undefined && typeof request.namespace !== "string") { + throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search namespace must be a string when provided.") + } + if (request.limit !== undefined && (typeof request.limit !== "number" || !Number.isSafeInteger(request.limit) || request.limit <= 0)) { + throw new ToolRuntimeError("InvalidToolInput", "tools.$codemode.search limit must be a positive safe integer when provided.") + } + const query = typeof request.query === "string" ? request.query : "" + const namespace = typeof request.namespace === "string" ? request.namespace : undefined + const index = yield* recordAndObserve(request) + return yield* observeEnd( + Effect.try({ + try: () => { + const limit = typeof request.limit === "number" ? request.limit : defaultSearchLimit + const scoped = namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace) + // A query that names one tool path exactly (optionally `tools.`-prefixed) is a + // lookup, not a search: return that tool alone. + 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) + const terms = tokenize(query) + // Additive field-weighted scoring, summed across terms: exact path or path + // segment (20) > path substring (8) > description substring (4) > any + // searchable text, incl. input parameter names/descriptions (2). An empty + // query browses everything, alphabetical by path. + const ranked = exact !== undefined + ? [exact] + : scoped + .map((entry) => { + const path = entry.description.path.toLowerCase() + const description = entry.description.description.toLowerCase() + const score = terms.reduce( + (total, term) => + total + + (path === term || path.endsWith(`.${term}`) ? 20 : 0) + + (path.includes(term) ? 8 : 0) + + (description.includes(term) ? 4 : 0) + + (entry.searchText.includes(term) ? 2 : 0), + 0, + ) + return { entry, score } + }) + .filter(({ score }) => terms.length === 0 || score > 0) + .sort((left, right) => + right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path)) + .map(({ entry }) => entry) + // Result paths carry the `tools.` prefix so each `path` is directly usable + // as the call site (`await tools.github.list({ ... })`). + const items = ranked.slice(0, limit).map(({ description }) => ({ ...description, path: `tools.${description.path}` })) + return checkedCopyIn({ items, total: ranked.length }, "Result from tool '$codemode.search'") + }, + catch: (cause) => cause, + }), + { index, name, input: request }, + ) + } + + const tool = resolve(tools, path, searchEnabled) + let describedInput: unknown + if (isDefinition(tool)) { + if (externalArgs.length !== 1) throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`) + describedInput = yield* Effect.try({ + try: () => decodeToolInput(tool, externalArgs[0]), + catch: (cause) => new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`), + }) + } + const input = isDefinition(tool) ? describedInput : externalArgs + const index = yield* recordAndObserve(input) + const currentCall = { index, name, input } + if (isDefinition(tool)) { + return yield* observeEnd( + Effect.gen(function*() { + const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput))) + const result = yield* Effect.try({ + try: () => decodeToolOutput(tool, raw), + catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`), + }) + return yield* decodeOutput(result, name) + }), + currentCall, + ) + } + return yield* observeEnd( + Effect.gen(function*() { + return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name) + }), + currentCall, + ) + }), + } +} + +export * as ToolRuntime from "./tool-runtime.js" diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts new file mode 100644 index 000000000000..a47a1a242134 --- /dev/null +++ b/packages/codemode/src/tool.ts @@ -0,0 +1,223 @@ +import { Effect, Schema } from "effect" + +/** + * JSON Schema subset accepted for render-only tool schemas. + * + * A JSON-Schema-described side of a tool is used to generate the model-visible TypeScript + * signature only — CodeMode performs no validation against it. This is the natural shape for + * adapter-provided tools (e.g. MCP definitions) whose schemas arrive as JSON Schema documents. + */ +export type JsonSchema = { + readonly type?: string | ReadonlyArray + readonly enum?: ReadonlyArray + readonly const?: unknown + readonly anyOf?: ReadonlyArray + readonly oneOf?: ReadonlyArray + readonly properties?: Readonly> + readonly required?: ReadonlyArray + readonly items?: JsonSchema + readonly additionalProperties?: boolean | JsonSchema + readonly description?: string + readonly $ref?: string + readonly $defs?: Readonly> + readonly definitions?: Readonly> +} + +/** Either a validating Effect Schema or a render-only JSON Schema document. */ +export type ToolSchema = Schema.Decoder | JsonSchema + +/** Schema-backed tool definition consumed by a CodeMode tool tree. */ +export type Definition = { + readonly _tag: "CodeModeTool" + readonly description: string + readonly input: ToolSchema + readonly output: ToolSchema | undefined + readonly run: (input: unknown) => Effect.Effect +} + +/** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */ +export type InputType = S extends Schema.Decoder ? S["Type"] : unknown + +/** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */ +export type ResultType = S extends Schema.Decoder ? S["Encoded"] : unknown + +/** Options for defining one CodeMode tool. */ +export type Options = { + readonly description: string + readonly input: I + readonly output?: O + readonly run: (input: InputType) => Effect.Effect, unknown, R> +} + +export const isDefinition = (value: unknown): value is Definition => + typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool" + +const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder & Schema.Top => + Schema.isSchema(schema) + +const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown" + +const renderSchema = (schema: JsonSchema, definitions: Readonly>): string => { + if (schema.$ref) { + const name = schema.$ref.split("/").pop() + return name && definitions[name] ? renderSchema(definitions[name], definitions) : name ?? "unknown" + } + if (schema.const !== undefined) return renderLiteral(schema.const) + if (schema.enum) return schema.enum.map(renderLiteral).join(" | ") + const alternatives = schema.anyOf ?? schema.oneOf + if (alternatives) { + if (alternatives.some((item) => item.type === "number")) return "number" + // An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]` + // (no properties/items); render the bare shape as {} instead of `{} | Array`. + if ( + alternatives.length === 2 && + alternatives[0]?.type === "object" && alternatives[0].properties === undefined && + alternatives[1]?.type === "array" && alternatives[1].items === undefined + ) { + return "{}" + } + return alternatives.map((item) => renderSchema(item, definitions)).join(" | ") + } + if (Array.isArray(schema.type)) return schema.type.map((item) => renderSchema({ type: item }, definitions)).join(" | ") + if (schema.type === "string") return "string" + if (schema.type === "number" || schema.type === "integer") return "number" + if (schema.type === "boolean") return "boolean" + if (schema.type === "null") return "null" + if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, definitions)}>` + if (schema.type === "object" || schema.properties) { + const required = new Set(schema.required ?? []) + const fields = Object.entries(schema.properties ?? {}).map(([name, value]) => + `${name}${required.has(name) ? "" : "?"}: ${renderSchema(value, definitions)}`) + if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + fields.push(`[key: string]: ${renderSchema(schema.additionalProperties, definitions)}`) + } + return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }` + } + return "unknown" +} + +export const toTypeScript = (schema: Schema.Top, decoded = false): string => { + const visible = decoded ? Schema.toType(schema) : schema + const document = Schema.toJsonSchemaDocument(visible) as { + readonly schema: JsonSchema + readonly definitions?: Readonly> + } + return renderSchema(document.schema, document.definitions ?? {}) +} + +/** Renders a raw JSON Schema document as a TypeScript type string. */ +export const jsonSchemaToTypeScript = (schema: JsonSchema): string => + renderSchema(schema, { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }) + +/** One input property of a tool, extracted best-effort from its input schema. */ +export type InputProperty = { + readonly name: string + readonly description: string | undefined + readonly required: boolean +} + +/** + * The property names, descriptions, and required flags of a tool's input schema — the raw + * material for search text. Best-effort: Effect Schemas go through their + * JSON Schema document (the same emission signature rendering uses); JSON Schemas are read + * directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present. + * Anything unresolvable yields `[]` (search falls back to path + description). + */ +export const inputProperties = (definition: Definition): Array => { + try { + const document = isEffectSchema(definition.input) + ? (Schema.toJsonSchemaDocument(definition.input) as { + readonly schema: JsonSchema + readonly definitions?: Readonly> + }) + : { + schema: definition.input, + definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) }, + } + const definitions = document.definitions ?? {} + let schema = document.schema + if (schema.$ref !== undefined) { + const name = schema.$ref.split("/").pop() + const resolved = name === undefined ? undefined : definitions[name] + if (resolved === undefined) return [] + schema = resolved + } + const required = new Set(schema.required ?? []) + return Object.entries(schema.properties ?? {}).map(([name, value]) => ({ + name, + description: typeof value.description === "string" ? value.description : undefined, + required: required.has(name), + })) + } catch { + return [] + } +} + +/** The model-visible TypeScript type of a tool's input. */ +export const inputTypeScript = (definition: Definition): string => + isEffectSchema(definition.input) ? toTypeScript(definition.input) : jsonSchemaToTypeScript(definition.input) + +/** The model-visible TypeScript type of a tool's result; tools without an output schema return `unknown`. */ +export const outputTypeScript = (definition: Definition): string => + definition.output === undefined + ? "unknown" + : isEffectSchema(definition.output) + ? toTypeScript(definition.output, true) + : jsonSchemaToTypeScript(definition.output) + +/** + * Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure); + * JSON-Schema-described inputs pass through unvalidated (render-only). + */ +export const decodeInput = (definition: Definition, value: unknown): unknown => + isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value + +/** + * Decodes a tool result before it is exposed to the program. Effect Schemas validate and + * transform (throwing on failure); JSON Schema outputs and tools without an output schema pass + * the host value through unchanged. + */ +export const decodeOutput = (definition: Definition, value: unknown): unknown => + definition.output !== undefined && isEffectSchema(definition.output) + ? Schema.decodeUnknownSync(definition.output)(value) + : value + +/** + * Defines one schema-described tool available to a CodeMode program through `tools.*`. + * + * `input` and `output` each accept a validating Effect Schema or a render-only JSON Schema + * document. Effect Schema input is decoded before `run` is invoked, and `run` returns the + * encoded representation of an Effect Schema `output`, which CodeMode decodes before returning + * it to the program. JSON Schemas only shape the model-visible signature; values pass through + * unvalidated. `output` is optional — without it the signature advertises `unknown` and the + * host result is exposed as-is. The host tool remains responsible for authorization and + * durable side-effect handling. + * + * @example + * ```ts + * const lookup = Tool.make({ + * description: "Look up an order", + * input: Schema.Struct({ id: Schema.String }), + * output: Schema.Struct({ status: Schema.String }), + * run: ({ id }) => Effect.succeed({ status: "open" }), + * }) + * + * const fromJsonSchema = Tool.make({ + * description: "Call an adapter-described tool", + * input: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + * run: (input) => callHost(input), + * }) + * ``` + */ +export const make = ( + options: Options, +): Definition => ({ + _tag: "CodeModeTool", + description: options.description, + input: options.input, + output: options.output, + run: (input) => options.run(input as InputType), +}) + +/** Constructors for schema-backed tools exposed inside CodeMode programs. */ +export const Tool = { make, isDefinition } diff --git a/packages/codemode/src/values.ts b/packages/codemode/src/values.ts new file mode 100644 index 000000000000..74960e0a3762 --- /dev/null +++ b/packages/codemode/src/values.ts @@ -0,0 +1,54 @@ +// Sandbox value types backed by host primitives. They live in their own module so both the +// interpreter (codemode.ts) and the data boundary (tool-runtime.ts) can reference them without +// a circular import. All four are opaque runtime values inside a program; when a value crosses +// the sandbox boundary (final result, tool arguments, JSON.stringify) they serialize exactly as +// JSON.stringify would: Date -> ISO string (invalid -> null), RegExp/Map/Set -> {}. + +import type { Effect, Fiber } from "effect" + +/** + * A first-class promise value produced by an un-awaited tool call (or by + * `Promise.resolve`/`Promise.reject`). Tool-call promises are eager: the call runs on a fiber + * forked at call time, and `await` observes that fiber's settlement. Promises are opaque + * runtime references — `typeof` reports `"object"` (as in real JS), operators reject them, and + * they cannot cross a data boundary un-awaited (the boundary raises an await-hinting + * diagnostic instead of serializing `{}`). + */ +export class SandboxPromise { + /** Set when Promise.race interrupts this promise's in-flight call after another entry wins. */ + interrupted = false + constructor( + /** Backing fiber for an eagerly started tool call; undefined for resolve/reject promises. */ + readonly fiber: Fiber.Fiber | undefined, + /** Immediate settlement for fiberless promises (Promise.resolve / Promise.reject). */ + readonly immediate?: Effect.Effect, + ) {} +} + +/** An immutable instant, backed by an epoch-milliseconds time value (NaN = Invalid Date). */ +export class SandboxDate { + constructor(readonly time: number) {} +} + +/** A regular expression backed by the host engine; `lastIndex` state lives on the host regex. */ +export class SandboxRegExp { + readonly regex: RegExp + constructor(pattern: string, flags: string) { + this.regex = new RegExp(pattern, flags) + } +} + +/** A keyed collection with SameValueZero keys; `bytes` caches its incremental size accounting. */ +export class SandboxMap { + readonly map = new Map() + bytes = 2 +} + +/** A unique-value collection; `bytes` caches its incremental size accounting. */ +export class SandboxSet { + readonly set = new Set() + bytes = 2 +} + +export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet => + value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts new file mode 100644 index 000000000000..92c64b7ff7a3 --- /dev/null +++ b/packages/codemode/test/codemode.test.ts @@ -0,0 +1,794 @@ +import { describe, expect, test } from "bun:test" +import { Cause, Effect, Schema } from "effect" +import { CodeMode, ExecuteInputSchema, ExecuteResultSchema, Tool, toolError } from "../src/index.js" +import type { InternalExecutionLimits } from "../src/codemode.js" +import type { Definition } from "../src/tool.js" + +const run = (tool: Definition) => + Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})")) + +class UnsafeHostError extends Schema.TaggedErrorClass()("UnsafeHostError", { + reason: Schema.String, +}) {} + +describe("CodeMode host failure boundary", () => { + test("preserves explicit safe tool failures", async () => { + const result = await run( + Tool.make({ + description: "Fail safely", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.fail(toolError("Authorized request was refused")), + }), + ) + + expect(result.ok ? undefined : result.error).toStrictEqual({ + kind: "ToolFailure", + message: "Authorized request was refused", + }) + }) + + test("sanitizes unknown host failures and defects", async () => { + for (const failure of [ + Effect.fail(new UnsafeHostError({ reason: "Authorization: Bearer typed-secret" })), + Effect.die(new Error("postgres://user:defect-secret@example.invalid")), + ]) { + const result = await run( + Tool.make({ + description: "Fail internally", + input: Schema.Struct({}), + output: Schema.String, + run: () => failure, + }), + ) + + expect(result.ok ? undefined : result.error).toStrictEqual({ + kind: "ToolFailure", + message: "Tool execution failed", + }) + expect(JSON.stringify(result)).not.toMatch(/typed-secret|defect-secret|Authorization: Bearer/) + } + }) + + test("sanitizes invalid host output", async () => { + const secret = "invalid-output-secret" + const result = await run( + Tool.make({ + description: "Return invalid output", + input: Schema.Struct({}), + output: Schema.Struct({ safe: Schema.String }), + run: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }), + }), + ) + + expect(result.ok ? undefined : result.error).toStrictEqual({ + kind: "InvalidToolOutput", + message: "Invalid output from tool 'host.call'.", + }) + expect(JSON.stringify(result)).not.toMatch(/invalid-output-secret/) + }) + + test("sanitizes host output that throws while being copied", async () => { + const result = await run( + Tool.make({ + description: "Return hostile output", + input: Schema.Struct({}), + output: Schema.Unknown, + run: () => + Effect.succeed( + new Proxy({}, { + ownKeys: () => { + throw new Error("host-output-secret") + }, + }), + ), + }), + ) + + expect(result.ok ? undefined : result.error).toStrictEqual({ + kind: "InvalidToolOutput", + message: "Invalid output from tool 'host.call'.", + }) + expect(JSON.stringify(result)).not.toMatch(/host-output-secret/) + }) + + test("propagates host interruption instead of returning a diagnostic", async () => { + const exit = await Effect.runPromiseExit( + CodeMode.make({ + tools: { + host: { + call: Tool.make({ + description: "Interrupt", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.interrupt, + }), + }, + }, + }).execute("return await tools.host.call({})"), + ) + + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") { + expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true) + } + }) +}) + +describe("CodeMode tool-call observation", () => { + test("reports the tools actually invoked with decoded input", async () => { + const calls: Array = [] + const lookup = Tool.make({ + description: "Look up a value", + input: Schema.Struct({ query: Schema.String }), + output: Schema.String, + run: ({ query }) => Effect.succeed(query), + }) + + const result = await Effect.runPromise( + CodeMode.make({ + tools: { context: { lookup } }, + onToolCallStart: (call) => Effect.sync(() => calls.push(call)), + }).execute(` + if (false) await tools.context.lookup({ query: "not called" }) + return await tools.context.lookup({ query: "deployment failure" }) + `), + ) + + expect(result.ok).toBe(true) + expect(calls).toStrictEqual([ + { index: 0, name: "context.lookup", input: { query: "deployment failure" } }, + ]) + }) + + test("observes settled calls with outcome and duration", async () => { + const events: Array<{ phase: string; index: number; name: string; outcome?: string; message?: string }> = [] + const lookup = Tool.make({ + description: "Look up a value", + input: Schema.Struct({ query: Schema.String }), + output: Schema.String, + run: ({ query }) => + query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query), + }) + + const runtime = CodeMode.make({ + tools: { context: { lookup } }, + onToolCallStart: (call) => Effect.sync(() => { + events.push({ phase: "start", index: call.index, name: call.name }) + }), + onToolCallEnd: (call) => Effect.sync(() => { + expect(call.durationMs).toBeGreaterThanOrEqual(0) + events.push({ + phase: "end", + index: call.index, + name: call.name, + outcome: call.outcome, + ...(call.message === undefined ? {} : { message: call.message }), + }) + }), + }) + + const success = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "ok" })`)) + expect(success.ok).toBe(true) + const failure = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "boom" })`)) + expect(failure.ok).toBe(false) + + expect(events).toStrictEqual([ + { phase: "start", index: 0, name: "context.lookup" }, + { phase: "end", index: 0, name: "context.lookup", outcome: "success" }, + { phase: "start", index: 0, name: "context.lookup" }, + { phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" }, + ]) + }) +}) + +describe("CodeMode console capture", () => { + test("captures console output as bounded result logs", async () => { + const result = await Effect.runPromise(CodeMode.execute({ + code: ` + const returned = console.log("Thread info:", { name: "Demo", count: 2 }) + console.warn("careful") + return returned + `, + })) + + expect(result).toStrictEqual({ + ok: true, + value: null, + logs: ['Thread info: {"name":"Demo","count":2}', "[warn] careful"], + toolCalls: [], + }) + expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) + }) + + test("keeps logs captured before failures", async () => { + const result = await Effect.runPromise(CodeMode.execute({ + code: ` + console.log("before failure") + throw new Error("boom") + `, + })) + + expect(result.ok ? undefined : result.logs).toStrictEqual(["before failure"]) + expect(result.ok ? undefined : result.error.message).toBe("Uncaught: boom") + }) + + test("captures console.dir and console.table output", async () => { + const result = await Effect.runPromise(CodeMode.execute({ + code: ` + console.dir({ nested: { ok: true } }) + console.table([ + { name: "Kit", count: 1, hidden: "x" }, + { name: "Olive", count: 2, hidden: "y" } + ], ["name", "count"]) + return "done" + `, + })) + + expect(result).toStrictEqual({ + ok: true, + value: "done", + logs: [ + '{"nested":{"ok":true}}', + "(index)\tname\tcount\n0\tKit\t1\n1\tOlive\t2", + ], + toolCalls: [], + }) + }) +}) + +describe("CodeMode output budget", () => { + test("truncates an oversized result value with a marker instead of failing", async () => { + const limits: InternalExecutionLimits = { maxOutputBytes: 40 } + const result = await Effect.runPromise(CodeMode.execute({ + code: `return { data: "${"x".repeat(200)}" }`, + limits, + })) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.truncated).toBe(true) + expect(typeof result.value).toBe("string") + expect(result.value).toMatch(/^\{"data":"x+ \[result truncated: \d+ bytes exceeds the 40-byte output limit; return a smaller value\]$/) + expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) + }) + + test("keeps leading logs within the remaining budget and marks the cut", async () => { + const limits: InternalExecutionLimits = { maxOutputBytes: 40 } + const result = await Effect.runPromise(CodeMode.execute({ + code: ` + console.log("first line") + console.log("${"y".repeat(200)}") + return "ok" + `, + limits, + })) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("ok") + expect(result.truncated).toBe(true) + expect(result.logs).toStrictEqual(["first line", "[logs truncated: showing 1 of 2 lines]"]) + }) + + test("does not mark results within the budget", async () => { + const result = await Effect.runPromise(CodeMode.execute({ + code: ` + console.log("fits") + return { fits: true } + `, + })) + expect(result).toStrictEqual({ + ok: true, + value: { fits: true }, + logs: ["fits"], + toolCalls: [], + }) + }) +}) + +describe("CodeMode schema flexibility", () => { + test("accepts render-only JSON Schema input and omitted output", async () => { + const observed: Array = [] + const call = Tool.make({ + description: "Call an adapter-described tool", + input: { + type: "object", + properties: { id: { type: "string" }, count: { type: "number" } }, + required: ["id"], + }, + run: (input) => Effect.sync(() => { + observed.push(input) + return { echoed: input } + }), + }) + const runtime = CodeMode.make({ tools: { adapter: { call } } }) + + expect(runtime.catalog()).toStrictEqual([{ + path: "adapter.call", + description: "Call an adapter-described tool", + signature: "tools.adapter.call(input: { id: string; count?: number }): Promise", + }]) + + // JSON Schema is render-only: mistyped input passes through unvalidated. + const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`)) + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toStrictEqual({ echoed: { id: 42 } }) + expect(observed).toStrictEqual([{ id: 42 }]) + }) + + test("renders JSON Schema outputs and $defs references", async () => { + const lookup = Tool.make({ + description: "Look up a user", + input: { type: "object", properties: { login: { type: "string" } }, required: ["login"] }, + output: { + $ref: "#/$defs/User", + $defs: { User: { type: "object", properties: { login: { type: "string" }, id: { type: "number" } }, required: ["login", "id"] } }, + }, + run: () => Effect.succeed({ login: "kit", id: 7 }), + }) + const runtime = CodeMode.make({ tools: { users: { lookup } } }) + + expect(runtime.catalog()).toStrictEqual([{ + path: "users.lookup", + description: "Look up a user", + signature: "tools.users.lookup(input: { login: string }): Promise<{ login: string; id: number }>", + }]) + + const result = await Effect.runPromise(runtime.execute(`return await tools.users.lookup({ login: "kit" })`)) + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toStrictEqual({ login: "kit", id: 7 }) + }) + + test("Effect Schema output without an input transform still renders unknown when omitted", async () => { + const ping = Tool.make({ + description: "Ping", + input: Schema.Struct({ host: Schema.String }), + run: () => Effect.succeed("pong"), + }) + const runtime = CodeMode.make({ tools: { net: { ping } } }) + expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: { host: string }): Promise") + + const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`)) + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toBe("pong") + }) +}) + +describe("CodeMode public contract", () => { + const lookup = Tool.make({ + description: "Look up an order by ID", + input: Schema.Struct({ id: Schema.String }), + output: Schema.Struct({ id: Schema.String, status: Schema.String }), + run: ({ id }) => Effect.succeed({ id, status: "open" }), + }) + const tools = { orders: { lookup } } + const source = `return await tools.orders.lookup({ id: "order_42" })` + + test("keeps one-shot, reusable, and agent-tool execution equivalent", async () => { + const runtime = CodeMode.make({ tools }) + const agentTool = runtime.agentTool() + const [oneShot, reusable, projected] = await Promise.all([ + Effect.runPromise(CodeMode.execute({ tools, code: source })), + Effect.runPromise(runtime.execute(source)), + Effect.runPromise(agentTool.execute({ code: source })), + ]) + + expect(reusable).toStrictEqual(oneShot) + expect(projected).toStrictEqual(oneShot) + expect(agentTool.name).toBe("code") + expect(agentTool.input).toBe(ExecuteInputSchema) + expect(agentTool.output).toBe(ExecuteResultSchema) + expect(agentTool.description).toBe(runtime.instructions()) + expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(projected)))).toStrictEqual(projected) + }) + + test("inlines a COMPLETE small catalog and keeps search registered but unadvertised", async () => { + const runtime = CodeMode.make({ tools }) + expect(runtime.catalog()).toStrictEqual([{ + path: "orders.lookup", + description: "Look up an order by ID", + signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>", + }]) + expect(runtime.instructions()).toContain("Available tools (COMPLETE list") + expect(runtime.instructions()).toContain("- orders (1 tool)") + expect(runtime.instructions()).toContain( + " - tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }> // Look up an order by ID", + ) + // A fully inlined catalog does not advertise search in the instructions... + expect(runtime.instructions()).not.toMatch(/\$codemode/) + + // ...but the search tool stays registered, so a speculative call still works. + const result = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "order" })`)) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value).toStrictEqual({ + items: [{ + path: "tools.orders.lookup", + description: "Look up an order by ID", + signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>", + }], + total: 1, + }) + } + }) + + test("instructions use markdown sections with placeholder-only call forms", () => { + const runtime = CodeMode.make({ tools }) + const instructions = runtime.instructions() + // Sections in order: workflow at the top, catalog at the bottom. + expect(instructions).toContain("## Workflow") + expect(instructions).toContain("## Rules") + expect(instructions).toContain("## Syntax") + expect(instructions.indexOf("## Workflow")).toBeLessThan(instructions.indexOf("## Rules")) + expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Syntax")) + expect(instructions.indexOf("## Syntax")).toBeLessThan(instructions.indexOf("\n## Available tools (COMPLETE list")) + // Rules carry the result-shape guidance. + expect(instructions).toContain("if a result is a string, JSON.parse it before reading fields") + expect(instructions).toContain("Return small: extract only the fields you need") + expect(instructions).toContain("Call a tool by its path: `await tools..(input)`.") + // Placeholders use the ./ style ONLY — no fabricated tool + // names, and no real catalog tools cherry-picked into example lines. + expect(instructions).toContain("`return { : data. }`") + expect(instructions).not.toContain("total_count") + expect(instructions).not.toContain("list_issues") + expect(instructions).not.toContain("tools.orders.lookup({") + // COMPLETE: step 1 picks from the inlined list; search is not advertised. + expect(instructions).toContain("1. Pick a tool from the list under `## Available tools`") + expect(instructions).not.toContain("Browse one namespace") + + const partial = CodeMode.make({ tools, discovery: { maxInlineCatalogBytes: 0 } }).instructions() + // PARTIAL: the workflow starts with search and the browse-namespace rule appears. + expect(partial).toContain( + '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "" })`', + ) + expect(partial).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.') + expect(partial).not.toContain("total_count") + expect(partial).not.toContain("tools.orders.lookup({") + }) + + test("zero tools keep minimal sections and the no-tools notice", () => { + const runtime = CodeMode.make({}) + const instructions = runtime.instructions() + expect(instructions).toContain("No tools are currently available.") + expect(instructions).toContain("## Syntax") + expect(instructions).toContain("## Available tools") + expect(instructions).not.toContain("## Workflow") + expect(instructions).not.toContain("## Rules") + expect(instructions).not.toMatch(/\$codemode/) + }) + + test("uses one ranked search returning complete definitions for large catalogs", async () => { + const upload = Tool.make({ + description: "Upload one readable local file to the current Discord thread", + input: Schema.Struct({ path: Schema.String }), + output: Schema.Struct({ sent: Schema.Boolean }), + run: () => Effect.succeed({ sent: true }), + }) + const generate = Tool.make({ + description: "Generate an image and upload it to the current Discord thread", + input: Schema.Struct({ prompt: Schema.String }), + output: Schema.Struct({ sent: Schema.Boolean }), + run: () => Effect.succeed({ sent: true }), + }) + const runtime = CodeMode.make({ + tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } }, + discovery: { maxInlineCatalogBytes: 0 }, + }) + expect(runtime.instructions()).toContain("Available tools (PARTIAL — 0 of 3 shown; find the rest with tools.$codemode.search)") + expect(runtime.instructions()).toContain("- thread (2 tools, none shown)") + expect(runtime.instructions()).toContain("- orders (1 tool, none shown)") + expect(runtime.instructions()).toMatch(/\$codemode\.search/) + expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/) + + const result = await Effect.runPromise(runtime.execute(` + return await tools.$codemode.search({ + query: "send message attachment upload file to current Discord thread", + limit: 2 + }) + `)) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toStrictEqual({ + items: [ + { + path: "tools.thread.uploadFile", + description: "Upload one readable local file to the current Discord thread", + signature: "tools.thread.uploadFile(input: { path: string }): Promise<{ sent: boolean }>", + }, + { + path: "tools.thread.generateImage", + description: "Generate an image and upload it to the current Discord thread", + signature: "tools.thread.generateImage(input: { prompt: string }): Promise<{ sent: boolean }>", + }, + ], + total: 2, + }) + expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }]) + + const variants = await Effect.runPromise(runtime.execute(` + return await Promise.all([ + tools.$codemode.search({ query: "file" }), + tools.$codemode.search({ query: "image" }) + ]) + `)) + expect(variants.ok).toBe(true) + if (variants.ok) { + expect((variants.value as Array<{ items: Array<{ path: string }> }>)[0]?.items[0]?.path).toBe("tools.thread.uploadFile") + expect((variants.value as Array<{ items: Array<{ path: string }> }>)[1]?.items[0]?.path).toBe("tools.thread.generateImage") + } + + const removed = await Effect.runPromise(runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`)) + expect(removed.ok).toBe(false) + if (!removed.ok) expect(removed.error.kind).toBe("UnknownTool") + }) + + test("search defaults to 10 results and resolves exact tool paths", async () => { + const tool = (index: number) => + Tool.make({ + description: `Numbered tool ${index}`, + input: Schema.Struct({ id: Schema.String }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + const runtime = CodeMode.make({ + tools: { + many: Object.fromEntries(Array.from({ length: 14 }, (_, index) => [`tool${index}`, tool(index)])), + }, + }) + + const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`)) + expect(browse.ok).toBe(true) + if (browse.ok) { + const value = browse.value as { items: Array<{ path: string }>; total: number } + expect(value.items).toHaveLength(10) + expect(value.total).toBe(14) + } + + for (const query of ["many.tool13", "tools.many.tool13"]) { + const exact = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`)) + expect(exact.ok).toBe(true) + if (exact.ok) { + expect(exact.value).toStrictEqual({ + items: [{ + path: "tools.many.tool13", + description: "Numbered tool 13", + signature: "tools.many.tool13(input: { id: string }): Promise", + }], + total: 1, + }) + } + } + }) + + test("scopes search to one namespace and browses it alphabetically", async () => { + const simple = (description: string) => + Tool.make({ + description, + input: Schema.Struct({ id: Schema.String }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + const runtime = CodeMode.make({ + tools: { + github: { list_issues: simple("List issues"), create_issue: simple("Create an issue") }, + linear: { list_issues: simple("List Linear issues") }, + }, + }) + + // Empty query + namespace browses just that namespace, alphabetical by path. + const browse = await Effect.runPromise(runtime.execute( + `return await tools.$codemode.search({ query: "", namespace: "github" })`, + )) + expect(browse.ok).toBe(true) + if (browse.ok) { + const value = browse.value as { items: Array<{ path: string }>; total: number } + expect(value.total).toBe(2) + expect(value.items.map((item) => item.path)).toStrictEqual(["tools.github.create_issue", "tools.github.list_issues"]) + } + + // A query + namespace ranks within that namespace only. + const scoped = await Effect.runPromise(runtime.execute( + `return await tools.$codemode.search({ query: "issues", namespace: "linear" })`, + )) + expect(scoped.ok).toBe(true) + if (scoped.ok) { + const value = scoped.value as { items: Array<{ path: string }>; total: number } + expect(value.total).toBe(1) + expect(value.items[0]?.path).toBe("tools.linear.list_issues") + } + + const invalid = await Effect.runPromise(runtime.execute( + `return await tools.$codemode.search({ query: "issues", namespace: 7 })`, + )) + expect(invalid.ok).toBe(false) + if (!invalid.ok) expect(invalid.error.kind).toBe("InvalidToolInput") + }) + + test("matches input parameter names and partial-word substrings", async () => { + const upload = Tool.make({ + description: "Send a document to the workspace", + input: { + type: "object", + properties: { attachment: { type: "string", description: "Local path of the payload to send" } }, + required: ["attachment"], + }, + run: () => Effect.succeed("ok"), + }) + const other = Tool.make({ + description: "Rename the workspace", + input: Schema.Struct({ name: Schema.String }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + const runtime = CodeMode.make({ tools: { files: { upload, other } } }) + + // "attachment" appears in neither path nor description — only in the input schema's + // property names, which the searchable text includes. + const byParameter = await Effect.runPromise(runtime.execute( + `return await tools.$codemode.search({ query: "attachment" })`, + )) + expect(byParameter.ok).toBe(true) + if (byParameter.ok) { + const value = byParameter.value as { items: Array<{ path: string }>; total: number } + expect(value.total).toBe(1) + expect(value.items[0]?.path).toBe("tools.files.upload") + } + + // Substring matching: a partial word ("docum") still hits the description. + const bySubstring = await Effect.runPromise(runtime.execute( + `return await tools.$codemode.search({ query: "docum" })`, + )) + expect(bySubstring.ok).toBe(true) + if (bySubstring.ok) { + const value = bySubstring.value as { items: Array<{ path: string }>; total: number } + expect(value.total).toBe(1) + expect(value.items[0]?.path).toBe("tools.files.upload") + } + }) + + test("empty query lists everything alphabetically by path", async () => { + const simple = (description: string) => + Tool.make({ + description, + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + // Deliberately declared out of alphabetical order. + const runtime = CodeMode.make({ + tools: { + zeta: { last: simple("Last") }, + alpha: { beta: simple("Middle"), aardvark: simple("First") }, + }, + }) + const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`)) + expect(browse.ok).toBe(true) + if (browse.ok) { + const value = browse.value as { items: Array<{ path: string }>; total: number } + expect(value.items.map((item) => item.path)).toStrictEqual([ + "tools.alpha.aardvark", + "tools.alpha.beta", + "tools.zeta.last", + ]) + } + }) + + test("inlines cheapest signatures first within the byte budget and labels every namespace", () => { + const cheap = Tool.make({ + description: "Cheap", + input: Schema.Struct({ q: Schema.String }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + const expensive = Tool.make({ + description: "An expensive tool whose description alone consumes far more than the remaining inline catalog byte budget for this runtime", + input: Schema.Struct({ someRatherLongParameterName: Schema.String, anotherEvenLongerParameterName: Schema.Number }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + // Budget fits alpha.cheap (70 bytes incl. newline) and would numerically fit + // beta.cheap, but alpha.expensive exhausts the budget first — inlining stops for + // every remaining namespace, exactly like the ported preview algorithm. + const runtime = CodeMode.make({ + tools: { alpha: { cheap, expensive }, beta: { cheap } }, + discovery: { maxInlineCatalogBytes: 145 }, + }) + + const instructions = runtime.instructions() + expect(instructions).toContain("Available tools (PARTIAL — 1 of 3 shown; find the rest with tools.$codemode.search)") + expect(instructions).toContain("- alpha (2 tools, 1 shown)") + expect(instructions).toContain(" - tools.alpha.cheap(input: { q: string }): Promise // Cheap") + expect(instructions).not.toContain("tools.alpha.expensive(") + expect(instructions).toContain("- beta (1 tool, none shown)") + expect(instructions).toMatch(/\$codemode\.search/) + }) + + test("decodes tool input and output before exposing either side", async () => { + const observed: Array = [] + const transformed = Tool.make({ + description: "Double a number", + input: Schema.Struct({ value: Schema.NumberFromString }), + output: Schema.NumberFromString, + run: ({ value }) => Effect.sync(() => { + observed.push(value) + return String(value * 2) + }), + }) + const runtime = CodeMode.make({ + tools: { math: { double: transformed } }, + onToolCallStart: (call) => Effect.sync(() => observed.push(call.input)), + }) + + const success = await Effect.runPromise(runtime.execute(`return await tools.math.double({ value: "21" })`)) + expect(success).toStrictEqual({ ok: true, value: 42, toolCalls: [{ name: "math.double" }] }) + expect(observed).toStrictEqual([{ value: 21 }, 21]) + + const invalid = await Effect.runPromise(runtime.execute(`return await tools.math.double({ value: 21 })`)) + expect(invalid.ok).toBe(false) + if (invalid.ok) return + expect(invalid.error.kind).toBe("InvalidToolInput") + expect(observed).toStrictEqual([{ value: 21 }, 21]) + }) + + test("returns JSON-safe data and normalizes undefined to null", async () => { + const result = await Effect.runPromise(CodeMode.execute({ + code: `return { top: undefined, nested: [1, undefined] }`, + })) + expect(result).toStrictEqual({ + ok: true, + value: { top: null, nested: [1, null] }, + toolCalls: [], + }) + expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) + + const dataLimits: InternalExecutionLimits = { maxDataBytes: 5 } + const oversized = await Effect.runPromise(CodeMode.execute({ + code: `return { value: undefined }`, + limits: dataLimits, + })) + expect(oversized.ok).toBe(false) + if (!oversized.ok) expect(oversized.error.kind).toBe("InvalidDataValue") + }) + + test("rejects invalid configuration and discovery limits", async () => { + const invalidConcurrency: InternalExecutionLimits = { maxConcurrency: 0 } + expect(() => CodeMode.make({ limits: invalidConcurrency })).toThrow(RangeError) + expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow(RangeError) + expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError) + + expect(() => CodeMode.make({ tools, discovery: { maxInlineCatalogBytes: -1 } })).toThrow(RangeError) + + const result = await Effect.runPromise(CodeMode.make({ + tools, + discovery: { maxInlineCatalogBytes: 0 }, + }).execute( + `return await tools.$codemode.search({ query: "order", limit: 0.5 })`, + )) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe("InvalidToolInput") + }) + + test("enforces source, operation, and tool-call limits as diagnostics", async () => { + const sourceLimits: InternalExecutionLimits = { maxSourceBytes: 1 } + const operationLimits: InternalExecutionLimits = { maxOperations: 10 } + const cases = [ + [CodeMode.execute({ code: "return 1", limits: sourceLimits }), "InvalidDataValue"], + [CodeMode.execute({ code: "while (true) {}", limits: operationLimits }), "OperationLimitExceeded"], + [CodeMode.execute({ tools, code: source, limits: { maxToolCalls: 0 } }), "ToolCallLimitExceeded"], + ] as const + + for (const [effect, kind] of cases) { + const result = await Effect.runPromise(effect) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.kind).toBe(kind) + } + }) + + test("reserves the discovery namespace", () => { + expect(() => CodeMode.make({ tools: { $codemode: { lookup } } })).toThrow( + /reserved for CodeMode discovery tools/, + ) + }) +}) diff --git a/packages/codemode/test/enumeration.test.ts b/packages/codemode/test/enumeration.test.ts new file mode 100644 index 000000000000..655230e02e6a --- /dev/null +++ b/packages/codemode/test/enumeration.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { CodeMode, Tool } from "../src/index.js" +import type { InternalExecutionLimits } from "../src/codemode.js" + +// Key enumeration: Object.keys and for...in share one surface over plain objects, arrays +// (index strings), and tool references (namespace/tool names from the host tool tree), so a +// model can discover what it may call instead of guessing names from the instructions. The +// motivating transcript: `Object.keys(tools)` failed with the generic plain-objects-only +// message and `for (const key in tools)` was unsupported syntax, forcing blind guesses. + +const echo = (description: string) => + Tool.make({ + description, + input: Schema.Struct({ value: Schema.String }), + output: Schema.String, + run: ({ value }) => Effect.succeed(value), + }) + +const tools = { + github: { list_issues: echo("List issues"), get_issue: echo("Get one issue") }, + memory: { search: echo("Search memory") }, + playwright: { navigate: echo("Navigate somewhere") }, +} + +const run = (code: string, limits?: InternalExecutionLimits) => + Effect.runPromise(CodeMode.execute({ tools, code, ...(limits ? { limits } : {}) })) +const value = async (code: string, limits?: InternalExecutionLimits) => { + const result = await run(code, limits) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} +const error = async (code: string, limits?: InternalExecutionLimits) => { + const result = await run(code, limits) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} + +describe("Object.keys over tool references", () => { + test("enumerates top-level namespaces (the transcript program)", async () => { + expect(await value(` + const namespaces = Object.keys(tools) + return { namespaces, count: namespaces.length } + `)).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 }) + }) + + test("enumerates tool names at a nested namespace", async () => { + expect(await value(`return Object.keys(tools.github)`)).toEqual(["list_issues", "get_issue"]) + }) + + test("a callable tool is a leaf and enumerates as []", async () => { + expect(await value(`return Object.keys(tools.github.list_issues)`)).toEqual([]) + }) + + test("the virtual discovery namespace enumerates its callable surface", async () => { + expect(await value(`return Object.keys(tools.$codemode)`)).toEqual(["search"]) + }) + + test("an unknown namespace is an UnknownTool error pointing at the discovery idioms", async () => { + const failure = await error(`return Object.keys(tools.nonexistent)`) + expect(failure.kind).toBe("UnknownTool") + expect(failure.message).toContain("Unknown tool namespace 'nonexistent'") + expect(failure.suggestions?.join(" ")).toContain("Object.keys(tools)") + }) + + test("Object.values/entries on a tool reference explain the working idioms", async () => { + for (const method of ["values", "entries"] as const) { + const failure = await error(`return Object.${method}(tools)`) + expect(failure.kind).toBe("InvalidDataValue") + expect(failure.message).toContain( + `Object.${method}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`, + ) + } + const nested = await error(`return Object.entries(tools.github)`) + expect(nested.message).toContain("Use Object.keys(tools) for names") + }) +}) + +describe("Object.keys over arrays", () => { + test("returns index strings, like JS", async () => { + expect(await value(`return Object.keys(["a", "b", "c"])`)).toEqual(["0", "1", "2"]) + expect(await value(`return Object.keys([])`)).toEqual([]) + }) + + test("objects keep their own enumerable keys", async () => { + expect(await value(`return Object.keys({ a: 1, b: 2 })`)).toEqual(["a", "b"]) + }) + + test("non-object inputs still fail clearly", async () => { + const failure = await error(`return Object.keys("nope")`) + expect(failure.message).toContain("Object.keys expects a data object or array") + }) +}) + +describe("for...in", () => { + test("iterates own enumerable keys of a plain object with break/continue", async () => { + expect(await value(` + const seen = [] + for (const key in { a: 1, b: 2, c: 3, d: 4 }) { + if (key === "b") continue + if (key === "d") break + seen.push(key) + } + return seen + `)).toEqual(["a", "c"]) + }) + + test("iterates index strings over arrays", async () => { + expect(await value(` + const indexes = [] + for (const i in ["x", "y", "z"]) { + if (i === "2") break + indexes.push(i) + } + return indexes + `)).toEqual(["0", "1"]) + }) + + test("supports let declarations and bare identifiers", async () => { + expect(await value(` + let last = "" + for (let key in { a: 1, b: 2 }) last = key + return last + `)).toBe("b") + expect(await value(` + let key = "before" + for (key in { only: 1 }) {} + return key + `)).toBe("only") + }) + + test("enumerates namespaces and tools from the host tool tree", async () => { + expect(await value(` + const names = [] + for (const ns in tools) { + for (const name in tools[ns]) names.push(ns + "." + name) + } + return names + `)).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"]) + }) + + test("unsupported values fail with a hint at for...of and Object.keys", async () => { + for (const expression of [`"text"`, "new Map([[1, 2]])", "new Set([1])", "42", "null"]) { + const failure = await error(`for (const key in ${expression}) {}; return "no"`) + expect(failure.message).toContain("for...in requires a plain object, array, or tools reference") + expect(failure.message).toContain("Use for...of for arrays/strings/Maps/Sets, or Object.keys(value)") + } + }) + + test("charges the operation budget per iteration", async () => { + const limits: InternalExecutionLimits = { maxOperations: 60 } + const build = `const obj = { ${Array.from({ length: 20 }, (_, index) => `k${index}: ${index}`).join(", ")} }` + + // The object itself fits comfortably; the for...in walk is what exhausts the budget. + expect(await value(`${build}; return 1`, limits)).toBe(1) + const failure = await error(`${build}; let n = 0; for (const key in obj) n += 1; return n`, limits) + expect(failure.kind).toBe("OperationLimitExceeded") + }) +}) diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts new file mode 100644 index 000000000000..25bdd0630e96 --- /dev/null +++ b/packages/codemode/test/parity.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode } from "../src/index.js" +import { ToolRuntime } from "../src/tool-runtime.js" + +// Runs a CodeMode program with no host tools and returns the ExecuteResult. These tests pin the +// JS-parity behaviors for the "99% of ordinary defensive JavaScript just works" goal: cases where +// a strict interpreter would throw but idiomatic JS yields undefined / succeeds. +// +// Note on the result boundary: this package normalizes a bare `undefined` result to `null` when +// it crosses out of the sandbox (results are JSON data), so tests asserting an in-sandbox +// `undefined` read check `=== undefined` inside the program and `null` at the boundary. +const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} })) +const value = async (code: string) => { + const result = await run(code) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} +const error = async (code: string) => { + const result = await run(code) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} + +describe("H2: string property access reads as undefined (not a throw)", () => { + test("unknown property on a string is undefined", async () => { + expect(await value(`const s = "hi"; return s.login === undefined`)).toBe(true) + expect(await value(`const s = "hi"; return s.login`)).toBeNull() + }) + + test("optional chaining + fallback on a string does not throw", async () => { + expect(await value(`const s = "hi"; return s?.login ?? "fallback"`)).toBe("fallback") + }) + + test("the real MCP pattern: result is a JSON string, defensive read falls through", async () => { + // me.result is a string; me.result?.login is undefined, so we fall back to the raw string. + expect(await value(`const me = { result: '{"login":"x"}' }; return me.result?.login ?? me.result`)).toBe( + '{"login":"x"}', + ) + }) + + test("unknown property on a number is undefined", async () => { + expect(await value(`return (5).foo ?? "n"`)).toBe("n") + }) + + test("supported string methods still work", async () => { + expect(await value(`return "AB".toLowerCase()`)).toBe("ab") + expect(await value(`return "hello".length`)).toBe(5) + }) +}) + +describe("H3: array property access reads as undefined (not a throw)", () => { + test("unknown property on an array is undefined", async () => { + expect(await value(`return [1,2,3].foo === undefined`)).toBe(true) + expect(await value(`return [1,2,3].foo`)).toBeNull() + }) + + test("optional chaining on an array does not throw", async () => { + expect(await value(`return [1,2,3]?.foo ?? "fb"`)).toBe("fb") + }) + + test("real-but-unsupported array methods still give the rewrite hint", async () => { + const err = await error(`return [1,2,3].splice(0,1)`) + expect(err.kind).toBe("UnsupportedSyntax") + expect(err.message).toContain("splice") + }) + + test("supported array methods and indexing still work", async () => { + expect(await value(`return [1,2,3].map(x => x + 1)`)).toEqual([2, 3, 4]) + expect(await value(`return [1,2,3][9] === undefined`)).toBe(true) + expect(await value(`return [1,2,3][9]`)).toBeNull() + }) +}) + +describe("H6: object spread of null/undefined is a no-op", () => { + test("spreading null is a no-op", async () => { + expect(await value(`const o = null; return { ...o, a: 1 }`)).toEqual({ a: 1 }) + }) + + test("spreading an absent argument merges cleanly", async () => { + expect(await value(`function f(opts){ return { ...opts, a: 1 } } return f(undefined)`)).toEqual({ a: 1 }) + }) + + test("spreading a real object still works", async () => { + expect(await value(`const o = { a: 1 }; return { ...o, b: 2 }`)).toEqual({ a: 1, b: 2 }) + }) + + test("spreading an array into an object still errors", async () => { + const err = await error(`return { ...[1,2], a: 1 }`) + expect(err.kind).toBe("InvalidDataValue") + }) +}) + +describe("H4: typeof on an undeclared identifier is 'undefined'", () => { + test("feature-detection guard does not throw", async () => { + expect(await value(`return typeof foo === "undefined" ? "safe" : "no"`)).toBe("safe") + }) + + test("typeof of a declared binding is unaffected", async () => { + expect(await value(`const x = 5; return typeof x`)).toBe("number") + expect(await value(`const s = "a"; return typeof s`)).toBe("string") + }) + + test("referencing an undeclared identifier outside typeof still throws", async () => { + const err = await error(`return foo + 1`) + expect(err.message).toContain("foo") + }) +}) + +describe("H1: NaN/Infinity flow as intermediates and normalize to null at the boundary", () => { + test("guards run instead of the program crashing on a transient NaN", async () => { + expect(await value(`return parseInt("abc") || 0`)).toBe(0) + expect(await value(`const x = Number("abc"); return Number.isNaN(x) ? 0 : x`)).toBe(0) + expect(await value(`const o = {}; o.count = (o.count || 0) + 1; return o.count`)).toBe(1) + // average of an empty list, guarded — the classic divide-by-zero that used to throw pre-guard + expect(await value(`const a = []; return a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0`)).toBe(0) + }) + + test("a non-finite value becomes null when it leaves the sandbox", async () => { + expect(await value(`return 5/0`)).toBeNull() + expect(await value(`return 0/0`)).toBeNull() + expect(await value(`return Math.max()`)).toBeNull() + // nested, too — normalization walks the returned structure + expect(await value(`return { a: Number("x"), b: 2, c: [1/0] }`)).toEqual({ a: null, b: 2, c: [null] }) + }) + + test("NaN and Infinity are usable identifiers and inspectable in-sandbox", async () => { + expect(await value(`return Number.isNaN(NaN)`)).toBe(true) + expect(await value(`return Infinity > 1e9`)).toBe(true) + expect(await value(`return Number.isFinite(1/0)`)).toBe(false) + expect(await value(`return [3,1,2].reduce((a,b)=>Math.max(a,b), -Infinity)`)).toBe(3) + // JSON.stringify inside the sandbox matches JS: non-finite serializes to null + expect(await value(`return JSON.stringify({ x: Number("z") })`)).toBe('{"x":null}') + }) + + test("copyOut normalizes non-finite numbers to null (the shared return + tool-arg boundary)", () => { + // Tool-call arguments funnel through copyOut too, so this one function pins both boundaries. + expect(ToolRuntime.copyOut(NaN)).toBeNull() + expect(ToolRuntime.copyOut(Infinity)).toBeNull() + expect(ToolRuntime.copyOut(-Infinity)).toBeNull() + expect(ToolRuntime.copyOut(42)).toBe(42) + expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] })).toEqual({ a: null, b: [null, 1] }) + }) +}) + +describe("H5: builtin coercion functions work as array callbacks", () => { + test("filter(Boolean) drops falsy values", async () => { + expect(await value(`return [0, 1, "", 2, null, 3].filter(Boolean)`)).toEqual([1, 2, 3]) + }) + + test("map(String) coerces each element", async () => { + expect(await value(`return [1, 2, 3].map(String)`)).toEqual(["1", "2", "3"]) + }) + + test("arrow callbacks still work (no regression)", async () => { + expect(await value(`return [1, 2, 3, 4].filter(x => x % 2 === 0)`)).toEqual([2, 4]) + expect(await value(`return [1, 2, 3].reduce((a, b) => a + b, 0)`)).toBe(6) + }) + + test("a non-callable callback is still rejected", async () => { + const err = await error(`return [1,2,3].map(42)`) + expect(err.message).toContain("callback") + }) +}) diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts new file mode 100644 index 000000000000..034a3cd5c841 --- /dev/null +++ b/packages/codemode/test/promise.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { CodeMode, Tool, toolError, type ExecuteResult } from "../src/index.js" +import type { InternalExecutionLimits } from "../src/codemode.js" + +// Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on +// supervised fibers, `await` settles them, and Promise.all/allSettled/race/resolve/reject are +// ordinary functions over arbitrary arrays mixing promises and plain values. + +type Trace = { + starts: Array + active: number + maxActive: number + completed: number + interrupted: number +} + +const makeTrace = (): Trace => ({ starts: [], active: 0, maxActive: 0, completed: 0, interrupted: 0 }) + +/** Echoes `id` after `ms` milliseconds, recording start order, live concurrency, and interruption. */ +const sleepyTool = (trace: Trace) => + Tool.make({ + description: "Echo an id after a delay", + input: Schema.Struct({ id: Schema.Number, ms: Schema.optionalKey(Schema.Number) }), + output: Schema.Number, + run: ({ id, ms }) => + Effect.gen(function*() { + trace.starts.push(id) + trace.active += 1 + trace.maxActive = Math.max(trace.maxActive, trace.active) + yield* Effect.sleep(ms ?? 20) + trace.active -= 1 + trace.completed += 1 + return id + }).pipe(Effect.onInterrupt(() => Effect.sync(() => { + trace.active -= 1 + trace.interrupted += 1 + }))), + }) + +const failingTool = Tool.make({ + description: "Always refuse", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.fail(toolError("Lookup refused")), +}) + +const run = (code: string, options: { trace?: Trace; limits?: InternalExecutionLimits } = {}): Promise => { + const trace = options.trace ?? makeTrace() + return Effect.runPromise(CodeMode.execute({ + tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } }, + code, + ...(options.limits ? { limits: options.limits } : {}), + })) +} + +const value = async (code: string, options: { trace?: Trace; limits?: InternalExecutionLimits } = {}) => { + const result = await run(code, options) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} + +const error = async (code: string, options: { trace?: Trace; limits?: InternalExecutionLimits } = {}) => { + const result = await run(code, options) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} + +describe("first-class promise values", () => { + test("an un-awaited tool call starts eagerly, in call order, before any await", async () => { + const trace = makeTrace() + const result = await value( + ` + const a = tools.host.sleepy({ id: 1, ms: 40 }) + const b = tools.host.sleepy({ id: 2, ms: 40 }) + const rb = await b + const ra = await a + return [ra, rb] + `, + { trace }, + ) + expect(result).toEqual([1, 2]) + expect(trace.starts).toEqual([1, 2]) + // Both calls overlapped even though they were awaited sequentially. + expect(trace.maxActive).toBeGreaterThan(1) + }) + + test("awaiting the same promise twice settles once and never re-runs the call", async () => { + const result = await run(` + const p = tools.host.sleepy({ id: 7 }) + const x = await p + const y = await p + return [x, y] + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toEqual([7, 7]) + expect(result.toolCalls).toStrictEqual([{ name: "host.sleepy" }]) + }) + + test("await of a non-promise value is a passthrough no-op", async () => { + expect(await value(`return await 42`)).toBe(42) + expect(await value(`const x = await "s"; return x`)).toBe("s") + expect(await value(`return await null`)).toBeNull() + expect(await value(`return (await [1, 2]).length`)).toBe(2) + }) + + test("returning an un-awaited tool call resolves it (async-function return semantics)", async () => { + expect(await value(`return tools.host.sleepy({ id: 9 })`)).toBe(9) + }) + + test("typeof a promise is 'object', and console.log renders it sensibly", async () => { + const result = await run(` + const p = Promise.resolve(1) + console.log(p) + return typeof p + `) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.value).toBe("object") + expect(result.logs).toStrictEqual(["[Promise (await it to get its value)]"]) + }) + + test("an awaited failure is catchable exactly like a synchronous throw", async () => { + expect(await value(` + const p = tools.host.fail({}) + try { + await p + return "no" + } catch (e) { + return e.message + } + `)).toBe("Lookup refused") + }) + + test("a fire-and-forget call completes before the execution ends", async () => { + const trace = makeTrace() + const result = await value( + ` + tools.host.sleepy({ id: 1, ms: 30 }) + return "done" + `, + { trace }, + ) + expect(result).toBe("done") + expect(trace.completed).toBe(1) + expect(trace.interrupted).toBe(0) + }) + + test("a never-awaited failing call surfaces as an unhandled-rejection diagnostic", async () => { + const diagnostic = await error(` + tools.host.fail({}) + return "done" + `) + expect(diagnostic.kind).toBe("ToolFailure") + expect(diagnostic.message).toContain("Unhandled rejection from an un-awaited tool call") + expect(diagnostic.message).toContain("Lookup refused") + expect(diagnostic.suggestions?.join(" ")).toContain("await tools.ns.tool(...)") + }) +}) + +describe("promises at data boundaries", () => { + test("returning an un-awaited promise inside data is a clear await-hinting diagnostic", async () => { + const diagnostic = await error(`return { result: tools.host.sleepy({ id: 1 }) }`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") + expect(diagnostic.message).toContain("await tools.ns.tool(...)") + }) + + test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => { + const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") + }) + + test("JSON.stringify of a promise is a diagnostic, not '{}'", async () => { + const diagnostic = await error(`return JSON.stringify(Promise.resolve(1))`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") + }) + + test("operators reject promise operands", async () => { + const diagnostic = await error(`return Promise.resolve(1) + 1`) + expect(diagnostic.kind).toBe("InvalidDataValue") + }) +}) + +describe("Promise.all over arbitrary arrays", () => { + test("mixes promises and plain values, preserving order", async () => { + expect(await value(` + return await Promise.all([tools.host.sleepy({ id: 1 }), "plain", tools.host.sleepy({ id: 2 }), 42]) + `)).toEqual([1, "plain", 2, 42]) + }) + + test("accepts arrays built beforehand, passed as identifiers, and spread elements", async () => { + expect(await value(` + const calls = [] + calls.push(tools.host.sleepy({ id: 1 })) + calls.push(7) + const more = [tools.host.sleepy({ id: 2 })] + const batch = [...calls, ...more, "x"] + return await Promise.all(batch) + `)).toEqual([1, 7, 2, "x"]) + }) + + test("runs items.map tool calls in parallel", async () => { + const trace = makeTrace() + const startedAt = Date.now() + const result = await value( + ` + const ids = [1, 2, 3, 4] + return await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 40 }))) + `, + { trace }, + ) + expect(result).toEqual([1, 2, 3, 4]) + expect(trace.maxActive).toBeGreaterThan(1) + // Four 40ms sleeps in parallel finish well under the 160ms sequential floor. + expect(Date.now() - startedAt).toBeLessThan(150) + }) + + test("caps live tool-call concurrency at the internal limit", async () => { + const trace = makeTrace() + const result = await value( + ` + const ids = [] + for (let i = 0; i < 20; i += 1) ids.push(i) + const results = await Promise.all(ids.map((id) => tools.host.sleepy({ id, ms: 10 }))) + return results.length + `, + { trace }, + ) + expect(result).toBe(20) + expect(trace.maxActive).toBeGreaterThan(1) + expect(trace.maxActive).toBeLessThanOrEqual(8) + }) + + test("resolves the empty array", async () => { + expect(await value(`return await Promise.all([])`)).toEqual([]) + }) + + test("rejects with the first failure, catchable in-program", async () => { + expect(await value(` + try { + await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.fail({})]) + return "no" + } catch (e) { + return e.message + } + `)).toBe("Lookup refused") + }) + + test("a non-collection argument is a clear error", async () => { + const diagnostic = await error(`return await Promise.all(42)`) + expect(diagnostic.message).toContain("Promise.all expects an array") + }) + + test("exceeding maxToolCalls inside Promise.all is a ToolCallLimitExceeded diagnostic", async () => { + const diagnostic = await error( + `return await Promise.all([tools.host.sleepy({ id: 1 }), tools.host.sleepy({ id: 2 }), tools.host.sleepy({ id: 3 })])`, + { limits: { maxToolCalls: 2 } }, + ) + expect(diagnostic.kind).toBe("ToolCallLimitExceeded") + }) +}) + +describe("Promise.allSettled", () => { + test("reports fulfilled and rejected outcomes with catch-normalized reasons", async () => { + expect(await value(` + return await Promise.allSettled([ + tools.host.sleepy({ id: 5 }), + tools.host.fail({}), + "plain", + Promise.reject(new Error("boom")), + ]) + `)).toEqual([ + { status: "fulfilled", value: 5 }, + { status: "rejected", reason: { message: "Lookup refused" } }, + { status: "fulfilled", value: "plain" }, + { status: "rejected", reason: { name: "Error", message: "boom" } }, + ]) + }) + + test("never rejects for program-level failures", async () => { + const result = await run(` + const settled = await Promise.allSettled([tools.host.fail({}), tools.host.fail({})]) + return settled.filter((s) => s.status === "rejected").length + `) + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toBe(2) + }) +}) + +describe("Promise.race", () => { + test("first settlement wins and losers are interrupted", async () => { + const trace = makeTrace() + const result = await value( + ` + const fast = tools.host.sleepy({ id: 1, ms: 10 }) + const slow = tools.host.sleepy({ id: 2, ms: 5000 }) + return await Promise.race([fast, slow]) + `, + { trace }, + ) + expect(result).toBe(1) + expect(trace.interrupted).toBe(1) + expect(trace.completed).toBe(1) + }) + + test("awaiting an interrupted loser afterwards is a catchable program failure", async () => { + expect(await value(` + const fast = tools.host.sleepy({ id: 1, ms: 10 }) + const slow = tools.host.sleepy({ id: 2, ms: 5000 }) + const winner = await Promise.race([fast, slow]) + try { + await slow + return "no" + } catch (e) { + return { winner, caught: e.message } + } + `)).toEqual({ winner: 1, caught: "This tool call was interrupted because another value settled a Promise.race first." }) + }) + + test("a rejection can win the race", async () => { + expect(await value(` + try { + await Promise.race([tools.host.fail({}), tools.host.sleepy({ id: 1, ms: 5000 })]) + return "no" + } catch (e) { + return e.message + } + `)).toBe("Lookup refused") + }) + + test("a plain value wins over pending promises", async () => { + const trace = makeTrace() + expect(await value(`return await Promise.race([tools.host.sleepy({ id: 1, ms: 5000 }), "immediate"])`, { trace })).toBe("immediate") + expect(trace.interrupted).toBe(1) + }) + + test("an empty race is a clear error instead of hanging", async () => { + const diagnostic = await error(`return await Promise.race([])`) + expect(diagnostic.message).toContain("never settle") + }) +}) + +describe("Promise.resolve / Promise.reject", () => { + test("resolve wraps plain values and passes promises through", async () => { + expect(await value(`return await Promise.resolve(42)`)).toBe(42) + expect(await value(`return await Promise.resolve(Promise.resolve("nested"))`)).toBe("nested") + expect(await value(`return await Promise.resolve(tools.host.sleepy({ id: 3 }))`)).toBe(3) + }) + + test("reject produces a promise whose await throws the reason", async () => { + expect(await value(` + try { + await Promise.reject("nope") + return "no" + } catch (e) { + return e + } + `)).toBe("nope") + }) +}) + +describe("timeout interruption of forked calls", () => { + test("the execution timeout interrupts in-flight forked fibers", async () => { + const trace = makeTrace() + const result = await run( + ` + const a = tools.host.sleepy({ id: 1, ms: 60000 }) + const b = tools.host.sleepy({ id: 2, ms: 60000 }) + return await a + `, + { trace, limits: { timeoutMs: 100 } }, + ) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe("TimeoutExceeded") + // Both calls started; neither escaped the timeout — the awaited one AND the abandoned one. + expect(trace.starts).toEqual([1, 2]) + expect(trace.interrupted).toBe(2) + expect(trace.completed).toBe(0) + }) + + test("the timeout also interrupts calls inside Promise.all", async () => { + const trace = makeTrace() + const result = await run( + `return await Promise.all([tools.host.sleepy({ id: 1, ms: 60000 }), tools.host.sleepy({ id: 2, ms: 60000 })])`, + { trace, limits: { timeoutMs: 100 } }, + ) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.error.kind).toBe("TimeoutExceeded") + expect(trace.interrupted).toBe(2) + }) +}) + +describe("unsupported promise surface", () => { + test(".then/.catch/.finally give a clear await-instead error", async () => { + for (const method of ["then", "catch", "finally"]) { + const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).${method}((x) => x)`) + expect(diagnostic.kind).toBe("UnsupportedSyntax") + expect(diagnostic.message).toContain(`Promise.prototype.${method} is not supported`) + expect(diagnostic.message).toContain("await") + } + }) + + test("other property reads on a promise hint at the missing await", async () => { + const diagnostic = await error(`return tools.host.sleepy({ id: 1 }).value`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") + expect(diagnostic.message).toContain("await it first") + }) + + test("unknown Promise statics list what is available", async () => { + const diagnostic = await error(`return await Promise.any([tools.host.sleepy({ id: 1 })])`) + expect(diagnostic.message).toContain("Promise.any is not available") + expect(diagnostic.message).toContain("Promise.allSettled") + }) + + test("new Promise(...) points at tool calls instead", async () => { + const diagnostic = await error(`return new Promise((resolve) => resolve(1))`) + expect(diagnostic.kind).toBe("UnsupportedSyntax") + expect(diagnostic.message).toContain("new Promise(...) is not supported") + expect(diagnostic.message).toContain("already return promises") + }) +}) diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts new file mode 100644 index 000000000000..ccf8ad233921 --- /dev/null +++ b/packages/codemode/test/stdlib.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CodeMode } from "../src/index.js" +import type { InternalExecutionLimits as ExecutionLimits } from "../src/codemode.js" + +// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS; +// at every data boundary (final result, tool arguments, JSON.stringify) they serialize exactly +// as JSON.stringify would: Date -> ISO string (invalid -> null), RegExp/Map/Set -> {}. +const run = (code: string, limits?: ExecutionLimits) => Effect.runPromise(CodeMode.execute({ code, tools: {}, ...(limits ? { limits } : {}) })) +const value = async (code: string, limits?: ExecutionLimits) => { + const result = await run(code, limits) + if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) + return result.value +} +const error = async (code: string, limits?: ExecutionLimits) => { + const result = await run(code, limits) + if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) + return result.error +} + +describe("Date", () => { + test("Date.now() returns a number", async () => { + expect(await value(`return typeof Date.now()`)).toBe("number") + }) + + test("epoch construction and ISO rendering", async () => { + expect(await value(`return new Date(0).toISOString()`)).toBe("1970-01-01T00:00:00.000Z") + }) + + test("string parsing round-trips", async () => { + expect(await value(`return new Date("2024-01-02T03:04:05.000Z").getTime()`)).toBe(1704164645000) + expect(await value(`return Date.parse("2024-01-02T03:04:05.000Z")`)).toBe(1704164645000) + }) + + test("date arithmetic and comparison use the time value", async () => { + expect(await value(`const a = new Date(1000); const b = new Date(3000); return b - a`)).toBe(2000) + expect(await value(`const a = new Date(1000); const b = new Date(3000); return a < b`)).toBe(true) + expect(await value(`return +new Date(42)`)).toBe(42) + }) + + test("UTC getters read calendar components", async () => { + expect(await value(`const d = new Date("2024-03-05T06:07:08.009Z"); return [d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()]`)).toEqual([2024, 2, 5, 6, 7, 8, 9]) + }) + + test("invalid dates yield NaN times, guardable in-sandbox", async () => { + expect(await value(`return Number.isNaN(new Date("garbage").getTime())`)).toBe(true) + expect(await value(`return new Date("garbage").toJSON()`)).toBeNull() + }) + + test("toISOString on an invalid date is a catchable error", async () => { + expect(await value(`try { new Date("garbage").toISOString(); return "no" } catch { return "caught" }`)).toBe("caught") + }) + + test("template interpolation renders the ISO form", async () => { + expect(await value("return `at ${new Date(0)}`")).toBe("at 1970-01-01T00:00:00.000Z") + }) + + test("dates serialize to ISO strings at the boundary, direct and nested", async () => { + expect(await value(`return new Date(0)`)).toBe("1970-01-01T00:00:00.000Z") + expect(await value(`return { when: new Date(0), tags: [new Date(1000)] }`)).toEqual({ + when: "1970-01-01T00:00:00.000Z", + tags: ["1970-01-01T00:00:01.000Z"], + }) + expect(await value(`return JSON.stringify({ d: new Date(0) })`)).toBe('{"d":"1970-01-01T00:00:00.000Z"}') + }) + + test("coercions: Number is the time, String is ISO, Boolean is true", async () => { + expect(await value(`return Number(new Date(5))`)).toBe(5) + expect(await value(`return String(new Date(0))`)).toBe("1970-01-01T00:00:00.000Z") + expect(await value(`return Boolean(new Date(0))`)).toBe(true) + }) + + test("sorting dates with a numeric comparator", async () => { + expect(await value(` + const dates = [new Date(3000), new Date(1000), new Date(2000)] + return dates.sort((a, b) => a - b).map((d) => d.getTime()) + `)).toEqual([1000, 2000, 3000]) + }) + + test("new Date(year, month, day) accepts component form", async () => { + expect(await value(`const d = new Date(2024, 0, 2); return [d.getFullYear(), d.getMonth(), d.getDate()]`)).toEqual([2024, 0, 2]) + }) + + test("typeof and unknown properties are forgiving", async () => { + expect(await value(`return typeof new Date(0)`)).toBe("object") + expect(await value(`return new Date(0).nope === undefined`)).toBe(true) + }) +}) + +describe("RegExp", () => { + test("literal test", async () => { + expect(await value(`return /ab+c/.test("xabbbc")`)).toBe(true) + expect(await value(`return /ab+c/.test("nope")`)).toBe(false) + }) + + test("exec exposes captures and index", async () => { + expect(await value(`const m = /a(b+)/.exec("xxabbc"); return { full: m[0], group: m[1], index: m.index }`)).toEqual({ + full: "abb", + group: "bb", + index: 2, + }) + expect(await value(`return /a/.exec("zzz")`)).toBeNull() + }) + + test("named groups read through", async () => { + expect(await value(`const m = /(?[a-z]+)-(?\\d+)/.exec("id ab-42"); return m.groups.word + m.groups.num`)).toBe("ab42") + }) + + test("global exec advances lastIndex across calls", async () => { + expect(await value(` + const r = /\\d+/g + const first = r.exec("a1b22c") + const second = r.exec("a1b22c") + return [first[0], second[0]] + `)).toEqual(["1", "22"]) + }) + + test("string match: non-global carries index, global lists all matches", async () => { + expect(await value(`const m = "a1b22".match(/\\d+/); return [m[0], m.index]`)).toEqual(["1", 1]) + expect(await value(`return "a1b22".match(/\\d+/g)`)).toEqual(["1", "22"]) + expect(await value(`return "abc".match(/\\d/)`)).toBeNull() + }) + + test("matchAll materializes match arrays with captures", async () => { + expect(await value(`return "a1b22".matchAll(/(\\d+)/g).map((m) => m[1])`)).toEqual(["1", "22"]) + }) + + test("replace and replaceAll with patterns and $1 substitution", async () => { + expect(await value(`return "a1b2".replace(/\\d/, "#")`)).toBe("a#b2") + expect(await value(`return "a1b2".replace(/\\d/g, "#")`)).toBe("a#b#") + expect(await value(`return "a1b2".replaceAll(/\\d/g, "#")`)).toBe("a#b#") + expect(await value(`return "hi bob".replace(/b(o)b/, "[$1]")`)).toBe("hi [o]") + }) + + test("replaceAll without the g flag is a catchable error", async () => { + expect(await value(`try { "a".replaceAll(/a/, "b"); return "no" } catch { return "caught" }`)).toBe("caught") + }) + + test("split and search accept patterns", async () => { + expect(await value(`return "a1b22c".split(/\\d+/)`)).toEqual(["a", "b", "c"]) + expect(await value(`return "ab42".search(/\\d/)`)).toBe(2) + expect(await value(`return "ab".search(/\\d/)`)).toBe(-1) + }) + + test("new RegExp constructs from strings; invalid patterns are catchable", async () => { + expect(await value(`return new RegExp("a+", "i").test("AAA")`)).toBe(true) + expect(await value(`try { new RegExp("("); return "no" } catch { return "caught" }`)).toBe("caught") + expect(await value(`try { /a/ instanceof RegExp } catch { }; return /a/.source`)).toBe("a") + }) + + test("source and flags properties read through", async () => { + expect(await value(`const r = /ab/gi; return { source: r.source, flags: r.flags, global: r.global }`)).toEqual({ + source: "ab", + flags: "gi", + global: true, + }) + }) + + test("regexes serialize to {} at the boundary, like JSON", async () => { + expect(await value(`return /a/`)).toEqual({}) + expect(await value(`return JSON.stringify({ r: /a/g })`)).toBe('{"r":{}}') + }) + + test("template interpolation renders the literal form", async () => { + expect(await value("return `${/ab/g}`")).toBe("/ab/g") + }) +}) + +describe("Map", () => { + test("get/set/has/size with chaining", async () => { + expect(await value(` + const m = new Map() + m.set("a", 1).set("b", 2) + return { a: m.get("a"), b: m.get("b"), has: m.has("a"), miss: m.get("zz") === undefined, size: m.size } + `)).toEqual({ a: 1, b: 2, has: true, miss: true, size: 5 - 3 }) + }) + + test("object keys use identity", async () => { + expect(await value(` + const key = { id: 1 } + const m = new Map() + m.set(key, "hit") + return [m.get(key), m.get({ id: 1 }) === undefined] + `)).toEqual(["hit", true]) + }) + + test("construction from entry pairs and another Map", async () => { + expect(await value(`const m = new Map([["a", 1], ["b", 2]]); return m.get("b")`)).toBe(2) + expect(await value(`const m = new Map([["a", 1]]); const n = new Map(m); n.set("b", 2); return [n.get("a"), n.get("b"), m.has("b")]`)).toEqual([1, 2, false]) + expect((await error(`return new Map("nope")`)).message).toMatch(/\[key, value\] pairs/) + expect((await error(`return new Map(["flat"])`)).message).toMatch(/\[key, value\] pairs/) + }) + + test("keys/values/entries return arrays", async () => { + expect(await value(` + const m = new Map([["a", 1], ["b", 2]]) + return { keys: m.keys(), values: m.values(), entries: m.entries() } + `)).toEqual({ keys: ["a", "b"], values: [1, 2], entries: [["a", 1], ["b", 2]] }) + }) + + test("Object.fromEntries(map) and Array.from(map)", async () => { + expect(await value(`return Object.fromEntries(new Map([["a", 1], ["b", 2]]))`)).toEqual({ a: 1, b: 2 }) + expect(await value(`return Array.from(new Map([["a", 1]]))`)).toEqual([["a", 1]]) + }) + + test("for...of iterates [key, value] pairs with destructuring", async () => { + expect(await value(` + const m = new Map([["a", 1], ["b", 2]]) + let total = 0 + let names = "" + for (const [key, count] of m) { names += key; total += count } + return names + total + `)).toBe("ab3") + }) + + test("spread produces entry pairs", async () => { + expect(await value(`return [...new Map([["a", 1]])]`)).toEqual([["a", 1]]) + }) + + test("forEach passes (value, key)", async () => { + expect(await value(` + const m = new Map([["a", 1], ["b", 2]]) + const seen = [] + m.forEach((count, key) => seen.push(key + count)) + return seen + `)).toEqual(["a1", "b2"]) + }) + + test("delete and clear", async () => { + expect(await value(` + const m = new Map([["a", 1], ["b", 2]]) + const removed = m.delete("a") + const missed = m.delete("zz") + const sizeAfterDelete = m.size + m.clear() + return [removed, missed, sizeAfterDelete, m.size] + `)).toEqual([true, false, 1, 0]) + }) + + test("counting idiom: grouped tallies", async () => { + expect(await value(` + const words = ["a", "b", "a", "c", "a"] + const counts = new Map() + for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1) + return Object.fromEntries(counts) + `)).toEqual({ a: 3, b: 1, c: 1 }) + }) + + test("maps serialize to {} at the boundary, like JSON", async () => { + expect(await value(`return new Map([["a", 1]])`)).toEqual({}) + expect(await value(`return JSON.stringify(new Map([["a", 1]]))`)).toBe("{}") + }) + + test("collection-length limit rejects unbounded growth", async () => { + const failure = await error( + `const m = new Map(); for (let i = 0; i < 10; i += 1) m.set(i, i); return m.size`, + { maxCollectionLength: 3 }, + ) + expect(failure.kind).toBe("InvalidDataValue") + expect(failure.message).toMatch(/maximum collection length/) + }) + + test("console.log renders map contents for debugging", async () => { + const result = await run(`console.log(new Map([["a", 1]])); return null`) + expect(result.ok).toBe(true) + expect(result.logs?.[0]).toBe(`Map(1) [["a",1]]`) + }) +}) + +describe("Set", () => { + test("add/has/delete/size with chaining", async () => { + expect(await value(` + const s = new Set() + s.add(1).add(2).add(1) + const removed = s.delete(2) + return [s.size, s.has(1), s.has(2), removed] + `)).toEqual([1, true, false, true]) + }) + + test("dedupe idiom: [...new Set(items)]", async () => { + expect(await value(`return [...new Set([1, 2, 2, 3, 1])]`)).toEqual([1, 2, 3]) + }) + + test("construction from strings and other Sets", async () => { + expect(await value(`return [...new Set("aba")]`)).toEqual(["a", "b"]) + expect(await value(`return Array.from(new Set(new Set([1, 2])))`)).toEqual([1, 2]) + }) + + test("SameValueZero: NaN is findable", async () => { + expect(await value(`const s = new Set([NaN]); return s.has(NaN)`)).toBe(true) + }) + + test("for...of iterates values", async () => { + expect(await value(` + let total = 0 + for (const n of new Set([1, 2, 3])) total += n + return total + `)).toBe(6) + }) + + test("sets serialize to {} at the boundary, like JSON", async () => { + expect(await value(`return { s: new Set([1]) }`)).toEqual({ s: {} }) + }) + + test("collection-length limit rejects unbounded growth", async () => { + const failure = await error( + `const s = new Set(); for (let i = 0; i < 10; i += 1) s.add(i); return s.size`, + { maxCollectionLength: 3 }, + ) + expect(failure.kind).toBe("InvalidDataValue") + }) +}) + +describe("stdlib integration", () => { + test("typeof reports constructors as functions and never throws", async () => { + expect(await value(`return typeof Map`)).toBe("function") + expect(await value(`return typeof ((x) => x)`)).toBe("function") + expect(await value(`return typeof Math`)).toBe("object") + expect(await value(`return typeof tools`)).toBe("object") + }) + + test("negation works on any value", async () => { + expect(await value(`return !new Map()`)).toBe(false) + expect(await value(`const fn = () => 1; return !fn`)).toBe(false) + }) + + test("object spread of sandbox values is a no-op, like JS", async () => { + expect(await value(`return { ...new Map([["a", 1]]), kept: true }`)).toEqual({ kept: true }) + }) + + test("dates inside Map values survive in-sandbox reads", async () => { + expect(await value(` + const m = new Map([["start", new Date(1000)]]) + return m.get("start").getTime() + `)).toBe(1000) + }) + + test("realistic pipeline: parse, extract with regex, dedupe, count by day", async () => { + expect(await value(` + const raw = '[{"at":"2024-01-01T05:00:00Z","tag":"a b"},{"at":"2024-01-01T09:00:00Z","tag":"b c"},{"at":"2024-01-02T01:00:00Z","tag":"a"}]' + const rows = JSON.parse(raw) + const tags = new Set() + const byDay = new Map() + for (const row of rows) { + for (const m of row.tag.matchAll(/[a-z]+/g)) tags.add(m[0]) + const day = new Date(row.at).toISOString().slice(0, 10) + byDay.set(day, (byDay.get(day) ?? 0) + 1) + } + return { tags: [...tags].sort((a, b) => (a < b ? -1 : 1)), byDay: Object.fromEntries(byDay) } + `)).toEqual({ tags: ["a", "b", "c"], byDay: { "2024-01-01": 2, "2024-01-02": 1 } }) + }) +}) diff --git a/packages/codemode/tsconfig.json b/packages/codemode/tsconfig.json new file mode 100644 index 000000000000..fe5c4d217b2e --- /dev/null +++ b/packages/codemode/tsconfig.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "noUncheckedIndexedAccess": false + } +} From 90b4af666d8a0b78efd3c6dfac3d4b72d5cac8ea Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 10:58:36 -0500 Subject: [PATCH 27/41] feat(opencode): rebuild code mode as an MCP adapter over @opencode-ai/codemode Replaces the vendored rune interpreter with a thin adapter: MCP defs become Tool.make definitions grouped as tools.., results prefer structuredContent over joined text, media is stripped host-side into tool-result attachments (media-only calls return a text marker), per-child permission asks are preserved, and start/end hooks drive the existing TUI toolCalls metadata. Deletes src/session/rune/ and moves acorn/typescript deps into the new package. --- bun.lock | 20 +- packages/opencode/package.json | 4 +- packages/opencode/src/session/code-mode.ts | 788 +---- .../src/session/rune/capability-error.ts | 10 - packages/opencode/src/session/rune/rune.md | 243 -- packages/opencode/src/session/rune/rune.ts | 2888 ----------------- .../opencode/src/session/rune/tool-runtime.ts | 274 -- packages/opencode/src/session/rune/tool.ts | 100 - .../session/code-mode-integration.test.ts | 127 +- .../opencode/test/session/code-mode.test.ts | 795 ++--- .../opencode/test/session/rune-parity.test.ts | 157 - 11 files changed, 534 insertions(+), 4872 deletions(-) delete mode 100644 packages/opencode/src/session/rune/capability-error.ts delete mode 100644 packages/opencode/src/session/rune/rune.md delete mode 100644 packages/opencode/src/session/rune/rune.ts delete mode 100644 packages/opencode/src/session/rune/tool-runtime.ts delete mode 100644 packages/opencode/src/session/rune/tool.ts delete mode 100644 packages/opencode/test/session/rune-parity.test.ts diff --git a/bun.lock b/bun.lock index 6d60e7848e91..c8212b618de0 100644 --- a/bun.lock +++ b/bun.lock @@ -138,6 +138,20 @@ "effect", ], }, + "packages/codemode": { + "name": "@opencode-ai/codemode", + "version": "0.0.1", + "dependencies": { + "acorn": "8.15.0", + "effect": "catalog:", + "typescript": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/console/app": { "name": "@opencode-ai/console-app", "version": "1.17.11", @@ -578,6 +592,7 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@opencode-ai/codemode": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -603,7 +618,6 @@ "@standard-schema/spec": "1.0.0", "@types/ws": "8.18.1", "@zip.js/zip.js": "2.7.62", - "acorn": "8.15.0", "ai": "catalog:", "ai-gateway-provider": "3.1.2", "bonjour-service": "1.3.0", @@ -637,7 +651,6 @@ "tree-sitter-bash": "0.25.0", "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", - "typescript": "catalog:", "ulid": "catalog:", "venice-ai-sdk-provider": "2.0.2", "vscode-jsonrpc": "8.2.1", @@ -666,6 +679,7 @@ "@typescript/native-preview": "catalog:", "drizzle-orm": "catalog:", "prettier": "3.6.2", + "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", }, @@ -1924,6 +1938,8 @@ "@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"], + "@opencode-ai/codemode": ["@opencode-ai/codemode@workspace:packages/codemode"], + "@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"], "@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e90732cc1a6a..b5e6d108e5f1 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -47,6 +47,7 @@ "@typescript/native-preview": "catalog:", "drizzle-orm": "catalog:", "prettier": "3.6.2", + "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2" }, @@ -83,6 +84,7 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@opencode-ai/codemode": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -108,7 +110,6 @@ "@standard-schema/spec": "1.0.0", "@types/ws": "8.18.1", "@zip.js/zip.js": "2.7.62", - "acorn": "8.15.0", "ai": "catalog:", "ai-gateway-provider": "3.1.2", "bonjour-service": "1.3.0", @@ -142,7 +143,6 @@ "tree-sitter-bash": "0.25.0", "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", - "typescript": "catalog:", "ulid": "catalog:", "venice-ai-sdk-provider": "2.0.2", "vscode-jsonrpc": "8.2.1", diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 0f5c45939736..b90b69ce45a6 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -1,26 +1,30 @@ import { Tool } from "@/tool/tool" -import { asSchema, type Tool as AITool, type JSONSchema7 } from "ai" +import type { Tool as AITool } from "ai" import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" -import { Effect, Schema } from "effect" -import { Rune } from "./rune/rune" -import type { ExecutionLimits, LogEntry } from "./rune/rune" -import type { HostTools } from "./rune/tool-runtime" +import { Cause, Effect, Schema } from "effect" +import { + CodeMode, + Tool as CodeModeTool, + toolError, + type ExecutionLimits, + type JsonSchema, + type ToolDefinition, +} from "@opencode-ai/codemode" export const CODE_MODE_TOOL = "execute" /** - * Execution limits for the Rune interpreter. `maxDataBytes` is raised well above - * the Rune default (256KB) because code mode forwards base64 media attachments, - * and the timeout matches the default MCP request timeout. + * Execution limits for CodeMode programs. The timeout matches the default MCP + * request timeout; everything else uses the CodeMode defaults. */ const CODE_LIMITS: ExecutionLimits = { - maxDataBytes: 10_000_000, timeoutMs: 30_000, } export const Parameters = Schema.Struct({ code: Schema.String.annotate({ - description: "JavaScript to run. Discover tools with `tools.$rune.search`/`tools.$rune.describe`, call them, and `return` the final value.", + description: + "JavaScript source to execute. Call the available tools with `await tools..(input)`, compose the results, and `return` the final value.", }), }) @@ -34,68 +38,44 @@ type Metadata = { } /** - * A real attachment: identical to a session `FilePart` (minus the ids) and carrying - * the actual bytes (`url`, often a base64 `data:` URL), so it lowers 1:1 into - * `Tool.ExecuteResult.attachments`. This never crosses into the sandbox — the program - * only ever sees the opaque {@link AttachmentHandle}. + * A tool-result attachment: identical to a session `FilePart` (minus the ids) and + * carrying the actual bytes (`url`, often a base64 `data:` URL), so it lowers 1:1 into + * `Tool.ExecuteResult.attachments`. Attachments never enter the sandbox — media stripped + * from child tool results is accumulated host-side and returned on the outer `execute` + * result, where the existing attachment plumbing turns it into visible images/files. */ export type Attachment = NonNullable[number] -/** - * The opaque, model-facing view of an attachment: metadata only, no bytes. A program - * can inspect `mime`/`filename`/`bytes`, propagate the handle (return it to show the - * user) or drop it, but can NOT read or leak the contents — so a stray `return`/log - * can never dump a base64 blob back into the conversation. - */ -export type AttachmentHandle = { type: "file"; id: string; mime: string; filename?: string; bytes?: number } - -/** The envelope every tool call resolves to, and the shape a program should `return`. */ -export type Envelope = { result: unknown; attachments?: AttachmentHandle[] } - -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 = { +/** One MCP tool in the grouped catalog: the flat `server_tool` key split into its + * namespace (`server`) and local name, with the raw JSON Schemas used for rendering. */ +export type CatalogEntry = { path: string key: string server: string local: string description: string tool: AITool - outputSchema?: JSONSchema7 + inputSchema: JsonSchema + outputSchema?: JsonSchema } -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 -} +/** Render-only cast: MCP definitions carry JSON Schema documents already. */ +const toJsonSchema = (schema: unknown): JsonSchema => schema as JsonSchema -function displayInput(input: unknown): Record | undefined { - if (input === null || input === undefined) return - if (typeof input === "object" && !Array.isArray(input)) { - const value = input as Record - if (Object.keys(value).length > 0) return value - return - } - return { input } +/** The input schema for entries without a cached MCP definition, recovered from the + * ai-sdk tool when possible so signatures stay informative. */ +function fallbackInputSchema(tool: AITool): JsonSchema { + const schema = (tool.inputSchema as { jsonSchema?: unknown } | undefined)?.jsonSchema + if (schema && typeof schema === "object") return toJsonSchema(schema) + return { type: "object", properties: {} } } -/** Re-join accessed segments into the flat catalog key (`server_tool`). The - * server/tool split is cosmetic, so `tools.a.b`, `tools["a.b"]`, `a/b`, and `a_b` - * all resolve to the same key — the model never has to guess the separator. */ -const toKey = (segments: readonly string[]) => segments.join("_").replace(/[./]/g, "_") - /** * 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`. `mcpDefs` carries the raw MCP - * definitions (keyed identically) so each entry retains its `outputSchema`. + * definitions (keyed identically) so each entry retains its original + * `inputSchema`/`outputSchema` for signature rendering. */ export function groupByServer( mcpTools: Record, @@ -107,317 +87,30 @@ export function groupByServer( 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 output = mcpDefs[key]?.outputSchema as JSONSchema7 | undefined + const def = mcpDefs[key] const entry: CatalogEntry = { path: `${server}.${local}`, key, server, local, - description: mcpTools[key]!.description ?? "", + description: mcpTools[key]!.description ?? def?.description ?? "", tool: mcpTools[key]!, - outputSchema: output, + inputSchema: def?.inputSchema ? toJsonSchema(def.inputSchema) : fallbackInputSchema(mcpTools[key]!), + ...(def?.outputSchema ? { outputSchema: toJsonSchema(def.outputSchema) } : {}), } groups.set(server, [...(groups.get(server) ?? []), entry]) } return groups } -const access = (segment: string) => (IDENTIFIER.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`) - -/** An object property name, bare when it is a valid identifier, else quoted. */ -const propKey = (name: string) => (IDENTIFIER.test(name) ? name : JSON.stringify(name)) - -/** Join type strings into a union, de-duplicating members and preserving order. An - * empty set of members (e.g. an empty `enum`/`anyOf`) is `never`, never the empty string. */ -const asUnion = (parts: string[]) => { - const unique = [...new Set(parts)] - return unique.length > 0 ? unique.join(" | ") : "never" -} - -/** Resolve a local JSON-Schema `$ref` (`#/$defs/Foo`) against the document root. - * Returns undefined for external or unresolvable refs. */ -function resolveRef(ref: string, root: JSONSchema7 | undefined): JSONSchema7 | undefined { - if (!root || !ref.startsWith("#/")) return undefined - let node: unknown = root - for (const raw of ref.slice(2).split("/")) { - const segment = raw.replace(/~1/g, "/").replace(/~0/g, "~") - if (!node || typeof node !== "object") return undefined - node = (node as Record)[segment] - } - return node && typeof node === "object" ? (node as JSONSchema7) : undefined -} - -const MAX_DEPTH = 8 - -/** Options for {@link renderType}. `root` anchors `$ref` resolution (defaults to the - * top-level schema); `pretty` switches from a single-line type to an indented, - * JSDoc-annotated block used by `describe`. */ -export type RenderOptions = { root?: JSONSchema7; pretty?: boolean } - -/** - * Render a JSON Schema as a TypeScript type string for model-facing signatures. - * Total (never throws — falls back to `any`/`object`) and cycle-safe: local `$ref`s - * are inlined, self-referential ones collapse to the ref name. Compact by default - * (single line, no docs); `pretty` produces an indented block with `/** … *\/` docs - * on described fields. Handles enums, `const`, `anyOf`/`oneOf` unions, `allOf` - * intersections (the common Pydantic `allOf: [{ $ref }]` shape), nullable `type` - * arrays, tuples, and `additionalProperties`. - */ -export function renderType( - def: JSONSchema7 | boolean | undefined, - options: RenderOptions = {}, - depth = 0, - seen: ReadonlySet = new Set(), -): string { - if (!def || typeof def === "boolean") return "any" - // Absolute recursion ceiling. Object/array recursion increments `depth`, and so do - // the union/nullable branches below, so this bounds every recursion path — including - // pure-union structural cycles that the `$ref` `seen` guard cannot see. Keeps the - // "never throws" contract even for pathological (non-JSON-transport) input. - if (depth > MAX_DEPTH) return "any" - const root = options.root ?? def - const opts: RenderOptions = { ...options, root } - - if (typeof def.$ref === "string") { - const target = resolveRef(def.$ref, root) - const name = def.$ref.split("/").pop() || "any" - if (!target) return "any" - if (seen.has(target)) return name // recursive type: reference by name rather than loop - return renderType(target, opts, depth, new Set([...seen, target])) - } - if (Array.isArray(def.enum)) return asUnion(def.enum.map((value) => JSON.stringify(value))) - if (def.const !== undefined) return JSON.stringify(def.const) - - // allOf = intersection. The dominant Pydantic/FastMCP shape is `allOf: [{ $ref }]` - // with a sibling description/default, so a single member renders as just that member; - // any base `properties` on `def` itself are intersected in. - if (Array.isArray(def.allOf) && def.allOf.length > 0) { - const base = def.properties || def.additionalProperties !== undefined ? renderObject(def, opts, depth, seen) : undefined - const members = def.allOf.map((member) => renderType(member as JSONSchema7, opts, depth + 1, seen)) - const parts = [...(base ? [base] : []), ...members].filter((part) => part !== "any") - return parts.length === 0 ? "any" : parts.length === 1 ? parts[0]! : parts.join(" & ") - } - - // Nullable / multi-type: `["string","null"]` -> `string | null` (don't drop members). - if (Array.isArray(def.type)) { - return asUnion(def.type.map((type) => renderType({ ...def, type }, opts, depth + 1, seen))) - } - - switch (def.type) { - case "integer": - return "number" - case "string": - case "number": - case "boolean": - case "null": - return def.type - case "array": { - const items = Array.isArray(def.items) ? def.items[0] : def.items - const inner = renderType(items as JSONSchema7 | undefined, opts, depth + 1, seen) - return /[ |&]/.test(inner) ? `(${inner})[]` : `${inner}[]` - } - } - - if (def.type === "object" || def.properties || def.additionalProperties !== undefined) { - return renderObject(def, opts, depth, seen) - } - - // anyOf / oneOf union — checked after object handling so a base object paired with a - // `require one of` anyOf still renders its properties instead of collapsing to a union. - const union = def.anyOf ?? def.oneOf - if (Array.isArray(union)) return asUnion(union.map((alt) => renderType(alt as JSONSchema7, opts, depth + 1, seen))) - return "any" -} - -/** Schema constraints that a TypeScript type can't express natively but a model - * benefits from, surfaced as JSDoc tags (`@default`, `@format`, `@deprecated`, …). */ -function docTags(schema: JSONSchema7 | boolean | undefined): string[] { - if (!schema || typeof schema === "boolean") return [] - // `deprecated` is a later JSON-Schema draft than the `ai` JSONSchema7 type models. - const s = schema as JSONSchema7 & { deprecated?: boolean } - const tags: string[] = [] - if (s.deprecated === true) tags.push("@deprecated") - if (s.default !== undefined) { - try { - tags.push(`@default ${JSON.stringify(s.default)}`) - } catch { - // unserializable default: skip rather than emit a broken tag - } - } - if (typeof s.format === "string") tags.push(`@format ${s.format}`) - if (typeof s.minItems === "number") tags.push(`@minItems ${s.minItems}`) - if (typeof s.maxItems === "number") tags.push(`@maxItems ${s.maxItems}`) - return tags -} - -/** - * Format a schema `description` plus `tags` as a JSDoc comment at the given indent, - * preserving multi-line text (a single line stays `/** … *\/`; multiple lines become a - * `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and - * blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so - * callers can prepend it directly to the field line. - */ -function jsdoc(description: string | undefined, tags: string[], pad: string): string { - const lines = [...(description ? description.split("\n") : []), ...tags].map((line) => - line.replaceAll("*/", "* /").replace(/\s+$/, ""), - ) - while (lines.length > 0 && lines[0]!.trim() === "") lines.shift() - while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop() - if (lines.length === 0) return "" - if (lines.length === 1) return `${pad}/** ${lines[0]} */\n` - const body = lines.map((line) => `${pad} *${line ? ` ${line}` : ""}`).join("\n") - return `${pad}/**\n${body}\n${pad} */\n` -} - -function renderObject( - def: JSONSchema7, - opts: RenderOptions, - depth: number, - seen: ReadonlySet, -): string { - const props = (def.properties ?? {}) as Record - const names = Object.keys(props) - const additional = def.additionalProperties - const indexType = - additional === true ? "any" : additional && typeof additional === "object" ? renderType(additional, opts, depth + 1, seen) : undefined - if (names.length === 0) return indexType ? `{ [key: string]: ${indexType} }` : "object" - if (depth >= MAX_DEPTH) return "object" - - const required = new Set(Array.isArray(def.required) ? def.required : []) - const field = (name: string) => `${propKey(name)}${required.has(name) ? "" : "?"}: ${renderType(props[name], opts, depth + 1, seen)}` - - if (!opts.pretty) { - const fields = names.map(field) - if (indexType) fields.push(`[key: string]: ${indexType}`) - return `{ ${fields.join("; ")} }` - } - - const pad = " ".repeat(depth + 1) - const lines = names.map((name) => `${jsdoc(props[name]?.description, docTags(props[name]), pad)}${pad}${field(name)}`) - if (indexType) lines.push(`${pad}[key: string]: ${indexType}`) - return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}` -} - -function inputType(tool: AITool): string { - try { - const schema = asSchema(tool.inputSchema).jsonSchema as JSONSchema7 | undefined - if (!schema?.properties || typeof schema.properties !== "object") return "input" - return renderType(schema) - } catch { - return "input" - } -} - -/** The `T` in `Result`: the structured `outputSchema` (when the MCP server declares - * one), else `unknown` — an untyped result has no guaranteed shape and must be inspected, - * not assumed. `Result` itself is defined once in the tool description prose. */ -const resultType = (outputSchema: JSONSchema7 | undefined) => (outputSchema ? renderType(outputSchema) : "unknown") - -/** The full, awaited call type shown by `tools.$rune.describe`. */ -const returnType = (outputSchema: JSONSchema7 | undefined) => `Promise>` - -const signatureFor = (entry: CatalogEntry) => - `tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)}): ${returnType(entry.outputSchema)}` - -/** The directly-callable signature for the inline preview. Unlike the full `describe` - * form it drops the uniform `Promise<…>` wrapper (calls are always awaited) but DOES - * show the awaited `Result` — so the model sees each tool's return shape without a - * discovery round-trip. */ -const previewSignature = (entry: CatalogEntry) => - `tools${access(entry.server)}${access(entry.local)}(input: ${inputType(entry.tool)}): Result<${resultType(entry.outputSchema)}>` - -/** - * Character budget for the inline signature preview in the tool description. All - * namespaces are always listed; per-tool call signatures are previewed (cheapest - * first, server by server) until this many characters are used, after which the - * remaining namespaces show counts only. This front-loads a directly-callable slice - * of the catalog — cutting discovery round-trips — without dumping every signature. - */ -const PREVIEW_BUDGET = 2000 - -/** - * The execute tool description: the calling convention, the discovery API, and the - * list of namespaces. A budgeted preview of per-tool call signatures is inlined; the - * full typed signature + schemas are fetched on demand with `tools.$rune.describe`, - * and any tool not previewed must be found via `tools.$rune.search` first. - */ -export function describe(groups: Map): string { - const lines = [ - "Execute JavaScript with access to connected MCP tools, grouped into namespaces (one per MCP server).", - "", - "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, input, output? }` (types as TypeScript)", - "", - "Call a tool by its path: `await tools..(input)`. Every call — and your final `return` —", - "uses the same envelope: `type Result = { result: T; attachments?: Attachment[] }`. The signatures", - "below (and `tools.$rune.describe`) show each tool's `T` as its return type.", - "", - "`result` (the `T`) is the tool's own payload. It is typed `unknown` unless the server declares an output", - "schema — an `unknown` result has NO guaranteed shape, so inspect it (e.g. `return` it to see it, or read", - "it defensively) before assuming any fields.", - "", - "`attachments` are files a tool produced (an image, a document, …), given to you as references you hold", - "but don't read inline: `type Attachment = { type: 'file'; mime: string; filename?: string; bytes?: number }`.", - "To actually SEE a file — e.g. look at a screenshot before deciding your next step — include it in what you", - "`return` (e.g. `return { result: summary, attachments: shot.attachments }`): returned attachments come back", - "into the conversation as real viewable images/files, so both YOU (on your next turn) and the user can see", - "them. Omit an attachment to discard it. You route whole attachment handles; you don't read their raw bytes.", - "", - "Only what you `return` re-enters the conversation — `result` becomes text; everything else in the sandbox", - "stays there. Compose multiple calls in one program and `return` the final value. 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") - } - // Select which signatures fit the budget (cheapest first within each server, - // servers alphabetical) before emitting, so the list can state exactly how - // comprehensive it is — overall and per namespace. - const ordered = [...groups].sort(([a], [b]) => a.localeCompare(b)) - const shown = new Map() - let used = 0 - let budgetLeft = true - let totalTools = 0 - let totalShown = 0 - for (const [server, entries] of ordered) { - totalTools += entries.length - const picked: string[] = [] - if (budgetLeft) { - for (const entry of entries) { - const line = ` - ${previewSignature(entry)}` - if (used + line.length > PREVIEW_BUDGET) { - budgetLeft = false - break - } - picked.push(line) - used += line.length - } - } - shown.set(server, picked) - totalShown += picked.length - } - - const complete = totalShown === totalTools - lines.push( - "", - complete - ? "This is the COMPLETE list of available tools — every connected tool is shown below with its call signature. Use `tools.$rune.describe(path)` for a tool's full types." - : `This is a PARTIAL list — ${totalShown} of ${totalTools} tools are shown below. Any tool not listed must be found with \`tools.$rune.search\` first; use \`tools.$rune.describe(path)\` for full types.`, - ) - for (const [server, entries] of ordered) { - const picked = shown.get(server)! - const total = entries.length - const count = `${total} tool${total === 1 ? "" : "s"}` - // Annotate only when a namespace is not fully shown, so a comprehensive - // namespace reads cleanly and a truncated one is unambiguous. - const label = - picked.length === total ? count : picked.length === 0 ? `${count}, none shown` : `${count}, ${picked.length} shown` - lines.push(`- ${server} (${label})`) - for (const line of picked) lines.push(line) +function displayInput(input: unknown): Record | undefined { + if (input === null || input === undefined) return + if (typeof input === "object" && !Array.isArray(input)) { + const value = input as Record + if (Object.keys(value).length > 0) return value + return } - return lines.join("\n") + return { input } } const lastSegment = (uri: string) => { @@ -428,71 +121,32 @@ const lastSegment = (uri: string) => { const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}` -/** Decoded byte length of a `data:` URL's base64 payload, or undefined for a - * non-data URL (e.g. an external `resource_link`) whose size we don't know. */ -function dataUrlBytes(url: string): number | undefined { - if (!url.startsWith("data:")) return undefined - const comma = url.indexOf(",") - if (comma === -1) return undefined - const base64 = url.slice(comma + 1) - if (base64.length === 0) return 0 - const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0 - return Math.max(0, Math.floor((base64.length * 3) / 4) - padding) -} - -/** Functions for converting between real attachments and the opaque handles the - * sandbox sees. See {@link attachmentTable}. */ -export type AttachmentTable = { - /** Register a real attachment, returning the opaque handle to hand to the program. */ - seal: (attachment: Attachment) => AttachmentHandle - /** Resolve a handle the program returned back to its real attachment, or undefined - * if it isn't one this table issued (a fabricated or stale handle is dropped). */ - resolve: (handle: unknown) => Attachment | undefined -} - -/** - * A per-execution table that keeps real attachment bytes host-side and only ever - * exposes opaque handles to the sandbox. The bytes never enter the program's context, - * so a program cannot read or accidentally re-emit them; on `return`, a propagated - * handle is looked up here to recover the real attachment for the user. - */ -export function attachmentTable(): AttachmentTable { - const real = new Map() - let seq = 0 - return { - seal(attachment) { - const id = `att_${++seq}` - real.set(id, attachment) - const bytes = dataUrlBytes(attachment.url) - return { - type: "file", - id, - mime: attachment.mime, - ...(attachment.filename ? { filename: attachment.filename } : {}), - ...(bytes !== undefined ? { bytes } : {}), - } - }, - resolve(handle) { - if (!handle || typeof handle !== "object") return undefined - const id = (handle as Record).id - return typeof id === "string" ? real.get(id) : undefined - }, - } +/** The stand-in payload for a media-only tool result, so the program knows the call + * succeeded even though the media itself never enters the sandbox. */ +const mediaMarker = (files: number, images: number) => { + const noun = files === images ? "image" : "file" + return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]` } /** - * Reduce an MCP tool result to the `{ result, attachments? }` envelope. `result` - * is the structured content (or joined text); media blocks (image/audio/resource) - * become opaque attachment handles via `seal` (the bytes stay host-side). Lenient — - * never throws on unexpected shapes. + * Reduce a raw MCP tool result to the value the sandbox sees. Structured content is + * preferred; otherwise text blocks are joined. Media blocks (image/audio/resource + * blob/resource_link) NEVER enter the sandbox: they are stripped into `collect`, the + * per-execution attachment accumulator, and a tool that returned ONLY media yields a + * small text marker instead. Lenient — never throws on unexpected shapes. */ -export function toEnvelope(result: unknown, seal: AttachmentTable["seal"]): Envelope { - if (result === null || typeof result !== "object") return { result } - const record = result as { structuredContent?: unknown; content?: unknown } - const attachments: AttachmentHandle[] = [] - const push = (attachment: Attachment) => attachments.push(seal(attachment)) - const text: string[] = [] +export function toSandboxResult(raw: unknown, collect: (attachment: Attachment) => void): unknown { + if (raw === null || typeof raw !== "object") return raw + const record = raw as { structuredContent?: unknown; content?: unknown } const content = Array.isArray(record.content) ? record.content : [] + const text: string[] = [] + let files = 0 + let images = 0 + const push = (attachment: Attachment) => { + files += 1 + if (attachment.mime.startsWith("image/")) images += 1 + collect(attachment) + } for (const item of content) { if (!item || typeof item !== "object") continue const block = item as Record @@ -532,27 +186,22 @@ export function toEnvelope(result: unknown, seal: AttachmentTable["seal"]): Enve } } - const value = - record.structuredContent !== undefined && record.structuredContent !== null - ? record.structuredContent - : text.length > 0 - ? text.join("\n") - : content.length > 0 - ? undefined // media-only result - : result - - return attachments.length > 0 ? { result: value, attachments } : { result: value } + if (record.structuredContent !== undefined && record.structuredContent !== null) return record.structuredContent + if (text.length > 0) return text.join("\n") + if (files > 0) return mediaMarker(files, images) + if (Array.isArray(record.content)) return null // MCP-shaped result with nothing extractable + return raw } /** * Append captured `console.*` output to the model-facing text as a trailing `Logs:` section, - * so a program's diagnostics ride back alongside its result (and errors). Each line is - * `[level] message`; returns the text unchanged when nothing was logged. This is the sandbox's - * only stdout-like channel — it goes to the model, not the user. + * so a program's diagnostics ride back alongside its result — on success AND on error. + * Returns the text unchanged when nothing was logged. This is the sandbox's only + * stdout-like channel — it goes to the model, not the user. */ -export function withLogs(output: string, logs: ReadonlyArray): string { +export function withLogs(output: string, logs: ReadonlyArray = []): string { if (logs.length === 0) return output - const section = "Logs:\n" + logs.map((entry) => `[${entry.level}] ${entry.message}`).join("\n") + const section = "Logs:\n" + logs.join("\n") return output.length > 0 ? `${output}\n\n${section}` : section } @@ -567,201 +216,73 @@ export function formatValue(value: unknown): string { } } -/** - * Lower the program's return value into model-facing output + attachments. The value - * is treated as a `{ result, attachments? }` envelope when it has a `result` key; - * otherwise the whole value is the result. Attachments are model-curated: each returned - * handle is resolved back to its real bytes via `resolve`; anything that isn't a handle - * this run issued is dropped. - */ -export function fromReturn( - value: unknown, - resolve: AttachmentTable["resolve"], -): { output: string; attachments?: Attachment[] } { - if (value !== null && typeof value === "object" && "result" in value) { - const env = value as { result: unknown; attachments?: unknown } - const attachments = Array.isArray(env.attachments) - ? env.attachments.map(resolve).filter((a): a is Attachment => a !== undefined) - : [] - return attachments.length > 0 - ? { output: formatValue(env.result), attachments } - : { output: formatValue(env.result) } - } - return { output: formatValue(value) } -} - -/** A search-indexed catalog entry: the fields ranking matches against, with - * `searchText` (path + description + parameter names/descriptions) precomputed. */ -export type SearchEntry = { path: string; server: string; description: string; searchText: string } +type Run = (input: unknown) => Effect.Effect -/** The lowercased searchable text for a tool: its path, description, and the name - * (and description, when present) of each input parameter. */ -function searchTextFor(entry: CatalogEntry): string { - const parts = [entry.path, entry.description] - try { - const schema = asSchema(entry.tool.inputSchema).jsonSchema as JSONSchema7 | undefined - const props = schema?.properties - if (props && typeof props === "object") { - for (const [name, value] of Object.entries(props)) { - parts.push(name) - const desc = (value as JSONSchema7 | undefined)?.description - if (typeof desc === "string") parts.push(desc) - } - } - } catch { - // fall back to path + description only - } - return parts.join("\n").toLowerCase() -} - -/** - * Split a query into lowercased search terms. camelCase boundaries are split - * (`resolveLibrary` -> `resolve library`) and `_ - . /` are treated as separators, - * so `resolve-library-id`, `resolveLibraryId`, and `resolve library id` all tokenize - * alike. Empties and the `*` wildcard are dropped. - */ -const tokenize = (query: string) => - query - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .toLowerCase() - .split(/[^a-z0-9]+/) - .filter((term) => term.length > 0 && term !== "*") - -/** - * Rank catalog entries against a query using tokenized, field-weighted scoring - * (adapted from the deferred-tool-search bridge). Each term contributes per field: - * exact tool name (20) > path substring (8) > description (4) > any searchable text (2), - * summed across terms. Because paths are `server.tool`, the exact tier matches a - * whole path segment (e.g. the term `search` matches `github.search`). An empty - * query lists everything (alphabetical). Results are ranked by score, tie-broken by path. - */ -export function rankTools( - entries: ReadonlyArray, - query: string, - namespace?: string, - limit = 25, -): { items: { path: string; description: string }[]; total: number } { - const terms = tokenize(query) - const scoped = namespace ? entries.filter((entry) => entry.server === namespace) : entries - const ranked = scoped - .map((entry) => { - const path = entry.path.toLowerCase() - const description = entry.description.toLowerCase() - const score = terms.reduce( - (total, term) => - total + - (path === term || path.endsWith(`.${term}`) ? 20 : 0) + - (path.includes(term) ? 8 : 0) + - (description.includes(term) ? 4 : 0) + - (entry.searchText.includes(term) ? 2 : 0), - 0, - ) - return { entry, score } +/** Build the `tools..` tree CodeMode executes against, one + * `Tool.make` definition per MCP tool with its render-only JSON Schemas. */ +function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) => Run) { + const tree: Record> = {} + for (const entry of catalog) { + const namespace = (tree[entry.server] ??= {}) + namespace[entry.local] = CodeModeTool.make({ + description: entry.description, + input: entry.inputSchema, + output: entry.outputSchema, + run: run(entry), }) - .filter((item) => terms.length === 0 || item.score > 0) - .sort((a, b) => b.score - a.score || a.entry.path.localeCompare(b.entry.path)) - return { - items: ranked.slice(0, limit).map(({ entry }) => ({ path: entry.path, description: brief(entry.description) })), - total: ranked.length, } + return tree } +/** Per-child permission gate: every MCP call inside a program asks before it runs — + * approving `execute` does not approve any child call. Denials become safe, + * catchable tool failures inside the program. */ +const askPermission = (ctx: Tool.Context, entry: CatalogEntry) => + ctx.ask({ permission: entry.key, metadata: {}, patterns: ["*"], always: ["*"] }).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt + const error = Cause.squash(cause) + return Effect.fail(toolError(error instanceof Error ? error.message : String(error), error)) + }), + ) + export function define( mcpTools: Record, mcpDefs: Record, servers: readonly string[], ) { const groups = groupByServer(mcpTools, servers, mcpDefs) - const catalog: CatalogEntry[] = [...groups.values()].flat() - const byKey = new Map(catalog.map((entry) => [entry.key, entry] as const)) - const index: SearchEntry[] = catalog.map((entry) => ({ - path: entry.path, - server: entry.server, - description: entry.description, - searchText: searchTextFor(entry), - })) - - const search = (query: unknown, options: unknown) => { - const q = typeof query === "string" ? query : "" - 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 - return rankTools(index, q, namespace, limit) - } - - 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) { - // Fuzzy "did you mean": rank the leaf name within its namespace, then fall - // back to a global search. Split only on namespace separators (`. _ /`) so a - // hyphenated tool name (e.g. `resolve-library-id`) stays one searchable leaf. - const segments = path.split(/[._/]+/).filter((s) => s.length > 0) - const leaf = segments.at(-1) ?? path - const namespace = segments.length > 1 ? segments[0] : undefined - const scoped = namespace ? rankTools(index, leaf, namespace, 5).items : [] - const suggestions = (scoped.length > 0 ? scoped : rankTools(index, leaf, undefined, 5).items).map((i) => i.path) - return { error: { code: "tool_not_found", message: `No tool at '${path}'.`, suggestions } } - } - // Everything the model sees is TypeScript: `signature` is the compact one-line - // call form; `input`/`output` are the detailed types (multi-line, with JSDoc for - // any described fields and literal unions for enums) that raw JSON Schema used to - // carry. `output` is present only when the server declares an outputSchema. - let input = "unknown" - try { - const schema = asSchema(entry.tool.inputSchema).jsonSchema as JSONSchema7 | undefined - input = renderType(schema, { pretty: true }) - } catch { - input = "unknown" - } - return { - path: entry.path, - description: entry.description, - signature: signatureFor(entry), - input, - ...(entry.outputSchema ? { output: renderType(entry.outputSchema, { pretty: true }) } : {}), - } - } + const catalog = [...groups.values()].flat().filter((entry) => entry.tool.execute !== undefined) + // The agent-facing description is the CodeMode instructions for this tool tree + // (syntax guide + tool signatures, or the namespace overview + search for large + // catalogs). The preview tree's runs are placeholders — rendering never invokes them. + const description = CodeMode.make({ + tools: toolTree(catalog, () => () => Effect.fail(toolError("Tool preview is not executable."))), + }).instructions() return Tool.define( CODE_MODE_TOOL, Effect.succeed>({ - description: describe(groups), + description, parameters: Parameters, execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { const calls: CallEntry[] = [] - // Real attachment bytes stay in this table for the life of the call; the sandbox - // only ever handles opaque references to them (see attachmentTable). - const files = attachmentTable() + // Media stripped from child tool results accumulates here for the life of the + // call; the bytes never enter the sandbox (see toSandboxResult). + const attachments: Attachment[] = [] + const collect = (attachment: Attachment) => void attachments.push(attachment) // Stream the current call list to the UI. Sent on every status change so the // tool part shows each child call appearing and resolving while the program runs. - const publish = (error?: boolean) => - ctx.metadata({ title: "execute", metadata: { toolCalls: calls.map((c) => ({ ...c })), ...(error ? { error } : {}) } }) - const mark = (index: number, status: CallEntry["status"]) => - Effect.suspend(() => { - calls[index] = { ...calls[index]!, status } - return publish() - }) - const tracked = (tool: string, input: unknown, effect: Effect.Effect) => - Effect.gen(function* () { - const index = calls.length - const childInput = displayInput(input) - calls.push({ tool, status: "running", ...(childInput ? { input: childInput } : {}) }) - yield* publish() - return yield* effect.pipe( - Effect.tap(() => mark(index, "completed")), - Effect.tapError(() => mark(index, "error")), - ) - }) + const publish = () => ctx.metadata({ title: "execute", metadata: { toolCalls: calls.map((c) => ({ ...c })) } }) - // One host function per MCP tool: gate on permission, dispatch to the native - // MCP tool, and coerce the result into the { result, attachments? } envelope. - // A failure (e.g. an MCP isError) fails the Effect, which the interpreter - // surfaces as a catchable in-program error. + // One CodeMode tool per MCP tool: gate on permission, dispatch through the + // ai-sdk wrapper (which owns callTool timeouts/progress and turns an MCP + // isError into a thrown Error), and shape the raw result for the sandbox. + // Failures surface as safe, catchable in-program errors via toolError. const callTool = (entry: CatalogEntry) => (input: unknown) => Effect.gen(function* () { - yield* ctx.ask({ permission: entry.key, metadata: {}, patterns: ["*"], always: ["*"] }) - return yield* tracked(entry.path, input, Effect.tryPromise({ + yield* askPermission(ctx, entry) + const raw = yield* Effect.tryPromise({ try: () => Promise.resolve( entry.tool.execute!(input ?? {}, { @@ -770,61 +291,48 @@ export function define( messages: [], }), ), - catch: (error) => (error instanceof Error ? error : new Error(String(error))), - }).pipe( - Effect.map((raw) => toEnvelope(raw, files.seal)), - )) + catch: (error) => toolError(error instanceof Error ? error.message : String(error), error), + }) + return toSandboxResult(raw, collect) }) - // The Rune host-tool tree: per-server namespaces (`tools..`) - // 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 = { - [RUNE_NS]: { - [SEARCH]: (query: unknown, options: unknown) => - tracked( - "$rune.search", - { query, ...(typeof options === "object" && options !== null && !Array.isArray(options) ? options : {}) }, - Effect.succeed(search(query, options)), - ), - [DESCRIBE]: (path: unknown) => tracked("$rune.describe", { path }, Effect.succeed(describeTool(path))), - }, - } - for (const entry of catalog) { - if (!entry.tool.execute) continue - let namespace = tools[entry.server] as HostTools | undefined - if (!namespace) { - namespace = {} - tools[entry.server] = namespace - } - namespace[entry.local] = callTool(entry) - } - - const result = yield* Rune.execute({ - code: params.code, - tools: tools as unknown as Record, + const runtime = CodeMode.make({ + tools: toolTree(catalog, callTool), limits: CODE_LIMITS, + onToolCallStart: ({ index, name, input }) => + Effect.suspend(() => { + const shown = displayInput(input) + calls[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) } + return publish() + }), + onToolCallEnd: ({ index, outcome }) => + Effect.suspend(() => { + const current = calls[index] + if (current) calls[index] = { ...current, status: outcome === "success" ? "completed" : "error" } + return publish() + }), }) + const result = yield* runtime.execute(params.code) + const logs = result.logs ?? [] + const attached = attachments.length > 0 ? { attachments } : {} + if (result.ok) { - const { output, attachments } = fromReturn(result.value, files.resolve) return { title: "execute", metadata: { toolCalls: calls }, - output: withLogs(output, result.logs), - ...(attachments && attachments.length > 0 ? { attachments } : {}), + output: withLogs(formatValue(result.value), logs), + ...attached, } satisfies Tool.ExecuteResult } - // Point the model at discovery when it references a tool that does not exist. - const hint = - result.error.kind === "UnknownCapability" - ? "\nUse tools.$rune.search(query) to discover available tools." - : "" + // Diagnostics may carry suggestions (e.g. pointing an unknown tool at + // discovery); append the ones the message doesn't already contain. + const hints = (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint)) return { title: "execute", metadata: { toolCalls: calls, error: true }, - output: withLogs(result.error.message + hint, result.logs), + output: withLogs([result.error.message, ...hints].join("\n"), logs), + ...attached, } satisfies Tool.ExecuteResult }), }), diff --git a/packages/opencode/src/session/rune/capability-error.ts b/packages/opencode/src/session/rune/capability-error.ts deleted file mode 100644 index 2951dcef400b..000000000000 --- a/packages/opencode/src/session/rune/capability-error.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Schema } from "effect" - -/** Safe operational refusal from a standard capability pack, reported as `CapabilityFailure`. */ -export class CapabilityError extends Schema.TaggedErrorClass()("CapabilityError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) {} - -export const capabilityError = (message: string, cause?: unknown): CapabilityError => - new CapabilityError({ message, ...(cause === undefined ? {} : { cause }) }) diff --git a/packages/opencode/src/session/rune/rune.md b/packages/opencode/src/session/rune/rune.md deleted file mode 100644 index 75958bf9cfb5..000000000000 --- a/packages/opencode/src/session/rune/rune.md +++ /dev/null @@ -1,243 +0,0 @@ -# Rune - -A sandboxed JavaScript interpreter. It runs untrusted model-authored code that calls -host-provided tools and transforms plain JSON. It is **not** a JS engine: it walks an -AST and hand-implements an allowlisted subset of the language. Anything not explicitly -implemented throws. - -## How it works - -### Pipeline (`Rune.execute`) -1. **Wrap** the source in `async function __rune__() { ... }`. -2. **Transpile** via the TypeScript compiler (`transpileModule`, target ESNext) — strips - types only. Runtime-agnostic (no `Bun.Transpiler`, no Node `module` APIs). -3. **Parse** the stripped body with `acorn` → ESTree AST. (Acorn is a parser only: it - provides zero runtime/stdlib.) -4. **Interpret**: a tree-walking evaluator (`Interpreter`) executes the AST as an - `Effect`. Host tool calls are async; everything else is synchronous evaluation. - -### Values -- Only **Data Values** cross boundaries: `null`, `undefined`, `boolean`, `number`, - `string`, and null-prototype objects/arrays of Data Values. -- `copyIn`/`copyOut` deep-clone at every boundary (tool args, tool results, return). - Runtime references (functions, `tools`) never leak into data. -- Objects are null-prototype (no inherited `Object` methods, no prototype pollution). - -### Tools -- The host passes a tree of functions under `tools`. The program calls - `await tools.(...)`; the interpreter resolves the path and invokes the host - function, copying args in and the result back. -- Unknown path → `UnknownCapability`. Host failures surface as catchable in-program - errors. - -### Result -`{ ok: true, value, toolCalls, logs }` or `{ ok: false, error: { kind, message, location?, -suggestions? }, toolCalls, logs }`. `value` is the program's `return`. Uncaught `throw` -becomes `ok: false`. `logs` is the captured `console.*` output (see below), present on -every path — including timeout and failure — so logs emitted before a crash survive. - -### Limits (`ExecutionLimits`, enforced; defaults) -| Limit | Default | Bounds | -|---|---|---| -| `maxOperations` | 100,000 | interpreter steps (kills infinite loops) | -| `timeoutMs` | 10,000 | wall-clock | -| `maxToolCalls` | 100 | tool invocations | -| `maxConcurrency` | 8 | in-flight `Promise.all` calls | -| `maxSourceBytes` | 32,000 | source size | -| `maxDataBytes` | 256,000 | any single data value (args/result/assignment/return) | -| `maxAuditBytes` | 1,000,000 | cumulative tool-call audit trail | -| `maxValueDepth` | 32 | nesting depth of a data value | -| `maxCollectionLength` | 10,000 | elements/keys in one array/object | - -Bytes are measured ~`JSON.stringify(value).byteLength`. `maxDataBytes` is per-value, -not cumulative. (Code mode overrides `maxDataBytes`→10MB, `timeoutMs`→30s.) - -## Standard library (allowlist — everything else throws) - -- **Globals**: `tools`, `console`, `Promise`, `Object`, `Math`, `JSON`, `Number`, `String`, - `Boolean`, `Array`, `parseInt`, `parseFloat`, `undefined`. -- **console**: `log`/`warn`/`error`/`info`/`debug`. Each formats its args (strings verbatim, - objects/arrays as JSON, space-joined) and appends a line to `logs`; it returns `undefined`, - is **not** a tool call (spends no tool-call budget), and any other member throws. Formatting - is charged to `maxOperations`, and total captured output is bounded by `maxAuditBytes`. -- **String**: case/trim, split, slice/substring/substr, includes/startsWith/endsWith, - indexOf/lastIndexOf, replace/replaceAll, repeat, padStart/padEnd, charAt/at, - charCodeAt/codePointAt, concat. **String args only.** -- **Array**: map/filter/reduce/reduceRight/forEach/find/findIndex/some/every/sort/ - toSorted/slice/concat/flat/flatMap/reverse/toReversed/with/join/includes/indexOf/at + - push/pop/shift/unshift. -- **Object**: keys/values/entries/hasOwn/assign/fromEntries. -- **Math**: max/min/abs/floor/ceil/round/trunc/sign/sqrt/cbrt/pow/hypot/log/log2/log10/ - exp, PI, E. -- **Number**: isInteger/isFinite/isNaN/isSafeInteger/parseInt/parseFloat; instance - toFixed/toExponential/toPrecision/toString(radix). -- **JSON**: parse, stringify (no replacer). -- **Syntax**: arrow + `function` (hoisted), closures, default/rest params, destructuring, - spread, optional chaining, template literals, conditionals, switch, loops, `for...of`, - try/catch, ternary, `in`, logical assignment, bitwise ops, `await`, `Promise.all`. - -## JavaScript semantics (parity with JS, so defensive code doesn't crash) - -Reading a value behaves like JS even when a key/name is absent — it yields `undefined` rather -than throwing, so idiomatic defensive code (`a?.b ?? c`, feature detection, merges) works: - -- **Unknown property reads yield `undefined`.** `"s".foo`, `(5).foo`, `[1,2].foo`, and - `obj.missing` are all `undefined` — including under `?.` (which only guards `null`/`undefined` - receivers). MCP results are often JSON *strings*, so `result?.field ?? result` reads the field - when present and falls back to the raw string otherwise instead of crashing. (Only the method - *allowlist* still errors — e.g. calling an unsupported `arr.splice(...)` gives a rewrite hint.) -- **`typeof undeclaredIdentifier` is `"undefined"`**, never a reference error, so - `typeof x !== "undefined"` guards are safe. (A bare `x` reference still throws.) -- **`{ ...null }` / `{ ...undefined }` is a no-op**, so `{ ...maybeOpts, override }` merges work - when the operand is absent. -- **Builtin coercions are valid array callbacks**: `filter(Boolean)`, `map(String)`, `map(Number)`. -- **`NaN`/`Infinity` flow as ordinary values** (and are bindable identifiers), so `Number(x)` / - `parseInt(x) || 0` / averages / counters don't crash mid-expression — guards like - `Number.isNaN(x)` get to run. A non-finite number is normalized to `null` only when it crosses - out of the sandbox (final `return` or a tool argument), exactly as `JSON.stringify` would. - -## What is missing - -- **`Date`** — no dates or time. -- **`RegExp` / regex literals** — none. `replace`/`split` take plain strings only. -- **`Map` / `Set` / `WeakMap` / `WeakSet`** — none. -- **`Promise`** — only `Promise.all`. No `new Promise`, `race`, `allSettled`, `resolve`, - `reject`. -- **`new`** — only the `Error` family (`Error`, `TypeError`, `RangeError`, `SyntaxError`, - `ReferenceError`, `EvalError`, `URIError`), and they yield plain `{ name, message }` - data objects, not real `Error` instances. -- **Classes, generators, `async function*`, `for await...of`, labeled break/continue** — - rejected as `UnsupportedSyntax`. -- **`Symbol`, `BigInt`** — none. -- **Partial built-ins** — e.g. `JSON.stringify` ignores replacers; `Array.from` takes no - map fn. (`NaN`/`Infinity` are usable in-sandbox but serialize to `null` at the boundary — see - JavaScript semantics above.) -- **No I/O** — no fetch, fs, timers, env, or any ambient capability. The only outside - contact is host `tools`. - -# Code mode (discovery layer) - -Code mode (`session/code-mode.ts`) is a *consumer* of Rune, not part of the interpreter. It -exposes connected MCP tools to the program as `tools..(input)` and adds its own -discovery capabilities under `tools.$rune.*`. The design deliberately tolerates the mistakes -weaker models commonly make rather than punishing them. The trade-offs below are intentional. - -## tools.$rune.search(query, { namespace?, limit? }) - -Ranked, in-memory search over the tool catalog, recomputed per call (no persistent index). -Returns `{ items: [{ path, description }], total }`. - -- **Weighted, tokenized scoring** (`rankTools`): per query term, exact path segment (20) > - path substring (8) > description (4) > any indexed text (2), summed across terms. The - indexed text is the path, the description, and each input parameter's name + description — - so a query can match a tool by one of its argument names. -- **camelCase / separator-resistant tokenizer** (`tokenize`): splits camelCase boundaries and - treats `_ - . /` as separators, so `library`, `resolveLibraryId`, and `resolve-library-id` - all tokenize alike. Models phrase queries inconsistently; this keeps recall up without the - query having to match a tool's exact casing or punctuation. -- **Namespace scoping**: optional `namespace` filters to one server. An empty query (or bare - `*`) lists everything alphabetically. `limit` caps `items` (default 25); `total` always - reports the full match count so the model knows when results were truncated. - -## tools.$rune.describe(path) - -Returns `{ path, description, signature, input, output? }` for one tool. **Everything is -TypeScript — no raw JSON Schema is ever surfaced to the model.** - -- **Compact signature + detailed TS types.** `signature` is the one-line call form, e.g. - `tools.github.create_issue(input: { title: string; body?: string }): Promise>`, - where `type Result = { result: T; attachments?: Attachment[] }` is defined once in the tool - description prose so signatures stay short. - `input` (and `output`, when the server declares an `outputSchema`) are the *detailed* types - rendered by `renderType` in pretty mode: an indented block with `/** … */` JSDoc on described - fields and literal unions for enums. This carries everything the raw JSON Schema used to — - parameter docs, enums, required-ness — now expressed as TypeScript rather than a schema blob. - A field's `description` becomes a JSDoc comment (single-line `/** … */`, or a multi-line - block, preserved as written) because a bare type has nowhere else to hold it; `*/` in a - description is neutralized so it cannot close the comment early. `describe` is on-demand, so - keeping the docs costs nothing on the hot path. -- **`renderType` is a total, cycle-safe JSON-Schema → TS renderer.** Local `$ref`s - (`#/$defs/…`) are resolved against the document root; a self-referential ref collapses to its - name rather than looping. It renders enums/`const` as literals, `anyOf`/`oneOf` and nullable - `type` arrays (`["string","null"]` → `string | null`) as unions, `allOf` as an intersection - (unwrapping the common Pydantic `allOf: [{ $ref }]` shape), tuples, and - `additionalProperties` as an index signature. It never throws — unknown shapes fall back to - `any`/`object`, and depth is capped. (Rune's own Effect-schema renderer in `rune/tool.ts`, - `renderSchema`, is separate and has known gaps — no `$ref` cycle guard, and unions containing - a number collapse to `number`; code mode does not use it.) -- **Return type shown ahead of the call — in the preview too.** Every tool resolves to - `Result`, where `T` is the structured `outputSchema` when the server declares one, else - `unknown`. The `T` is surfaced not just by `describe` but in the budgeted inline preview in the - tool description (`tools.x.y(input: …): Result`), so the model sees a tool's result shape - without a discovery round-trip. `unknown` is deliberate — an untyped result has no guaranteed - shape (many MCP servers return plain text), so the prose directs the model to inspect it (e.g. - `return` it) before assuming fields, rather than pretending a shape we can't verify. - -## Attachments are opaque handles — bytes never enter the sandbox - -A tool's media (image/audio/resource content blocks) becomes an `attachments` array on the -result envelope, but the program only ever sees an **opaque handle**, not the bytes: -`type Attachment = { type: 'file'; id: string; mime: string; filename?: string; bytes?: number }`. -The real bytes (a base64 `data:` URL) are kept host-side in a per-execution `attachmentTable` -keyed by `id` (`code-mode.ts`); the handle carries only metadata. The program can inspect -`mime`/`bytes`, **propagate** a handle (return it under `attachments` to show the user), or -**drop** it — but it cannot read or re-emit the contents. On `return`, each propagated handle is -resolved back to its real attachment via the table; a fabricated or stale handle resolves to -nothing and is dropped. - -This is a deliberate divergence from the prior art we studied (both expose the base64 directly -and lean on prompt guidance plus output truncation). Making the handle opaque means a careless -`return`/log **cannot** dump a base64 blob back into the conversation — the leak is structurally -impossible rather than merely discouraged. The trade-off: a program can no longer read attachment -bytes to route them into another tool's input; if that need arises it would be an explicit host -call (e.g. a `readAttachment(handle)`), not the always-on default. Not implemented yet. - -## console output is surfaced to the model as a trailing `Logs:` section - -The sandbox has no stdout, but `console.log`/`warn`/`error`/`info`/`debug` are available (an -interpreter builtin, not a tool). Code mode reads the run's `logs` and, when non-empty, appends -them to the model-facing text as a trailing section — one `[level] message` line each: - -``` - - -Logs: -[log] resolved 3 candidates -[warn] falling back to first match -``` - -This holds on the error path too, so a program's diagnostics ride back with the failure that -followed them. Logs go to the **model only** (not the user) — they are the program's scratch -channel for narrating what it did, distinct from the `return` value (the answer) and returned -`attachments` (media for the model + user). A program that logs nothing gets no section. - -## Path handling — separator-tolerant - -The flat catalog key is `server_tool`, but the model is never required to guess the -separator. `toKey` normalizes `. / _` to the same key, so `tools.context7.resolve-library-id`, -`describe('context7/resolve-library-id')`, and `describe('context7_resolve-library-id')` all -resolve to the same tool. A slash-vs-dot mismatch previously made `describe` silently miss -(returning a soft error the model then destructured into `{}`); normalizing the separator -removes that whole failure mode. - -## Tool-call errors throw; discovery errors are soft - -A **tool call** that fails (an MCP `isError`, a transport failure, or a call to a path that -doesn't exist) throws inside the program, so the model uses ordinary `try`/`catch` — the same -control flow it already writes for any async call. We deliberately do *not* wrap results in an -`{ ok, error }` value: a uniform success envelope (`{ result, attachments? }`) plus normal -exceptions is simpler to reason about and to type than forcing every call site to branch on a -discriminated union. An uncaught tool error fails the whole `execute` run and is reported back. - -The **discovery helpers** are the exception — see below. - -## Discovery errors are soft, with "did you mean" — never thrown - -`tools.$rune.search` and `tools.$rune.describe` never throw. An unknown `describe(path)` -returns `{ error: { code: 'tool_not_found', message, suggestions } }`. Suggestions come from a -fuzzy fallback: rank the *leaf* name within its namespace, then fall back to a global search, -returning real callable paths (e.g. `context7/resolve-library` → suggests -`context7.resolve-library-id`). This avoids derailing a whole program over one typo — the model -branches on `result.error` and retries with a suggestion. (Tool -*calls* on a genuinely unknown path still surface as a catchable in-program error via Rune's -`UnknownCapability`; only the discovery helpers return soft errors.) diff --git a/packages/opencode/src/session/rune/rune.ts b/packages/opencode/src/session/rune/rune.ts deleted file mode 100644 index 655da452f3f1..000000000000 --- a/packages/opencode/src/session/rune/rune.ts +++ /dev/null @@ -1,2888 +0,0 @@ -import { parse } from "acorn" -import { Cause, Effect, Schema } from "effect" -import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" -import { - copyIn, - copyOut, - dataByteLength, - isBlockedMember, - ToolReference, - ToolRuntime, - ToolRuntimeError, - type HostTools, - type SafeObject, - type ToolCall, - type ToolDescription, - type Services, -} from "./tool-runtime.js" -import type { Definition } from "./tool.js" -import { CapabilityError } from "./capability-error.js" - -export type { ToolCall, ToolDescription } from "./tool-runtime.js" -export { Tool } from "./tool.js" -export { CapabilityError } from "./capability-error.js" - -/** Resource budgets enforced during each Rune Program execution. */ -export type ExecutionLimits = { - readonly maxOperations?: number - readonly maxToolCalls?: number - readonly maxConcurrency?: number - readonly maxSourceBytes?: number - readonly maxDataBytes?: number - readonly maxAuditBytes?: number - readonly maxValueDepth?: number - readonly maxCollectionLength?: number - readonly timeoutMs?: number -} - -type CapabilityTree = { - readonly [name: string]: Definition | CapabilityTree -} - -type ResolvedExecutionLimits = { - readonly maxOperations: number - readonly maxToolCalls: number - readonly maxConcurrency: number - readonly maxSourceBytes: number - readonly maxDataBytes: number - readonly maxAuditBytes: number - readonly maxValueDepth: number - readonly maxCollectionLength: number - readonly timeoutMs: number -} - -export type ExecuteOptions = {}> = { - code: string - tools?: Tools & CapabilityTree - limits?: ExecutionLimits -} - -/** One captured `console.*` line: the method used and the formatted message. */ -export type LogEntry = { - readonly level: "log" | "warn" | "error" | "info" | "debug" - readonly message: string -} - -export type ExecuteResult = - | { - ok: true - value: unknown - toolCalls: ReadonlyArray - logs: ReadonlyArray - } - | { - ok: false - error: { - kind: DiagnosticKind - message: string - location?: { readonly line: number; readonly column: number } - suggestions?: ReadonlyArray - } - toolCalls: ReadonlyArray - logs: ReadonlyArray - } - -export type RuneOptions = {}> = Omit, "code"> - -/** Input schema for the single agent-facing tool produced by `rune.asTool()`. */ -export const CodeInput = Schema.Struct({ code: Schema.String }) - -const DiagnosticKindSchema = Schema.Literals([ - "ParseError", "UnsupportedSyntax", "UnknownCapability", "InvalidToolInput", "InvalidToolOutput", "InvalidDataValue", - "OperationLimitExceeded", "ToolCallLimitExceeded", "AuditLimitExceeded", "ConcurrencyLimitExceeded", "TimeoutExceeded", - "CapabilityFailure", "ExecutionFailure", -]) - -/** Structured success or diagnostic result schema returned by Rune execution. */ -export const ExecuteResultSchema = Schema.Union([ - Schema.Struct({ - ok: Schema.Literal(true), - value: Schema.Unknown, - toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })), - logs: Schema.Array(Schema.Struct({ level: Schema.String, message: Schema.String })), - }), - Schema.Struct({ - ok: Schema.Literal(false), - error: Schema.Struct({ - kind: DiagnosticKindSchema, - message: Schema.String, - location: Schema.optional(Schema.Struct({ line: Schema.Number, column: Schema.Number })), - suggestions: Schema.optional(Schema.Array(Schema.String)), - }), - toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })), - logs: Schema.Array(Schema.Struct({ level: Schema.String, message: Schema.String })), - }), -]) - -export type CodeTool = { - readonly name: "code" - readonly description: string - readonly input: typeof CodeInput - readonly execute: (input: { readonly code: string }) => Effect.Effect -} - -export type Rune = { - /** Lists schema-described capability paths provided by the host. */ - readonly catalog: () => ReadonlyArray - /** Builds model-facing syntax guidance and visible capability signatures. */ - readonly instructions: () => string - /** Projects the configured runtime as one agent-facing `code` tool. */ - readonly asTool: () => CodeTool - /** Executes a program using this runtime's configured host tools. */ - readonly run: (code: string) => Effect.Effect -} - -type SourcePosition = { - line: number - column: number -} - -type SourceLocation = { - start: SourcePosition - end: SourcePosition -} - -type AstNode = { - type: string - loc?: SourceLocation - [key: string]: unknown -} - -type ProgramNode = AstNode & { - type: "Program" - body: Array -} - -type Binding = { - mutable: boolean - value: unknown - // Absent means initialized. `false` marks a parameter binding seeded into its scope but not - // yet bound, so a default that forward-references a later parameter sees a TDZ error (as in JS) - // rather than silently resolving to an outer binding of the same name. - initialized?: boolean -} - -type StatementResult = - | { kind: "none" } - | { kind: "value"; value: unknown } - | { kind: "return"; value: unknown } - | { kind: "break" } - | { kind: "continue" } - -type MemberReference = { - target: SafeObject | Array - key: string | number -} - -class RuneFunction { - constructor( - readonly parameters: ReadonlyArray, - readonly body: AstNode, - readonly capturedScopes: ReadonlyArray>, - ) {} -} - -class IntrinsicReference { - constructor( - readonly receiver: unknown, - readonly name: string, - ) {} -} - -// A read-only computed member (e.g. `str.length`, a character index) — not assignable. -class ComputedValue { - constructor(readonly value: unknown) {} -} - -class PromiseNamespace {} - -class PromiseAllReference {} - -// A built-in global namespace (`Object`, `Math`, `JSON`, `Array`); members resolve to a -// GlobalMethodReference, except known constants (e.g. `Math.PI`) which resolve to a value. -class GlobalNamespace { - constructor(readonly name: "Object" | "Math" | "JSON" | "Array") {} -} - -class GlobalMethodReference { - constructor(readonly namespace: "Object" | "Math" | "JSON" | "Array" | "Number" | "String", readonly name: string) {} -} - -// A built-in callable global (`Number`, `String`, `Boolean`, `parseInt`, `parseFloat`). -class CoercionFunction { - constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {} -} - -// The `console` builtin. `console.` resolves to a ConsoleMethodReference; calling it -// formats its arguments and appends a line to the run's shared LogCollector. Logging is a pure -// side effect on the host — it never dispatches a tool, spends the tool-call budget, or returns -// a value the program can branch on (every method returns undefined, like JS `console`). -class ConsoleReference {} - -const consoleLevels = new Set(["log", "warn", "error", "info", "debug"] as const) - -class ConsoleMethodReference { - constructor(readonly level: LogEntry["level"]) {} -} - -/** - * Collects `console.*` output for one execution. Shared by reference across parallel - * interpreter forks (like the operation budget), so logs emitted inside `Promise.all` - * / `.map` callbacks are captured too. Bounded by a total character budget — once spent, - * further lines are dropped (the last accepted line is truncated to fit) so a logging loop - * can't grow memory without also spending the operation budget. - */ -class LogCollector { - readonly entries: LogEntry[] = [] - private used = 0 - constructor(private readonly maxChars: number) {} - push(level: LogEntry["level"], message: string): void { - if (this.used >= this.maxChars) return - const remaining = this.maxChars - this.used - const text = message.length > remaining ? message.slice(0, remaining) : message - this.used += text.length - this.entries.push({ level, message: text }) - } -} - -/** Format one `console.*` argument the way `console` roughly does: strings verbatim, - * objects/arrays as JSON, opaque runtime references as a placeholder (never leaking their - * internals), everything else via String(). Never throws. */ -const formatLogArg = (value: unknown): string => { - if (typeof value === "string") return value - if (value === null) return "null" - if (value === undefined) return "undefined" - if (isRuntimeReference(value)) return "[runtime reference]" - if (typeof value === "object") { - try { - return JSON.stringify(value) ?? coerceToString(value) - } catch { - return coerceToString(value) - } - } - return String(value) -} - -class ProgramThrow { - constructor(readonly value: unknown) {} -} - -export type DiagnosticKind = - | "ParseError" - | "UnsupportedSyntax" - | "UnknownCapability" - | "InvalidToolInput" - | "InvalidToolOutput" - | "InvalidDataValue" - | "OperationLimitExceeded" - | "ToolCallLimitExceeded" - | "AuditLimitExceeded" - | "ConcurrencyLimitExceeded" - | "TimeoutExceeded" - | "CapabilityFailure" - | "ExecutionFailure" - -const arrayMethods = new Set([ - "map", "filter", "find", "findIndex", "findLast", "findLastIndex", "some", "every", "includes", "join", - "reduce", "reduceRight", "flatMap", "forEach", "sort", "toSorted", "slice", "concat", "indexOf", "lastIndexOf", - "at", "flat", "reverse", "toReversed", "with", "push", "pop", "shift", "unshift", -]) -const retryableArrayMethods = new Set(["splice", "fill", "copyWithin", "keys", "values", "entries"]) - -/** - * Array methods whose cost is O(1) (or bounded by the argument count), so they must - * NOT be charged the receiver's length. Charging `push` per element would make an - * accumulation loop quadratic in the operation budget and trip it on legitimate code. - */ -const cheapArrayMethods = new Set(["push", "pop", "at"]) - -const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"]) - -const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"]) - -const stringMethods = new Set([ - "toLowerCase", "toUpperCase", "trim", "trimStart", "trimEnd", "split", "slice", "substring", "substr", - "includes", "startsWith", "endsWith", "indexOf", "lastIndexOf", "replace", "replaceAll", - "repeat", "padStart", "padEnd", "charAt", "charCodeAt", "codePointAt", "at", "concat", "toString", -]) - -const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"]) - -const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"]) - -const stringStatics = new Set(["fromCharCode", "fromCodePoint"]) - -const errorConstructors = new Set(["Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError", "EvalError", "URIError"]) - -const OptionalShortCircuit: unique symbol = Symbol("rune.optional-short-circuit") - -const supportedSyntaxMessage = - "Supported orchestration syntax: tools.* calls, data literals, destructuring, optional chaining, template literals, conditionals, switch, loops, arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods, Object/Math/JSON helpers, and Promise.all([tool calls]) or Promise.all(items.map((item) => tool call)) for parallel tool calls." - -const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError => - new InterpreterRuntimeError(`Syntax '${kind}' is not supported in Rune. ${supportedSyntaxMessage}`, node, "UnsupportedSyntax", [supportedSyntaxMessage]) - -export const defaultExecutionLimits = (): ResolvedExecutionLimits => ({ - maxOperations: 100_000, - maxToolCalls: 100, - maxConcurrency: 8, - maxSourceBytes: 32_000, - maxDataBytes: 256_000, - maxAuditBytes: 1_000_000, - maxValueDepth: 32, - maxCollectionLength: 10_000, - timeoutMs: 10_000, -}) - -export const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimits => ({ - ...defaultExecutionLimits(), - ...limits, -}) - -class InterpreterRuntimeError extends Error { - readonly node?: AstNode - - constructor(message: string, node?: AstNode, readonly kind: DiagnosticKind = "ExecutionFailure", readonly suggestions?: ReadonlyArray) { - super(message) - this.name = "InterpreterRuntimeError" - - if (node) { - this.node = node - } - } -} - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null - -const asNode = (value: unknown, context: string): AstNode => { - if (!isRecord(value) || typeof value.type !== "string") { - throw new InterpreterRuntimeError(`Invalid AST node while reading ${context}.`) - } - - return value as AstNode -} - -const getArray = (node: AstNode, key: string): Array => { - const value = node[key] - if (!Array.isArray(value)) { - throw new InterpreterRuntimeError(`Expected '${key}' to be an array.`, node) - } - - return value -} - -const getString = (node: AstNode, key: string): string => { - const value = node[key] - if (typeof value !== "string") { - throw new InterpreterRuntimeError(`Expected '${key}' to be a string.`, node) - } - - return value -} - -const getBoolean = (node: AstNode, key: string): boolean => { - const value = node[key] - if (typeof value !== "boolean") { - throw new InterpreterRuntimeError(`Expected '${key}' to be a boolean.`, node) - } - - return value -} - -const getOptionalNode = (node: AstNode, key: string): AstNode | undefined => { - const value = node[key] - if (value === undefined || value === null) { - return undefined - } - - return asNode(value, key) -} - -const getNode = (node: AstNode, key: string): AstNode => { - const value = node[key] - return asNode(value, key) -} - -const parseProgram = (code: string): ProgramNode => { - const transpiled = transpileModule(`async function __rune__() {\n${code}\n}`, { - reportDiagnostics: true, - compilerOptions: { - target: ScriptTarget.ESNext, - module: ModuleKind.ESNext, - }, - }) - const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error) - - if (diagnostic) { - throw new InterpreterRuntimeError( - `Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`, - undefined, - "ParseError", - ) - } - - const bodyStart = transpiled.outputText.indexOf("{") + 1 - const bodyEnd = transpiled.outputText.lastIndexOf("}") - const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd) - const parsed = parse(executableCode, { - ecmaVersion: "latest", - sourceType: "script", - allowReturnOutsideFunction: true, - allowAwaitOutsideFunction: true, - locations: true, - }) as unknown - - if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) { - throw new InterpreterRuntimeError("Failed to parse script as a Program node.") - } - - return parsed as ProgramNode -} - -const formatLocation = (node?: AstNode): string => { - if (!node || !node.loc) { - return "" - } - - const location = sourceLocation(node) - return ` (line ${location.line}, col ${location.column})` -} - -const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({ - line: Math.max(1, (node.loc?.start.line ?? 2) - 1), - column: Math.max(1, (node.loc?.start.column ?? 4) - 3), -}) - -type Diagnostic = Extract['error'] - -const publicErrorMessage = (message: string): string => - message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "") - -const normalizeError = (error: unknown): Diagnostic => { - if (error instanceof InterpreterRuntimeError) { - return { - kind: error.kind, - message: `${error.message}${formatLocation(error.node)}`, - ...(error.node?.loc ? { location: sourceLocation(error.node) } : {}), - ...(error.suggestions ? { suggestions: error.suggestions } : {}), - } - } - - if (error instanceof ToolRuntimeError) { - return { - kind: error.kind, - message: error.message, - ...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}), - } - } - - if (error instanceof CapabilityError) { - return { kind: "CapabilityFailure", message: publicErrorMessage(error.message) } - } - - if (error instanceof ProgramThrow) { - const value = error.value - let message: string - if (containsRuntimeReference(value)) { - // A thrown capability/function reference must not leak its internal structure. - message = "a non-data value" - } else if (typeof value === "string") { - message = value - } else if (value !== null && typeof value === "object" && typeof (value as { message?: unknown }).message === "string") { - message = (value as { message: string }).message - } else { - try { - message = JSON.stringify(copyOut(value)) ?? String(value) - } catch { - message = String(value) - } - } - return { kind: "ExecutionFailure", message: `Uncaught: ${message}` } - } - - if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) { - return { - kind: "ExecutionFailure", - message: "Execution exceeded the maximum nesting depth.", - } - } - - if (error instanceof Error) { - return { - kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure", - message: publicErrorMessage(error.message), - } - } - - // A non-Error thrown by a host tool (raw string / number / Symbol) still routes through - // path redaction so filesystem paths can never leak through the catch-all branch. - return { - kind: "ExecutionFailure", - message: publicErrorMessage(String(error)), - } -} - -// ── Built-in method/global implementations ─────────────────────────────────── -// These mirror the corresponding JavaScript operations over Data Values. They are -// pure (string/Object/Math/JSON/coercion) and so live as free functions; array -// methods that run Rune callbacks live on the interpreter (they need invokeFunction). - -const boundedData = (value: unknown, label: string, node: AstNode, limits: ResolvedExecutionLimits): unknown => { - const copied = copyIn(value, label, limits) - if (dataByteLength(copied) > limits.maxDataBytes) { - throw new InterpreterRuntimeError(`${label} exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, node, "InvalidDataValue") - } - return copied -} - -const isRuntimeReference = (value: unknown): boolean => - value instanceof RuneFunction || value instanceof ToolReference || value instanceof IntrinsicReference || - value instanceof GlobalNamespace || value instanceof GlobalMethodReference || value instanceof PromiseNamespace || - value instanceof PromiseAllReference || value instanceof CoercionFunction || - value instanceof ConsoleReference || value instanceof ConsoleMethodReference - -const containsRuntimeReference = (value: unknown, seen = new Set()): boolean => { - if (isRuntimeReference(value)) return true - if (value === null || typeof value !== "object") return false - if (seen.has(value)) return false - seen.add(value) - const contains = Array.isArray(value) - ? value.some((item) => containsRuntimeReference(item, seen)) - : Object.values(value).some((item) => containsRuntimeReference(item, seen)) - seen.delete(value) - return contains -} - -const runtimeValueBytes = ( - value: unknown, - label: string, - node: AstNode, - limits: ResolvedExecutionLimits, - depth = 0, - seen = new Set(), -): number => { - if (depth > limits.maxValueDepth) { - throw new InterpreterRuntimeError(`${label} exceeds the maximum value depth of ${limits.maxValueDepth}.`, node, "InvalidDataValue") - } - if (isRuntimeReference(value)) return 0 - if (value === null || value === undefined || typeof value === "string" || typeof value === "boolean" || typeof value === "number") { - return dataByteLength(value) - } - if (typeof value !== "object") { - throw new InterpreterRuntimeError(`${label} must contain data or Rune references only.`, node, "InvalidDataValue") - } - if (seen.has(value)) throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") - seen.add(value) - let bytes = 2 - if (Array.isArray(value)) { - if (value.length > limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - for (const item of value) bytes += runtimeValueBytes(item, label, node, limits, depth + 1, seen) + 1 - } else { - const entries = Object.entries(value) - if (entries.length > limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - for (const [key, item] of entries) { - if (isBlockedMember(key)) throw new InterpreterRuntimeError(`${label} contains blocked property '${key}'.`, node, "InvalidDataValue") - bytes += dataByteLength(key) + runtimeValueBytes(item, label, node, limits, depth + 1, seen) + 1 - } - } - seen.delete(value) - return bytes -} - -const boundedProgramValue = (value: unknown, label: string, node: AstNode, limits: ResolvedExecutionLimits): unknown => { - if (runtimeValueBytes(value, label, node, limits) > limits.maxDataBytes) { - throw new InterpreterRuntimeError(`${label} exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, node, "InvalidDataValue") - } - return value -} - -// A cheap proxy for the work an O(n) built-in performed, used to charge the operation budget. -const workUnits = (value: unknown): number => { - if (typeof value === "string" || Array.isArray(value)) return value.length - if (value !== null && typeof value === "object") return Object.keys(value).length - return 1 -} - -const invokeStringMethod = (value: string, name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { - const str = (index: number): string => { - const arg = args[index] - if (typeof arg !== "string") throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node) - return arg - } - const num = (index: number): number => { - const arg = args[index] - if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node) - return arg - } - const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index)) - const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index)) - const byteLength = (text: string): number => new TextEncoder().encode(text).byteLength - const limitString = (bytes: number): void => { - if (bytes > limits.maxDataBytes) { - throw new InterpreterRuntimeError(`String.${name} exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, node, "InvalidDataValue") - } - } - const replacementCount = (search: string): number => { - if (search === "") return value.length + 1 - let count = 0 - let offset = 0 - while ((offset = value.indexOf(search, offset)) !== -1) { - count += 1 - offset += search.length - } - return count - } - - let result: unknown - switch (name) { - case "toLowerCase": result = value.toLowerCase(); break - case "toUpperCase": result = value.toUpperCase(); break - case "trim": result = value.trim(); break - case "trimStart": result = value.trimStart(); break - case "trimEnd": result = value.trimEnd(); break - case "split": { - if (args.length === 0) { - result = [value] - break - } - const separator = str(0) - const requestedLimit = optNum(1) - const effectiveLimit = requestedLimit === undefined ? undefined : requestedLimit >>> 0 - const maximumParts = separator === "" ? value.length : replacementCount(separator) + 1 - const parts = effectiveLimit === undefined ? maximumParts : Math.min(maximumParts, effectiveLimit) - if (parts > limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`String.split exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - result = value.split(separator, effectiveLimit) - break - } - case "slice": result = value.slice(optNum(0), optNum(1)); break - case "includes": result = value.includes(str(0), optNum(1)); break - case "startsWith": result = value.startsWith(str(0), optNum(1)); break - case "endsWith": result = value.endsWith(str(0), optNum(1)); break - case "indexOf": result = value.indexOf(str(0), optNum(1)); break - case "lastIndexOf": result = value.lastIndexOf(str(0), optNum(1)); break - case "replace": result = value.replace(str(0), str(1)); break - case "replaceAll": { - const search = str(0) - const replacement = str(1) - const growth = Math.max(0, byteLength(replacement) - byteLength(search)) - limitString(byteLength(value) + replacementCount(search) * growth) - result = value.replaceAll(search, replacement) - break - } - case "repeat": { - const count = num(0) - if (!Number.isFinite(count) || count < 0) throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node) - limitString(byteLength(value) * Math.floor(count)) - result = value.repeat(count) - break - } - case "padStart": { - const length = num(0) - limitString(Math.max(0, length)) - result = value.padStart(length, optStr(1)) - break - } - case "padEnd": { - const length = num(0) - limitString(Math.max(0, length)) - result = value.padEnd(length, optStr(1)) - break - } - case "charAt": result = value.charAt(optNum(0) ?? 0); break - case "at": result = value.at(optNum(0) ?? 0); break - case "substring": result = value.substring(optNum(0) ?? 0, optNum(1)); break - case "substr": result = value.substr(optNum(0) ?? 0, optNum(1)); break - // JS charCodeAt returns NaN out of range, but Rune forbids NaN as a Data Value; - // yield undefined instead, matching codePointAt and `at` (the other absent-slot sentinels). - case "charCodeAt": { const code = value.charCodeAt(optNum(0) ?? 0); result = Number.isNaN(code) ? undefined : code; break } - case "codePointAt": result = value.codePointAt(optNum(0) ?? 0); break - case "toString": result = value; break - case "concat": { - const pieces = args.map((_, index) => str(index)) - limitString(byteLength(value) + pieces.reduce((size, piece) => size + byteLength(piece), 0)) - result = value.concat(...pieces) - break - } - default: throw new InterpreterRuntimeError(`String method '${name}' is not available in Rune.`, node) - } - return boundedData(result, `String.${name} result`, node, limits) -} - -const invokeNumberMethod = (value: number, name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { - const optNum = (index: number): number | undefined => { - const arg = args[index] - if (arg === undefined) return undefined - if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node) - return arg - } - let result: unknown - switch (name) { - case "toFixed": result = value.toFixed(optNum(0)); break - case "toExponential": result = value.toExponential(optNum(0)); break - case "toPrecision": { - const digits = optNum(0) - result = digits === undefined ? value.toString() : value.toPrecision(digits) - break - } - case "toString": { - const radix = optNum(0) - if (radix !== undefined && (radix < 2 || radix > 36)) { - throw new InterpreterRuntimeError("Number.toString radix must be between 2 and 36.", node) - } - result = value.toString(radix) - break - } - default: throw new InterpreterRuntimeError(`Number method '${name}' is not available in Rune.`, node) - } - return boundedData(result, `Number.${name} result`, node, limits) -} - -// JavaScript's String(...) without tripping over Rune's null-prototype data objects. -const coerceToString = (value: unknown): string => { - if (value === null) return "null" - if (value === undefined) return "undefined" - if (typeof value === "object") { - return Array.isArray(value) - ? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",") - : "[object Object]" - } - return String(value) -} - -const coerceToNumber = (value: unknown): number => - value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value) - -const invokeCoercion = (ref: CoercionFunction, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { - const value = boundedData(args[0], `${ref.name} input`, node, limits) - if (ref.name === "Number") return coerceToNumber(value) - if (ref.name === "Boolean") return Boolean(value) - if (ref.name === "parseInt") { - const radix = args[1] - if (radix !== undefined && typeof radix !== "number") throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node) - return parseInt(coerceToString(value), radix) - } - if (ref.name === "parseFloat") return parseFloat(coerceToString(value)) - return coerceToString(value) -} - -const invokeObjectMethod = (name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { - const requireObject = (): Record => { - const value = boundedData(args[0], `Object.${name} input`, node, limits) - if (value === null || typeof value !== "object" || Array.isArray(value)) { - throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node) - } - return value as Record - } - const guardedSet = (out: Record, key: string, item: unknown): void => { - if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in Rune.`, node) - out[key] = item - } - switch (name) { - case "keys": return Object.keys(requireObject()) - case "values": return Object.values(requireObject()) - case "entries": return Object.entries(requireObject()).map(([key, item]) => [key, item]) - case "hasOwn": return Object.hasOwn(requireObject(), String(args[1])) - case "assign": { - const out: Record = Object.create(null) - for (const source of args) { - if (source === null || source === undefined) continue - const value = boundedData(source, "Object.assign input", node, limits) - if (value === null || typeof value !== "object" || Array.isArray(value)) throw new InterpreterRuntimeError("Object.assign expects data objects.", node) - for (const [key, item] of Object.entries(value)) guardedSet(out, key, item) - } - return out - } - case "fromEntries": { - const pairs = boundedData(args[0], "Object.fromEntries input", node, limits) - if (!Array.isArray(pairs)) throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) - const out: Record = Object.create(null) - for (const pair of pairs) { - if (!Array.isArray(pair)) throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node) - guardedSet(out, String(pair[0]), pair[1]) - } - return out - } - default: throw new InterpreterRuntimeError(`Object.${name} is not available in Rune.`, node) - } -} - -const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { - const nums = args.map((arg) => { - if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node) - return arg - }) - const [a = Number.NaN, b = Number.NaN] = nums - switch (name) { - case "max": return Math.max(...nums) - case "min": return Math.min(...nums) - case "abs": return Math.abs(a) - case "floor": return Math.floor(a) - case "ceil": return Math.ceil(a) - case "round": return Math.round(a) - case "trunc": return Math.trunc(a) - case "sign": return Math.sign(a) - case "sqrt": return Math.sqrt(a) - case "cbrt": return Math.cbrt(a) - case "pow": return Math.pow(a, b) - case "hypot": return Math.hypot(...nums) - case "log": return Math.log(a) - case "log2": return Math.log2(a) - case "log10": return Math.log10(a) - case "exp": return Math.exp(a) - default: throw new InterpreterRuntimeError(`Math.${name} is not available in Rune.`, node) - } -} - -const invokeJsonMethod = (name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { - switch (name) { - case "stringify": { - const replacer = args[1] - if (Array.isArray(replacer) || replacer instanceof RuneFunction) { - throw new InterpreterRuntimeError("JSON.stringify replacers are not supported in Rune.", node, "UnsupportedSyntax", [supportedSyntaxMessage]) - } - const space = args[2] - const indent = typeof space === "number" || typeof space === "string" ? space : undefined - // copyIn first so only Data Values serialize — never a RuneFunction/ToolReference. - return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value", limits)), null, indent) - } - case "parse": { - const text = args[0] - if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node) - let parsed: unknown - try { - parsed = JSON.parse(text) - } catch { - throw new InterpreterRuntimeError("JSON.parse received invalid JSON.", node) - } - return copyIn(parsed, "JSON.parse result", limits) - } - default: throw new InterpreterRuntimeError(`JSON.${name} is not available in Rune.`, node) - } -} - -const invokeArrayStatic = (name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { - switch (name) { - case "isArray": - return Array.isArray(args[0]) - case "of": - return [...args] - case "from": { - if (args.length > 1) { - throw new InterpreterRuntimeError( - "Array.from(...) does not support a map function in Rune; call .map() on the result instead.", - node, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) - } - const source = boundedData(args[0], "Array.from input", node, limits) - if (typeof source === "string") return Array.from(source) - if (Array.isArray(source)) return [...source] - if (source !== null && typeof source === "object" && typeof (source as { length?: unknown }).length === "number") { - return Array.from(source as ArrayLike) - } - throw new InterpreterRuntimeError("Array.from expects an array, string, or array-like value.", node) - } - default: - throw new InterpreterRuntimeError(`Array.${name} is not available in Rune.`, node) - } -} - -const invokeNumberStatic = (name: string, args: Array, node: AstNode): unknown => { - const value = args[0] - switch (name) { - case "isInteger": return Number.isInteger(value) - case "isFinite": return Number.isFinite(value) - case "isNaN": return Number.isNaN(value) - case "isSafeInteger": return Number.isSafeInteger(value) - case "parseInt": { - const radix = args[1] - if (radix !== undefined && typeof radix !== "number") throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node) - return parseInt(coerceToString(value), radix) - } - case "parseFloat": return parseFloat(coerceToString(value)) - default: throw new InterpreterRuntimeError(`Number.${name} is not available in Rune.`, node) - } -} - -const invokeStringStatic = (name: string, args: Array, node: AstNode): unknown => { - const codes = args.map((arg) => { - if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node) - return arg - }) - switch (name) { - case "fromCharCode": return String.fromCharCode(...codes) - case "fromCodePoint": return String.fromCodePoint(...codes) - default: throw new InterpreterRuntimeError(`String.${name} is not available in Rune.`, node) - } -} - -const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { - if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node, limits) - if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node) - if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node, limits) - if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node) - if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node) - return invokeJsonMethod(ref.name, args, node, limits) -} - -// Every identifier a parameter pattern binds, used to seed TDZ slots before defaults run. -const collectPatternNames = (pattern: AstNode, out: Array = []): Array => { - switch (pattern.type) { - case "Identifier": - out.push(getString(pattern, "name")) - break - case "AssignmentPattern": - collectPatternNames(getNode(pattern, "left"), out) - break - case "RestElement": - collectPatternNames(getNode(pattern, "argument"), out) - break - case "ArrayPattern": - for (const element of getArray(pattern, "elements")) { - if (element !== null) collectPatternNames(asNode(element, "elements"), out) - } - break - case "ObjectPattern": - for (const property of getArray(pattern, "properties")) { - const prop = asNode(property, "properties") - collectPatternNames(prop.type === "RestElement" ? getNode(prop, "argument") : getNode(prop, "value"), out) - } - break - } - return out -} - -class Interpreter { - private scopes: Array> - private readonly limits: ResolvedExecutionLimits - private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect - private readonly budget: { operations: number } - // Shared by reference with any parallel forks (see forkForParallelCallback) so every - // console.* line lands in one ordered collection regardless of which fork emitted it. - private readonly logs: LogCollector - private lastValue: unknown - // Cached byte size (and, for objects, key count) of each live container, maintained incrementally - // by the mutation helpers so appending in a loop is O(1)/op rather than re-walking the whole - // container each time (which made push/index-assign/key-assign loops O(n^2) — a CPU DoS). These - // are a fast path under the authoritative copyIn/copyOut boundary checks, never a replacement. - private readonly containerSizes = new WeakMap() - private readonly objectCounts = new WeakMap() - - constructor( - limits: ResolvedExecutionLimits, - invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, - budget: { operations: number } = { operations: 0 }, - logs: LogCollector = new LogCollector(limits.maxAuditBytes), - ) { - const globalScope = new Map() - this.scopes = [globalScope] - this.limits = limits - this.invokeTool = invokeTool - this.budget = budget - this.logs = logs - this.lastValue = undefined - globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) - globalScope.set("console", { mutable: false, value: new ConsoleReference() }) - globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) - globalScope.set("undefined", { mutable: false, value: undefined }) - globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") }) - globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") }) - globalScope.set("JSON", { mutable: false, value: new GlobalNamespace("JSON") }) - globalScope.set("Number", { mutable: false, value: new CoercionFunction("Number") }) - globalScope.set("String", { mutable: false, value: new CoercionFunction("String") }) - globalScope.set("Boolean", { mutable: false, value: new CoercionFunction("Boolean") }) - globalScope.set("Array", { mutable: false, value: new GlobalNamespace("Array") }) - globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") }) - globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") }) - // Non-finite numbers flow as ordinary values inside the sandbox (normalized to null at the - // boundary — see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`. - globalScope.set("NaN", { mutable: false, value: NaN }) - globalScope.set("Infinity", { mutable: false, value: Infinity }) - } - - run(program: ProgramNode): Effect.Effect { - const self = this - // Run the program body in its own module scope on top of the builtin global scope, so - // top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like - // JS module scope, instead of colliding with the seeded globals. - this.pushScope() - return Effect.gen(function*() { - self.hoistFunctions(program.body) - for (const statement of program.body) { - const result = yield* self.evaluateStatement(statement) - - if (result.kind === "return") { - return result.value - } - - if (result.kind === "break" || result.kind === "continue") { - throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement) - } - - if (result.kind === "value") { - self.lastValue = result.value - } - } - - return self.lastValue - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) - } - - private evaluateStatement(node: AstNode): Effect.Effect { - this.recordOperation(node) - - switch (node.type) { - case "ExpressionStatement": - return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value })) - case "VariableDeclaration": - return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" })) - case "ReturnStatement": { - const argumentNode = getOptionalNode(node, "argument") - return argumentNode - ? Effect.map(this.evaluateExpression(argumentNode), (value) => ({ kind: "return", value })) - : Effect.succeed({ kind: "return", value: undefined }) - } - case "BlockStatement": - return this.evaluateBlock(node) - case "IfStatement": - return this.evaluateIfStatement(node) - case "SwitchStatement": - return this.evaluateSwitchStatement(node) - case "WhileStatement": - return this.evaluateWhileStatement(node) - case "DoWhileStatement": - return this.evaluateDoWhileStatement(node) - case "ForStatement": - return this.evaluateForStatement(node) - case "ForOfStatement": - return this.evaluateForOfStatement(node) - case "BreakStatement": - return Effect.succeed(this.evaluateBreakStatement(node)) - case "ContinueStatement": - return Effect.succeed(this.evaluateContinueStatement(node)) - case "ThrowStatement": - return this.evaluateThrowStatement(node) - case "TryStatement": - return this.evaluateTryStatement(node) - case "EmptyStatement": - return Effect.succeed({ kind: "none" }) - case "FunctionDeclaration": - return Effect.succeed({ kind: "none" }) // bound ahead of time by hoistFunctions - default: - throw unsupportedSyntax(node.type, node) - } - } - - private evaluateBlock(node: AstNode): Effect.Effect { - this.pushScope() - const self = this - return Effect.gen(function*() { - const body = getArray(node, "body") - self.hoistFunctions(body) - - for (const statementValue of body) { - const statement = asNode(statementValue, "body") - const result = yield* self.evaluateStatement(statement) - - if (result.kind === "value") { - self.lastValue = result.value - continue - } - - if (result.kind !== "none") { - return result - } - } - - return { kind: "none" } satisfies StatementResult - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) - } - - private createFunction(node: AstNode): RuneFunction { - if (node.generator === true) { - throw new InterpreterRuntimeError("Generator functions are not supported in Rune.", node, "UnsupportedSyntax", [supportedSyntaxMessage]) - } - return new RuneFunction( - getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)), - getNode(node, "body"), - this.scopes.slice(), - ) - } - - // Function declarations are hoisted: bound in their scope before the body runs, so a - // program can call a helper defined further down (matching JavaScript). - private hoistFunctions(statements: Array): void { - for (const statementValue of statements) { - if (!isRecord(statementValue) || statementValue.type !== "FunctionDeclaration") continue - const node = statementValue as AstNode - this.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node) - } - } - - private evaluateIfStatement(node: AstNode): Effect.Effect { - const testNode = getNode(node, "test") - const consequentNode = getNode(node, "consequent") - const alternateNode = getOptionalNode(node, "alternate") - - return Effect.flatMap(this.evaluateExpression(testNode), (test) => - test ? this.evaluateStatement(consequentNode) : alternateNode ? this.evaluateStatement(alternateNode) : Effect.succeed({ kind: "none" })) - } - - private evaluateSwitchStatement(node: AstNode): Effect.Effect { - const self = this - this.pushScope() - return Effect.gen(function*() { - const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant")) - if (containsRuntimeReference(discriminant)) { - throw new InterpreterRuntimeError("Switch discriminants must be data values in Rune.", node, "InvalidDataValue") - } - const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`)) - let defaultIndex: number | undefined - let selected: number | undefined - for (const [index, branch] of cases.entries()) { - const test = getOptionalNode(branch, "test") - if (!test) { - defaultIndex = index - continue - } - const candidate = yield* self.evaluateExpression(test) - if (containsRuntimeReference(candidate)) { - throw new InterpreterRuntimeError("Switch case values must be data values in Rune.", test, "InvalidDataValue") - } - if (candidate === discriminant) { - selected = index - break - } - } - const start = selected ?? defaultIndex - if (start === undefined) return { kind: "none" } satisfies StatementResult - for (let index = start; index < cases.length; index += 1) { - for (const statementValue of getArray(cases[index]!, "consequent")) { - const result = yield* self.evaluateStatement(asNode(statementValue, "consequent")) - if (result.kind === "break") return { kind: "none" } satisfies StatementResult - if (result.kind === "return" || result.kind === "continue") return result - if (result.kind === "value") self.lastValue = result.value - } - } - return { kind: "none" } satisfies StatementResult - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) - } - - private evaluateWhileStatement(node: AstNode): Effect.Effect { - const testNode = getNode(node, "test") - const bodyNode = getNode(node, "body") - - const self = this - return Effect.gen(function*() { - while (yield* self.evaluateExpression(testNode)) { - const result = yield* self.evaluateStatement(bodyNode) - - if (result.kind === "continue") { - continue - } - - if (result.kind === "break") { - return { kind: "none" } satisfies StatementResult - } - - if (result.kind === "return") { - return result - } - - if (result.kind === "value") { - self.lastValue = result.value - } - } - - return { kind: "none" } satisfies StatementResult - }) - } - - private evaluateDoWhileStatement(node: AstNode): Effect.Effect { - const bodyNode = getNode(node, "body") - const testNode = getNode(node, "test") - - const self = this - return Effect.gen(function*() { - do { - const result = yield* self.evaluateStatement(bodyNode) - - if (result.kind === "continue") { - continue - } - - if (result.kind === "break") { - return { kind: "none" } satisfies StatementResult - } - - if (result.kind === "return") { - return result - } - - if (result.kind === "value") { - self.lastValue = result.value - } - } while (yield* self.evaluateExpression(testNode)) - - return { kind: "none" } satisfies StatementResult - }) - } - - private evaluateForStatement(node: AstNode): Effect.Effect { - this.pushScope() - const self = this - return Effect.gen(function*() { - const initNode = getOptionalNode(node, "init") - const testNode = getOptionalNode(node, "test") - const updateNode = getOptionalNode(node, "update") - const bodyNode = getNode(node, "body") - - if (initNode) { - if (initNode.type === "VariableDeclaration") { - yield* self.evaluateVariableDeclaration(initNode) - } else { - yield* self.evaluateExpression(initNode) - } - } - - const perIterationBindings = initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var" - ? Array.from(self.currentScope().keys()) - : [] - - while (testNode ? yield* self.evaluateExpression(testNode) : true) { - let iterationScope: Map | undefined - if (perIterationBindings.length > 0) { - iterationScope = new Map(perIterationBindings.map((name) => { - const binding = self.currentScope().get(name)! - return [name, { ...binding }] - })) - self.scopes.push(iterationScope) - } - const result = yield* self.evaluateStatement(bodyNode).pipe( - Effect.ensuring(Effect.sync(() => { - if (iterationScope) self.popScope() - })), - ) - - if (result.kind === "return") { - return result - } - - if (result.kind === "break") { - return { kind: "none" } satisfies StatementResult - } - - if (result.kind === "value") { - self.lastValue = result.value - } - - if (iterationScope) { - const loopScope = self.currentScope() - for (const name of perIterationBindings) { - loopScope.set(name, { ...iterationScope.get(name)! }) - } - } - - if (updateNode) { - yield* self.evaluateExpression(updateNode) - } - - if (result.kind === "continue") { - continue - } - } - - return { kind: "none" } satisfies StatementResult - }).pipe(Effect.ensuring(Effect.sync(() => self.popScope()))) - } - - private evaluateForOfStatement(node: AstNode): Effect.Effect { - if (getBoolean(node, "await")) { - throw new InterpreterRuntimeError("for await...of is not supported.", node) - } - - const self = this - return Effect.gen(function*() { - const left = getNode(node, "left") - const right = yield* self.evaluateExpression(getNode(node, "right")) - const body = getNode(node, "body") - - if (!Array.isArray(right)) { - throw new InterpreterRuntimeError("for...of requires an array value in Rune.", node) - } - - let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined - let assignmentName: string | undefined - - if (left.type === "VariableDeclaration") { - const declarations = getArray(left, "declarations") - if (declarations.length !== 1) { - throw new InterpreterRuntimeError("for...of supports one declared binding.", left) - } - - const declarator = asNode(declarations[0], "declarations[0]") - declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" } - } else if (left.type === "Identifier") { - assignmentName = getString(left, "name") - } else { - throw new InterpreterRuntimeError("Unsupported for...of binding.", left) - } - - for (const value of right) { - if (declaration) { - self.pushScope() - yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left) - } else if (assignmentName) { - self.setIdentifierValue(assignmentName, value, left) - } - - const result = yield* self.evaluateStatement(body).pipe( - Effect.ensuring(Effect.sync(() => { - if (declaration) self.popScope() - })), - ) - - if (result.kind === "return") { - return result - } - - if (result.kind === "break") { - return { kind: "none" } - } - - if (result.kind === "value") { - self.lastValue = result.value - } - - if (result.kind === "continue") { - continue - } - } - - return { kind: "none" } - }) - } - - private evaluateBreakStatement(node: AstNode): StatementResult { - const labelNode = getOptionalNode(node, "label") - - if (labelNode) { - throw new InterpreterRuntimeError("Labeled break is not supported in v1.", node) - } - - return { kind: "break" } - } - - private evaluateContinueStatement(node: AstNode): StatementResult { - const labelNode = getOptionalNode(node, "label") - - if (labelNode) { - throw new InterpreterRuntimeError("Labeled continue is not supported in v1.", node) - } - - return { kind: "continue" } - } - - private evaluateThrowStatement(node: AstNode): Effect.Effect { - const argument = getNode(node, "argument") - return Effect.flatMap(this.evaluateExpression(argument), (value) => Effect.fail(new ProgramThrow(value))) - } - - private evaluateTryStatement(node: AstNode): Effect.Effect { - const body = getNode(node, "block") - const handler = getOptionalNode(node, "handler") - const finalizer = getOptionalNode(node, "finalizer") - const self = this - - const attempted = Effect.matchCauseEffect(this.evaluateStatement(body), { - onFailure: (cause) => { - if (cause.reasons.some(Cause.isInterruptReason) || !handler) { - return Effect.failCause(cause) - } - - const thrown = Cause.squash(cause) - // The program sees a plain { message } error. Use the interpreter error's raw message so - // the program never sees the transpiled-source "(line N, col N)" coordinates that - // normalizeError appends — without disturbing a host/tool message that legitimately ends - // that way. Other error kinds carry no appended location, so normalizeError is used as-is. - const caught = thrown instanceof ProgramThrow - ? thrown.value - : Object.assign(Object.create(null) as SafeObject, { - message: thrown instanceof InterpreterRuntimeError ? thrown.message : normalizeError(thrown).message, - }) - const parameter = getOptionalNode(handler, "param") - self.pushScope() - return Effect.gen(function*() { - if (parameter) yield* self.declarePattern(parameter, caught, true, handler) - return yield* self.evaluateStatement(getNode(handler, "body")) - }).pipe( - Effect.ensuring(Effect.sync(() => self.popScope())), - ) - }, - onSuccess: Effect.succeed, - }) - - if (!finalizer) return attempted - - const isAbrupt = (result: StatementResult): boolean => - result.kind === "return" || result.kind === "break" || result.kind === "continue" - - return Effect.matchCauseEffect(attempted, { - onFailure: (cause) => - cause.reasons.some(Cause.isInterruptReason) - ? Effect.failCause(cause) - : Effect.flatMap(this.evaluateStatement(finalizer), (final) => - isAbrupt(final) ? Effect.succeed(final) : Effect.failCause(cause)), - onSuccess: (result) => - Effect.flatMap(this.evaluateStatement(finalizer), (final) => - isAbrupt(final) ? Effect.succeed(final) : Effect.succeed(result)), - }) - } - - private evaluateVariableDeclaration(node: AstNode): Effect.Effect { - const kind = getString(node, "kind") - const declarations = getArray(node, "declarations") - const self = this - return Effect.gen(function*() { - for (const declarationValue of declarations) { - const declaration = asNode(declarationValue, "declarations") - - if (declaration.type !== "VariableDeclarator") { - throw new InterpreterRuntimeError("Unsupported variable declaration shape.", declaration) - } - - const init = getOptionalNode(declaration, "init") - const value = init ? yield* self.evaluateExpression(init) : undefined - yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration) - } - }) - } - - private declarePattern(pattern: AstNode, value: unknown, mutable: boolean, node: AstNode): Effect.Effect { - const self = this - return Effect.gen(function*() { - if (pattern.type === "Identifier") { - self.declare(getString(pattern, "name"), value, mutable, node) - return - } - - // Default values: `x = expr` / `{ a = 1 }` — the default is evaluated only when the value is undefined. - if (pattern.type === "AssignmentPattern") { - const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value - yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node) - return - } - - if (pattern.type === "ObjectPattern") { - if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) { - throw new InterpreterRuntimeError("Object destructuring requires a data object value.", pattern, "InvalidDataValue") - } - - const consumed = new Set() - for (const propertyValue of getArray(pattern, "properties")) { - const property = asNode(propertyValue, "properties") - - // Object rest: `{ a, ...others }` — gather the not-yet-consumed own keys. - if (property.type === "RestElement") { - const rest: SafeObject = Object.create(null) as SafeObject - for (const [key, item] of Object.entries(value as SafeObject)) { - if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item - } - yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property) - continue - } - - if (property.type !== "Property" || getBoolean(property, "computed") || getString(property, "kind") !== "init") { - throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property) - } - - const keyNode = getNode(property, "key") - const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value) - if (isBlockedMember(key)) { - throw new InterpreterRuntimeError(`Property '${key}' is not available in Rune.`, keyNode) - } - consumed.add(key) - yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property) - } - return - } - - if (pattern.type === "ArrayPattern") { - if (!Array.isArray(value)) { - throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern) - } - - for (const [index, item] of getArray(pattern, "elements").entries()) { - if (item === null) continue - const element = asNode(item, `elements[${index}]`) - // Array rest: `[head, ...tail]` — binds the remaining elements (must be last). - if (element.type === "RestElement") { - yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element) - break - } - yield* self.declarePattern(element, value[index], mutable, pattern) - } - return - } - - throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern) - }) - } - - private evaluateExpression(node: AstNode): Effect.Effect { - this.recordOperation(node) - - switch (node.type) { - case "Literal": - return Effect.sync(() => boundedData(node.value, "Literal", node, this.limits)) - case "Identifier": - return Effect.sync(() => this.getIdentifierValue(getString(node, "name"), node)) - case "BinaryExpression": - return this.evaluateBinaryExpression(node) - case "LogicalExpression": - return this.evaluateLogicalExpression(node) - case "UnaryExpression": - return this.evaluateUnaryExpression(node) - case "AssignmentExpression": - return this.evaluateAssignmentExpression(node) - case "CallExpression": - return this.evaluateCallExpression(node) - case "ArrowFunctionExpression": - case "FunctionExpression": - return Effect.sync(() => this.createFunction(node)) - case "MemberExpression": - return this.readMember(node) - case "ChainExpression": - return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => - value === OptionalShortCircuit ? undefined : value) - case "ObjectExpression": - return this.evaluateObjectExpression(node) - case "ArrayExpression": - return this.evaluateArrayExpression(node) - case "TemplateLiteral": - return this.evaluateTemplateLiteral(node) - case "ConditionalExpression": - return this.evaluateConditionalExpression(node) - case "UpdateExpression": - return this.evaluateUpdateExpression(node) - case "AwaitExpression": { - return this.evaluateExpression(getNode(node, "argument")) - } - case "NewExpression": - return this.evaluateNewExpression(node) - default: - throw unsupportedSyntax(node.type, node) - } - } - - private evaluateNewExpression(node: AstNode): Effect.Effect { - const callee = getNode(node, "callee") - if (callee.type !== "Identifier" || !errorConstructors.has(getString(callee, "name"))) { - throw unsupportedSyntax("NewExpression", node) - } - const name = getString(callee, "name") - const argNodes = getArray(node, "arguments") - const self = this - return Effect.gen(function*() { - const arg = argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined - const message = arg === undefined ? "" : coerceToString(arg) - const errorValue: SafeObject = Object.create(null) as SafeObject - errorValue.name = name - errorValue.message = message - return errorValue - }) - } - - private evaluateBinaryExpression(node: AstNode): Effect.Effect { - const operator = getString(node, "operator") - const self = this - return Effect.gen(function*() { - const lhs = (yield* self.evaluateExpression(getNode(node, "left"))) as any - const rhs = (yield* self.evaluateExpression(getNode(node, "right"))) as any - if (containsRuntimeReference(lhs) || containsRuntimeReference(rhs)) { - throw new InterpreterRuntimeError("Binary operators require data values in Rune.", node, "InvalidDataValue") - } - // Data objects/arrays are null-prototype, so JS's ToPrimitive throws an opaque host - // "No default value" TypeError when an operator coerces them. Coerce to their JS string - // form first (as String(x) / template literals do) so operators behave like JavaScript. - // Identity (=== / !==) and the right operand of `in` keep their raw object value. - const coerceOperand = (operand: unknown): unknown => - operand !== null && typeof operand === "object" ? coerceToString(operand) : operand - const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object" - const l = coerceOperand(lhs) as any - const r = coerceOperand(rhs) as any - let result: unknown - switch (operator) { - case "+": result = l + r; break - case "-": result = l - r; break - case "*": result = l * r; break - case "/": result = l / r; break - case "%": result = l % r; break - case "**": result = l ** r; break - // Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces. - case "==": result = bothObjects ? lhs === rhs : l == r; break - case "===": result = lhs === rhs; break - case "!=": result = bothObjects ? lhs !== rhs : l != r; break - case "!==": result = lhs !== rhs; break - case "<": result = l < r; break - case "<=": result = l <= r; break - case ">": result = l > r; break - case ">=": result = l >= r; break - case "&": result = l & r; break - case "|": result = l | r; break - case "^": result = l ^ r; break - case "<<": result = l << r; break - case ">>": result = l >> r; break - case ">>>": result = l >>> r; break - case "in": - if (rhs === null || typeof rhs !== "object") { - throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node) - } - // Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...). - result = Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey); break - default: throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node) - } - return boundedData(result, "Binary expression result", node, self.limits) - }) - } - - private evaluateLogicalExpression(node: AstNode): Effect.Effect { - const operator = getString(node, "operator") - return Effect.flatMap(this.evaluateExpression(getNode(node, "left")), (left) => { - if (operator === "&&") return left ? this.evaluateExpression(getNode(node, "right")) : Effect.succeed(left) - if (operator === "||") return left ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right")) - if (operator === "??") return left !== null && left !== undefined ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right")) - throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node) - }) - } - - private evaluateUnaryExpression(node: AstNode): Effect.Effect { - const operator = getString(node, "operator") - const argument = getNode(node, "argument") - // `typeof undeclaredIdentifier` is `"undefined"` in JS (never a ReferenceError), so - // feature-detection guards like `typeof x !== "undefined"` don't crash. Short-circuit before - // evaluating the argument; a declared-but-TDZ binding still falls through to the normal throw. - if (operator === "typeof" && argument.type === "Identifier" && !this.resolveBinding(getString(argument, "name"))) { - return Effect.succeed("undefined") - } - return Effect.map(this.evaluateExpression(argument), (value) => { - if (containsRuntimeReference(value)) { - throw new InterpreterRuntimeError("Unary operators require data values in Rune.", node, "InvalidDataValue") - } - const rhs = value as any - // Numeric/bitwise unary operators ToPrimitive their operand; coerce null-prototype - // data objects/arrays to their JS string form first (see evaluateBinaryExpression). - // `!` and `typeof` operate on the raw value (no ToPrimitive, no crash). - const operand = - (operator === "+" || operator === "-" || operator === "~") && rhs !== null && typeof rhs === "object" - ? (coerceToString(rhs) as any) - : rhs - let result: unknown - switch (operator) { - case "+": result = +operand; break - case "-": result = -operand; break - case "!": result = !rhs; break - case "typeof": result = typeof rhs; break - case "~": result = ~operand; break - default: throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node) - } - return boundedData(result, "Unary expression result", node, this.limits) - }) - } - - private evaluateAssignmentExpression(node: AstNode): Effect.Effect { - const left = getNode(node, "left") - const operator = getString(node, "operator") - const self = this - return Effect.gen(function*() { - if (operator === "??=" || operator === "||=" || operator === "&&=") { - return yield* self.evaluateLogicalAssignment(node, left, operator) - } - const rightValue = yield* self.evaluateExpression(getNode(node, "right")) - if (left.type === "Identifier") { - const name = getString(left, "name") - if (operator === "=") return self.setIdentifierValue(name, rightValue, left) - const next = boundedData(self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node), "Assignment result", node, self.limits) - return self.setIdentifierValue(name, next, left) - } - if (left.type === "MemberExpression") { - if (operator === "=") return yield* self.writeMember(left, rightValue) - return yield* self.modifyMember(left, (current) => { - const next = boundedData(self.applyCompoundAssignment(operator, current, rightValue, node), "Assignment result", node, self.limits) - return Effect.succeed({ write: true, next, result: next }) - }) - } - throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left) - }) - } - - private evaluateLogicalAssignment(node: AstNode, left: AstNode, operator: string): Effect.Effect { - const self = this - const shouldAssign = (current: unknown): boolean => - operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current) - if (left.type === "Identifier") { - const name = getString(left, "name") - return Effect.gen(function*() { - const current = self.getIdentifierValue(name, left) - if (!shouldAssign(current)) return current - const rightValue = yield* self.evaluateExpression(getNode(node, "right")) - return self.setIdentifierValue(name, rightValue, left) - }) - } - if (left.type === "MemberExpression") { - // Resolve the member exactly once; evaluate the RHS only if we actually assign. - return self.modifyMember(left, (current) => - shouldAssign(current) - ? Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => ({ write: true, next: rightValue, result: rightValue })) - : Effect.succeed({ write: false, next: current, result: current })) - } - throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left) - } - - private evaluateUpdateExpression(node: AstNode): Effect.Effect { - const operator = getString(node, "operator") - const argument = getNode(node, "argument") - const prefix = getBoolean(node, "prefix") - - const increment = operator === "++" ? 1 : operator === "--" ? -1 : undefined - - if (increment === undefined) { - throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node) - } - - if (argument.type === "Identifier") { - return Effect.sync(() => { - const name = getString(argument, "name") - const current = Number(this.getIdentifierValue(name, argument)) - const next = boundedData(current + increment, "Update result", node, this.limits) as number - this.setIdentifierValue(name, next, argument) - return prefix ? next : current - }) - } - - if (argument.type === "MemberExpression") { - return this.modifyMember(argument, (current) => { - const value = Number(current) - const next = boundedData(value + increment, "Update result", node, this.limits) as number - return Effect.succeed({ write: true, next, result: prefix ? next : value }) - }) - } - - throw new InterpreterRuntimeError("Update target must be an Identifier or MemberExpression.", argument) - } - - private evaluateCallExpression(node: AstNode): Effect.Effect { - const callee = getNode(node, "callee") - const argNodes = getArray(node, "arguments") - - const self = this - return Effect.gen(function*() { - const callable = yield* self.evaluateExpression(callee) - if (callable === OptionalShortCircuit) return OptionalShortCircuit - if ((callable === null || callable === undefined) && node.optional === true) return OptionalShortCircuit - if (callable instanceof PromiseAllReference) { - if (argNodes.length !== 1) { - throw new InterpreterRuntimeError(`Promise.all expects exactly one collection expression. ${supportedSyntaxMessage}`, node) - } - const argument = asNode(argNodes[0], "arguments[0]") - return yield* self.evaluatePromiseAll(argument, node) - } - - const args = yield* self.evaluateCallArguments(argNodes) - - if (callable instanceof ToolReference) { - if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee) - return yield* self.invokeTool(callable.path, args) - } - if (callable instanceof RuneFunction) { - return yield* self.invokeFunction(callable, args) - } - if (callable instanceof IntrinsicReference) { - return yield* self.invokeIntrinsic(callable, args, node) - } - if (callable instanceof GlobalMethodReference) { - const globalResult = invokeGlobalMethod(callable, args, node, self.limits) - self.recordWork(workUnits(globalResult), node) - return boundedData(globalResult, `${callable.namespace}.${callable.name} result`, node, self.limits) - } - if (callable instanceof CoercionFunction) { - const coercionResult = invokeCoercion(callable, args, node, self.limits) - self.recordWork(workUnits(coercionResult), node) - return boundedData(coercionResult, `${callable.name} result`, node, self.limits) - } - if (callable instanceof ConsoleMethodReference) { - const message = args.map(formatLogArg).join(" ") - // Charge formatting to the operation budget so a console.log loop is bounded by - // maxOperations, not just the wall-clock timeout. - self.recordWork(message.length, node) - self.logs.push(callable.level, message) - return undefined - } - throw new InterpreterRuntimeError("Only tool capabilities are callable in Rune.", callee) - }) - } - - private evaluateCallArguments(argNodes: Array): Effect.Effect, unknown, R> { - const self = this - return Effect.gen(function*() { - const args: Array = [] - for (const [index, arg] of argNodes.entries()) { - const argNode = asNode(arg, `arguments[${index}]`) - if (argNode.type === "SpreadElement") { - const spread = yield* self.evaluateExpression(getNode(argNode, "argument")) - const items = Array.isArray(spread) ? spread : typeof spread === "string" ? Array.from(spread) : undefined - if (items === undefined) throw new InterpreterRuntimeError("Spread arguments require an array or string in Rune.", argNode) - if (args.length + items.length > self.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Call arguments exceed the maximum collection length of ${self.limits.maxCollectionLength}.`, argNode, "InvalidDataValue") - } - args.push(...items) - self.recordWork(items.length, argNode) - } else { - args.push(yield* self.evaluateExpression(argNode)) - if (args.length > self.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Call arguments exceed the maximum collection length of ${self.limits.maxCollectionLength}.`, argNode, "InvalidDataValue") - } - } - } - return args - }) - } - - private evaluatePromiseAll(argument: AstNode, node: AstNode): Effect.Effect, unknown, R> { - if (argument.type === "ArrayExpression") { - const elements = getArray(argument, "elements") - if (elements.length > this.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Promise.all exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "ConcurrencyLimitExceeded") - } - const calls = elements.map((value, index) => { - if (value === null) { - throw new InterpreterRuntimeError(`Promise.all array elements must be direct Tool Capability calls. ${supportedSyntaxMessage}`, argument) - } - const element = asNode(value, `elements[${index}]`) - if (element.type === "SpreadElement") { - throw new InterpreterRuntimeError(`Promise.all does not support spread elements yet. ${supportedSyntaxMessage}`, element) - } - if (!this.isToolCallExpression(element)) { - throw new InterpreterRuntimeError(`Promise.all array elements must be direct Tool Capability calls. ${supportedSyntaxMessage}`, element) - } - return element.type === "AwaitExpression" ? getNode(element, "argument") : element - }) - const self = this - return Effect.gen(function*() { - const prepared: Array<{ readonly path: ReadonlyArray; readonly args: Array }> = [] - for (const call of calls) { - const callable = yield* self.evaluateExpression(getNode(call, "callee")) - if (!(callable instanceof ToolReference) || callable.path.length === 0) { - throw new InterpreterRuntimeError("Promise.all expects direct Tool Capability calls.", call) - } - const args = yield* self.evaluateCallArguments(getArray(call, "arguments")) - prepared.push({ path: callable.path, args }) - } - const values = yield* Effect.all(prepared.map(({ path, args }) => self.invokeTool(path, args)), { concurrency: self.limits.maxConcurrency }) - return boundedProgramValue(values, "Promise.all result", node, self.limits) as Array - }) - } - - if (argument.type === "CallExpression") { - return this.evaluateParallelMap(argument, node) - } - - throw new InterpreterRuntimeError(`Promise.all supports an array literal or a direct .map(...) expression. ${supportedSyntaxMessage}`, node) - } - - private evaluateParallelMap(call: AstNode, node: AstNode): Effect.Effect, unknown, R> { - const callee = getNode(call, "callee") - const args = getArray(call, "arguments") - if (callee.type !== "MemberExpression" || args.length !== 1) { - throw new InterpreterRuntimeError(`Promise.all supports direct items.map((item) => tools.path(item)) expressions. ${supportedSyntaxMessage}`, node) - } - - const self = this - return Effect.gen(function*() { - const method = yield* self.evaluateExpression(callee) - const callback = yield* self.evaluateExpression(asNode(args[0], "arguments[0]")) - if (!(method instanceof IntrinsicReference) || method.name !== "map" || !(callback instanceof RuneFunction)) { - throw new InterpreterRuntimeError(`Promise.all supports direct items.map((item) => tools.path(item)) expressions. ${supportedSyntaxMessage}`, node) - } - if (!self.isToolCallExpression(callback.body)) { - throw new InterpreterRuntimeError(`Promise.all mapped callbacks must directly call a Tool Capability. ${supportedSyntaxMessage}`, node) - } - const items = method.receiver as Array - if (items.length > self.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Promise.all exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, node, "ConcurrencyLimitExceeded") - } - - const values = yield* Effect.all( - items.map((item, index) => Effect.suspend(() => self.forkForParallelCallback().invokeFunction(callback, [item, index]))), - { concurrency: self.limits.maxConcurrency }, - ) - return boundedProgramValue(values, "Promise.all result", node, self.limits) as Array - }) - } - - private isToolCallExpression(node: AstNode): boolean { - const expression = node.type === "AwaitExpression" ? getNode(node, "argument") : node - return expression.type === "CallExpression" && this.isToolPath(getNode(expression, "callee")) - } - - private isToolPath(node: AstNode): boolean { - if (node.type === "Identifier") return getString(node, "name") === "tools" - return node.type === "MemberExpression" && this.isToolPath(getNode(node, "object")) - } - - private invokeFunction(fn: RuneFunction, args: Array): Effect.Effect { - const self = this - return Effect.suspend(() => { - const savedScopes = self.scopes - self.scopes = [...fn.capturedScopes, new Map()] - const run = Effect.gen(function*() { - // Seed every parameter name into the scope as a TDZ slot first, so a default that - // references another parameter resolves to that (uninitialized) param rather than - // silently falling through to an outer binding of the same name — matching JS. - const paramScope = self.currentScope() - for (const parameter of fn.parameters) { - for (const name of collectPatternNames(parameter)) { - paramScope.set(name, { mutable: true, value: undefined, initialized: false }) - } - } - for (const [index, parameter] of fn.parameters.entries()) { - if (parameter.type === "RestElement") { - yield* self.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter) - break - } - yield* self.declarePattern(parameter, args[index], true, parameter) - } - - if (fn.body.type === "BlockStatement") { - const result = yield* self.evaluateStatement(fn.body) - return result.kind === "return" || result.kind === "value" ? result.value : undefined - } - - return yield* self.evaluateExpression(fn.body) - }) - return run.pipe(Effect.ensuring(Effect.sync(() => { self.scopes = savedScopes }))) - }) - } - - private invokeIntrinsic(ref: IntrinsicReference, args: Array, node: AstNode): Effect.Effect { - if (typeof ref.receiver === "string") { - this.recordWork(ref.receiver.length, node) - const result = invokeStringMethod(ref.receiver, ref.name, args, node, this.limits) - if (typeof result === "string") this.recordWork(result.length, node) - return Effect.succeed(result) - } - if (typeof ref.receiver === "number") { - return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node, this.limits)) - } - if (Array.isArray(ref.receiver)) { - if (!cheapArrayMethods.has(ref.name)) this.recordWork(ref.receiver.length, node) - const self = this - return Effect.map(this.invokeArrayMethod(ref.receiver, ref.name, args, node), (result) => { - if (Array.isArray(result)) self.recordWork(result.length, node) - return result - }) - } - throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in Rune.`, node) - } - - private invokeArrayMethod(target: Array, name: string, args: Array, node: AstNode): Effect.Effect { - const boundedCollection = (items: Array): Array => { - if (items.length > this.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Array.${name} exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - return boundedProgramValue(items, `Array.${name} result`, node, this.limits) as Array - } - const optNumber = (value: unknown, label: string): number | undefined => { - if (value === undefined) return undefined - if (typeof value !== "number") throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node) - return value - } - switch (name) { - case "join": { - if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) { - throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node) - } - const input = boundedData(target, "Array.join input", node, this.limits) as Array - return Effect.succeed(boundedData(input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : args[0] as string), "Array.join result", node, this.limits)) - } - case "includes": - if (args.length === 0 || args.length > 2) throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node) - return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index"))) - case "indexOf": - return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index"))) - case "lastIndexOf": - return Effect.succeed(args[1] === undefined ? target.lastIndexOf(args[0]) : target.lastIndexOf(args[0], optNumber(args[1], "start index"))) - case "at": - return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0)) - case "slice": - return Effect.succeed(boundedCollection(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end")))) - case "concat": - return Effect.succeed(boundedCollection(target.concat(...args))) - case "flat": - return Effect.succeed(boundedCollection(target.flat(optNumber(args[0], "depth") ?? 1))) - case "reverse": - return Effect.succeed(boundedCollection([...target].reverse())) - case "sort": - case "toSorted": - return this.sortArray(target, args[0], node) - case "toReversed": - return Effect.succeed(boundedCollection([...target].reverse())) - case "with": { - const index = optNumber(args[0], "index") ?? 0 - const resolved = index < 0 ? target.length + index : index - if (resolved < 0 || resolved >= target.length) { - throw new InterpreterRuntimeError("Array.with index is out of range.", node) - } - const copied = [...target] - copied[resolved] = args[1] - return Effect.succeed(boundedCollection(copied)) - } - case "push": { - if (target.length + args.length > this.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Array.push exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - // Validate before mutating (so no rollback is needed) and charge only the new elements, - // keeping a push loop O(1)/element instead of re-walking the whole array each call. - let added = 0 - for (const item of args) { - this.rejectCircularInsertion(target, item, "Array.push result", node) - added += this.nestedValueBytes(item, "Array.push result", node) + 1 - } - this.growContainerBytes(target, added, node, "Array.push result") - target.push(...args) - return Effect.succeed(target.length) - } - case "unshift": { - if (target.length + args.length > this.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Array.unshift exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - let added = 0 - for (const item of args) { - this.rejectCircularInsertion(target, item, "Array.unshift result", node) - added += this.nestedValueBytes(item, "Array.unshift result", node) + 1 - } - this.growContainerBytes(target, added, node, "Array.unshift result") - target.unshift(...args) - return Effect.succeed(target.length) - } - // Removals only shrink the array; drop the cached size so the next growth recomputes it. - case "pop": - this.containerSizes.delete(target) - return Effect.succeed(target.pop()) - case "shift": - this.containerSizes.delete(target) - return Effect.succeed(target.shift()) - } - - const callback = args[0] - if (!(callback instanceof RuneFunction) && !(callback instanceof CoercionFunction)) { - throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node) - } - const self = this - // Accept a user arrow function or a builtin coercion callable (Boolean/String/Number), so the - // idioms `filter(Boolean)` / `map(String)` / `map(Number)` work as in JS. Coercions are - // synchronous; only RuneFunctions can await tool calls. - const apply = (callbackArgs: Array): Effect.Effect => - callback instanceof CoercionFunction - ? Effect.succeed(invokeCoercion(callback, callbackArgs, node, self.limits)) - : self.invokeFunction(callback, callbackArgs) - return Effect.gen(function*() { - // Iterate a snapshot taken at call time so a callback that mutates the array can't - // self-extend the loop — matching JS, where elements appended during iteration are not visited. - const items = target.slice() - switch (name) { - case "map": { - const values: Array = [] - for (const [index, item] of items.entries()) values.push(yield* apply([item, index, items])) - return boundedCollection(values) - } - case "flatMap": { - const values: Array = [] - for (const [index, item] of items.entries()) { - const mapped = yield* apply([item, index, items]) - if (Array.isArray(mapped)) values.push(...mapped) - else values.push(mapped) - boundedCollection(values) - } - return boundedCollection(values) - } - case "filter": { - const values: Array = [] - for (const [index, item] of items.entries()) { - if (yield* apply([item, index, items])) values.push(item) - } - return boundedCollection(values) - } - case "find": - for (const [index, item] of items.entries()) { - if (yield* apply([item, index, items])) return item - } - return undefined - case "findIndex": - for (const [index, item] of items.entries()) { - if (yield* apply([item, index, items])) return index - } - return -1 - case "some": - for (const [index, item] of items.entries()) { - if (yield* apply([item, index, items])) return true - } - return false - case "every": - for (const [index, item] of items.entries()) { - if (!(yield* apply([item, index, items]))) return false - } - return true - case "forEach": - for (const [index, item] of items.entries()) yield* apply([item, index, items]) - return undefined - case "reduce": { - let accumulator: unknown - let start: number - if (args.length >= 2) { - accumulator = args[1] - start = 0 - } else { - if (items.length === 0) throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node) - accumulator = items[0] - start = 1 - } - for (let index = start; index < items.length; index += 1) { - accumulator = yield* apply([accumulator, items[index], index, items]) - } - return accumulator - } - case "reduceRight": { - let accumulator: unknown - let start: number - if (args.length >= 2) { - accumulator = args[1] - start = items.length - 1 - } else { - if (items.length === 0) throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node) - accumulator = items[items.length - 1] - start = items.length - 2 - } - for (let index = start; index >= 0; index -= 1) { - accumulator = yield* apply([accumulator, items[index], index, items]) - } - return accumulator - } - case "findLast": - for (let index = items.length - 1; index >= 0; index -= 1) { - if (yield* apply([items[index], index, items])) return items[index] - } - return undefined - case "findLastIndex": - for (let index = items.length - 1; index >= 0; index -= 1) { - if (yield* apply([items[index], index, items])) return index - } - return -1 - } - throw new InterpreterRuntimeError(`Array method '${name}' is not available in Rune.`, node) - }) - } - - private sortArray(target: Array, comparator: unknown, node: AstNode): Effect.Effect, unknown, R> { - if (comparator !== undefined && !(comparator instanceof RuneFunction)) { - throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node) - } - if (!(comparator instanceof RuneFunction)) { - return Effect.sync(() => boundedProgramValue( - [...target].sort((a, b) => { - const left = coerceToString(a) - const right = coerceToString(b) - return left < right ? -1 : left > right ? 1 : 0 - }), - "Array.sort result", - node, - this.limits, - ) as Array) - } - const self = this - const mergeSort = (items: Array): Effect.Effect, unknown, R> => { - if (items.length <= 1) return Effect.succeed(items) - const midpoint = Math.floor(items.length / 2) - return Effect.gen(function*() { - const left = yield* mergeSort(items.slice(0, midpoint)) - const right = yield* mergeSort(items.slice(midpoint)) - const merged: Array = [] - let leftIndex = 0 - let rightIndex = 0 - while (leftIndex < left.length && rightIndex < right.length) { - // Coerce the comparator's result like JS ToNumber (data objects -> NaN, never a host - // crash) and treat NaN as 0 — the spec's "no consistent order" → keep the left element. - const order = coerceToNumber(yield* self.invokeFunction(comparator, [left[leftIndex], right[rightIndex]])) - if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++]) - else merged.push(right[rightIndex++]) - } - return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)] - }) - } - // Per spec, undefined elements sort to the end and the comparator is never called on them. - const defined = target.filter((item) => item !== undefined) - const undefinedCount = target.length - defined.length - return Effect.map(mergeSort(defined), (items) => - boundedProgramValue([...items, ...Array(undefinedCount).fill(undefined)], "Array.sort result", node, this.limits) as Array) - } - - private evaluateObjectExpression(node: AstNode): Effect.Effect, unknown, R> { - const objectValue: Record = Object.create(null) as Record - const keys = new Set() - const properties = getArray(node, "properties") - const self = this - return Effect.gen(function*() { - for (const propertyValue of properties) { - const property = asNode(propertyValue, "properties") - - if (property.type === "SpreadElement") { - const spread = yield* self.evaluateExpression(getNode(property, "argument")) - // JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common - // `{ ...maybeOpts, override }` merge works when the operand is absent. - if (spread === null || spread === undefined) continue - if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) { - throw new InterpreterRuntimeError("Object spread requires a data object in Rune.", property, "InvalidDataValue") - } - for (const [key, value] of Object.entries(spread)) { - if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in Rune.`, property) - objectValue[key] = value - keys.add(key) - if (keys.size > self.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Object expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, property, "InvalidDataValue") - } - } - continue - } - - if (property.type !== "Property") { - throw new InterpreterRuntimeError("Only standard object properties are supported.", property) - } - - if (getString(property, "kind") !== "init") { - throw new InterpreterRuntimeError("Only init object properties are supported.", property) - } - - const keyNode = getNode(property, "key") - const valueNode = getNode(property, "value") - const computed = getBoolean(property, "computed") - - let key: PropertyKey - - if (computed) { - key = self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode) - } else if (keyNode.type === "Identifier") { - key = getString(keyNode, "name") - } else if (keyNode.type === "Literal") { - key = self.toPropertyKey(keyNode.value, keyNode) - } else { - throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode) - } - - if (isBlockedMember(String(key))) { - throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in Rune.`, keyNode) - } - objectValue[String(key)] = yield* self.evaluateExpression(valueNode) - keys.add(String(key)) - if (keys.size > self.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Object expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, property, "InvalidDataValue") - } - } - - return boundedProgramValue(objectValue, "Object expression result", node, self.limits) as Record - }) - } - - private evaluateArrayExpression(node: AstNode): Effect.Effect, unknown, R> { - const elements = getArray(node, "elements") - const values: Array = [] - - const self = this - return Effect.gen(function*() { - for (const elementValue of elements) { - if (elementValue === null) { - values.push(undefined) - if (values.length > self.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Array expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - continue - } - const element = asNode(elementValue, "elements") - if (element.type === "SpreadElement") { - const spread = yield* self.evaluateExpression(getNode(element, "argument")) - const items = Array.isArray(spread) ? spread : typeof spread === "string" ? Array.from(spread) : undefined - if (items === undefined) throw new InterpreterRuntimeError("Array spread requires an array or string in Rune.", element) - values.push(...items) - self.recordWork(items.length, element) - } else { - values.push(yield* self.evaluateExpression(element)) - } - if (values.length > self.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Array expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - } - return boundedProgramValue(values, "Array expression result", node, self.limits) as Array - }) - } - - private evaluateTemplateLiteral(node: AstNode): Effect.Effect { - const quasis = getArray(node, "quasis") - const expressions = getArray(node, "expressions") - - let output = "" - - const self = this - return Effect.gen(function*() { - for (let index = 0; index < quasis.length; index += 1) { - const quasi = asNode(quasis[index], "quasis") - const rawValue = quasi.value - - if (!isRecord(rawValue) || typeof rawValue.cooked !== "string") { - throw new InterpreterRuntimeError("Invalid template literal quasi.", quasi) - } - - output += rawValue.cooked - boundedData(output, "Template literal result", node, self.limits) - - if (index < expressions.length) { - const value = boundedData(yield* self.evaluateExpression(asNode(expressions[index], "expressions")), "Template interpolation", node, self.limits) - output += coerceToString(value) - boundedData(output, "Template literal result", node, self.limits) - } - } - - return output - }) - } - - private evaluateConditionalExpression(node: AstNode): Effect.Effect { - return Effect.flatMap(this.evaluateExpression(getNode(node, "test")), (test) => - this.evaluateExpression(getNode(node, test ? "consequent" : "alternate"))) - } - - private applyCompoundAssignment( - operator: string, - current: unknown, - incoming: unknown, - node: AstNode, - ): unknown { - const lhs = current as any - const rhs = incoming as any - - switch (operator) { - case "+=": - return lhs + rhs - case "-=": - return lhs - rhs - case "*=": - return lhs * rhs - case "/=": - return lhs / rhs - case "%=": - return lhs % rhs - case "**=": - return lhs ** rhs - case "&=": - return lhs & rhs - case "|=": - return lhs | rhs - case "^=": - return lhs ^ rhs - case "<<=": - return lhs << rhs - case ">>=": - return lhs >> rhs - case ">>>=": - return lhs >>> rhs - default: - throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node) - } - } - - private getMemberReference(node: AstNode): Effect.Effect { - const objectNode = getNode(node, "object") - const propertyNode = getNode(node, "property") - const computed = getBoolean(node, "computed") - const optional = node.optional === true - const self = this - return Effect.gen(function*() { - const objectValue = yield* self.evaluateExpression(objectNode) - if (objectValue === OptionalShortCircuit) return OptionalShortCircuit - if ((objectValue === null || objectValue === undefined) && optional) return OptionalShortCircuit - - const key = computed - ? self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode) - : propertyNode.type === "Identifier" - ? getString(propertyNode, "name") - : self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode) - - if (objectValue instanceof ToolReference) { - if (typeof key !== "string" || isBlockedMember(key)) { - throw new InterpreterRuntimeError("Tool paths must use safe string property names.", propertyNode) - } - return new ToolReference([...objectValue.path, key]) - } - - if (objectValue instanceof PromiseNamespace) { - if (key === "all") return new PromiseAllReference() - throw new InterpreterRuntimeError(`Promise.${String(key)} is not available in Rune. Use Promise.all(...) for parallel Tool Capabilities.`, propertyNode) - } - - if (objectValue instanceof ConsoleReference) { - if (typeof key === "string" && consoleLevels.has(key as LogEntry["level"])) { - return new ConsoleMethodReference(key as LogEntry["level"]) - } - throw new InterpreterRuntimeError(`console.${String(key)} is not available in Rune. Use log, warn, error, info, or debug.`, propertyNode) - } - - if (objectValue instanceof GlobalNamespace) { - if (typeof key !== "string" || isBlockedMember(key)) { - throw new InterpreterRuntimeError(`${objectValue.name}.${String(key)} is not available in Rune.`, propertyNode) - } - if (objectValue.name === "Math" && mathConstants.has(key)) { - return new ComputedValue((Math as unknown as Record)[key]) - } - return new GlobalMethodReference(objectValue.name, key) - } - - if (typeof objectValue === "string") { - if (key === "length") return new ComputedValue(objectValue.length) - if (typeof key === "number") return new ComputedValue(objectValue[key]) - if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)]) - if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key) - // Unknown property on a string reads as `undefined`, matching JS (`"x".foo === undefined`), - // instead of throwing — so defensive access like `result?.login ?? result` on a JSON-string - // tool result doesn't crash. (Optional chaining only guards null/undefined receivers, so a - // real string still reaches here.) Only the method allowlist above yields callables. - return new ComputedValue(undefined) - } - - if (typeof objectValue === "number") { - if (typeof key === "string" && numberMethods.has(key)) return new IntrinsicReference(objectValue, key) - // Unknown property on a number reads as `undefined`, matching JS, rather than throwing. - return new ComputedValue(undefined) - } - - // Number / String expose a small allowlist of statics; everything else stays opaque. - if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) { - if (objectValue.name === "Number" && numberConstants.has(key)) { - return new ComputedValue((Number as unknown as Record)[key]) - } - if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key) - if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key) - } - - if (isRuntimeReference(objectValue)) { - throw new InterpreterRuntimeError("Rune runtime references are opaque and do not expose properties.", objectNode, "InvalidDataValue") - } - - if (typeof objectValue !== "object" || objectValue === null) { - throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode) - } - - if (typeof key === "string" && isBlockedMember(key)) { - throw new InterpreterRuntimeError(`Property '${key}' is not available in Rune.`, propertyNode) - } - - if (Array.isArray(objectValue)) { - if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && (typeof key !== "number" && !/^\d+$/.test(key))) { - if (typeof key === "string" && retryableArrayMethods.has(key)) { - throw new InterpreterRuntimeError( - `Array.${key}(...) is not supported in Rune. Rewrite using map/filter/find/some/every/includes/join or a for...of loop.`, - propertyNode, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) - } - // Unknown property on an array reads as `undefined`, matching JS (`[1,2].foo === undefined`), - // instead of throwing — so defensive access under optional chaining behaves as expected. The - // retryable-method hint above still fires for real methods we don't support (e.g. splice). - return new ComputedValue(undefined) - } - return { target: objectValue, key } - } - - return { target: objectValue as SafeObject, key } - }) - } - - private readMember(node: AstNode): Effect.Effect { - return Effect.map(this.getMemberReference(node), (reference) => { - if (reference === OptionalShortCircuit) return OptionalShortCircuit - if (reference instanceof ComputedValue) return reference.value - if ( - reference === undefined || - reference instanceof ToolReference || - reference instanceof PromiseAllReference || - reference instanceof IntrinsicReference || - reference instanceof GlobalMethodReference || - reference instanceof ConsoleMethodReference - ) return reference - if (Array.isArray(reference.target)) { - if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { - return new IntrinsicReference(reference.target, reference.key) - } - return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)] - } - return reference.target[String(reference.key)] - }) - } - - private writeMember(node: AstNode, value: unknown): Effect.Effect { - return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value })) - } - - // Resolves the member reference EXACTLY ONCE (so a side-effecting object/key expression - // runs once), then lets `compute` decide whether to write — enabling compound assignment, - // updates, plain writes, and short-circuiting logical assignment to share one safe path. - private modifyMember( - node: AstNode, - compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>, - ): Effect.Effect { - const self = this - return Effect.gen(function*() { - const reference = yield* self.getMemberReference(node) - if ( - reference === OptionalShortCircuit || - reference instanceof ComputedValue || - reference === undefined || - reference instanceof ToolReference || - reference instanceof PromiseAllReference || - reference instanceof IntrinsicReference || - reference instanceof GlobalMethodReference || - reference instanceof ConsoleMethodReference - ) { - throw new InterpreterRuntimeError("Only data fields may be assigned in Rune.", node) - } - if (Array.isArray(reference.target)) { - if (reference.key === "length") throw new InterpreterRuntimeError("Array length cannot be assigned in Rune.", node) - if (typeof reference.key === "string" && arrayMethods.has(reference.key)) { - throw new InterpreterRuntimeError("Array methods cannot be assigned in Rune.", node) - } - } - const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key) - const current = (reference.target as Record)[key] - const { write, next, result } = yield* compute(current) - if (write) self.assignToReference(reference, key, next, node) - return result - }) - } - - // Writes `next` to a resolved member, enforcing index/capacity/byte limits and rolling - // back the mutation if the bound is exceeded (so a caught error can't leave it grown). - // Byte size of a container, cached after the first walk and maintained incrementally by the - // mutation helpers. O(1) on a cache hit; O(container) once on the first touch. - private cachedContainerBytes(container: object, node: AstNode): number { - const cached = this.containerSizes.get(container) - if (cached !== undefined) return cached - const bytes = runtimeValueBytes(container, "value", node, this.limits) - this.recordWork(workUnits(container), node) - this.containerSizes.set(container, bytes) - return bytes - } - - // Bytes a value contributes when nested one level inside a container; also enforces that the - // nested value's depth stays within maxValueDepth. O(value), independent of the container size. - private nestedValueBytes(value: unknown, label: string, node: AstNode): number { - return runtimeValueBytes(value, label, node, this.limits, 1) - } - - private rejectCircularInsertion(container: object, value: unknown, label: string, node: AstNode, seen = new Set()): void { - if (value === container) throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") - if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return - seen.add(value) - const items = Array.isArray(value) ? value : Object.values(value) - for (const item of items) this.rejectCircularInsertion(container, item, label, node, seen) - seen.delete(value) - } - - // Add `addedBytes` of new entries to a container, rejecting (before any mutation) if that would - // exceed maxDataBytes, then record the container's new cached size. - private growContainerBytes(container: object, addedBytes: number, node: AstNode, label: string): void { - const next = this.cachedContainerBytes(container, node) + addedBytes - if (next > this.limits.maxDataBytes) { - throw new InterpreterRuntimeError(`${label} exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") - } - this.containerSizes.set(container, next) - } - - private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void { - if (Array.isArray(reference.target)) { - const target = reference.target - const index = key as number - if (!Number.isInteger(index) || index < 0 || index >= this.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Array assignment index must be between 0 and ${this.limits.maxCollectionLength - 1}.`, node, "InvalidDataValue") - } - this.rejectCircularInsertion(target, next, "Array assignment result", node) - const addedBytes = this.nestedValueBytes(next, "Array assignment result", node) - if (index === target.length) { - // Append — the hot path; O(1) incremental size update (this is the O(n^2)-loop fix). - this.growContainerBytes(target, addedBytes + 1, node, "Array assignment result") - target[index] = next - } else if (index < target.length) { - // Replace an existing slot (value or hole): adjust by the byte delta. - const oldBytes = this.nestedValueBytes(target[index], "Array assignment result", node) - const nextSize = this.cachedContainerBytes(target, node) + addedBytes - oldBytes - if (nextSize > this.limits.maxDataBytes) { - throw new InterpreterRuntimeError(`Array assignment result exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") - } - this.containerSizes.set(target, nextSize) - target[index] = next - } else { - // index > length introduces holes; fall back to a full revalidation and reset the cache. - const previousLength = target.length - target[index] = next - try { - boundedProgramValue(target, "Array assignment result", node, this.limits) - this.containerSizes.set(target, runtimeValueBytes(target, "value", node, this.limits)) - } catch (error) { - delete target[index] - target.length = previousLength - throw error - } - } - return - } - const target = reference.target as SafeObject - const objectKey = key as string - this.rejectCircularInsertion(target, next, "Object assignment result", node) - const addedBytes = this.nestedValueBytes(next, "Object assignment result", node) - if (Object.hasOwn(target, objectKey)) { - const oldBytes = this.nestedValueBytes(target[objectKey], "Object assignment result", node) - const nextSize = this.cachedContainerBytes(target, node) + addedBytes - oldBytes - if (nextSize > this.limits.maxDataBytes) { - throw new InterpreterRuntimeError(`Object assignment result exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") - } - this.containerSizes.set(target, nextSize) - target[objectKey] = next - return - } - const count = (this.objectCounts.get(target) ?? Object.keys(target).length) + 1 - if (count > this.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Object assignment exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - this.growContainerBytes(target, dataByteLength(objectKey) + addedBytes + 1, node, "Object assignment result") - this.objectCounts.set(target, count) - target[objectKey] = next - } - - private toPropertyKey(value: unknown, node: AstNode): string | number { - if (typeof value === "string" || typeof value === "number") { - return value - } - - throw new InterpreterRuntimeError("Property key must be a string or number.", node) - } - - private declare(name: string, value: unknown, mutable: boolean, node: AstNode): void { - const scope = this.currentScope() - - // A pre-seeded parameter slot (initialized === false) is being bound for the first time; - // anything else already present is a genuine duplicate declaration. - const existing = scope.get(name) - if (existing && existing.initialized !== false) { - throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node) - } - - scope.set(name, { mutable, value, initialized: true }) - } - - private getIdentifierValue(name: string, node: AstNode): unknown { - const binding = this.resolveBinding(name) - - if (!binding) { - throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node) - } - - // A parameter default that forward-references a later (not-yet-bound) parameter — JS TDZ. - if (binding.initialized === false) { - throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node) - } - - return binding.value - } - - private setIdentifierValue(name: string, value: unknown, node: AstNode): unknown { - const binding = this.resolveBinding(name) - - if (!binding) { - throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node) - } - - if (!binding.mutable) { - throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node) - } - - binding.value = value - return value - } - - private resolveBinding(name: string): Binding | undefined { - for (let index = this.scopes.length - 1; index >= 0; index -= 1) { - const scope = this.scopes[index] - const binding = scope?.get(name) - - if (binding) { - return binding - } - } - - return undefined - } - - private currentScope(): Map { - const scope = this.scopes[this.scopes.length - 1] - - if (!scope) { - throw new InterpreterRuntimeError("Interpreter scope stack is empty.") - } - - return scope - } - - private pushScope(): void { - this.scopes.push(new Map()) - } - - private popScope(): void { - this.scopes.pop() - } - - private forkForParallelCallback(): Interpreter { - const fork = new Interpreter(this.limits, this.invokeTool, this.budget, this.logs) - fork.scopes.splice( - 0, - fork.scopes.length, - ...this.scopes.map((scope) => new Map(Array.from(scope, ([name, binding]) => [name, { ...binding }]))), - ) - return fork - } - - private recordOperation(node: AstNode): void { - this.recordWork(1, node) - } - - // Charge `units` of work to the operation budget so O(n) built-ins (collection/string - // walks and spreads) are bounded by maxOperations, not only by the wall-clock timeout. - private recordWork(units: number, node?: AstNode): void { - this.budget.operations += Math.max(1, Math.ceil(units)) - - if (this.budget.operations > this.limits.maxOperations) { - throw new InterpreterRuntimeError(`Execution exceeded its operation limit of ${this.limits.maxOperations}.`, node, "OperationLimitExceeded") - } - } -} - -/** - * Executes one Effect-native Rune Program without constructing a reusable runtime. - * - * @example - * ```ts - * const result = yield* Rune.execute({ - * tools: { lookup }, - * code: `return await tools.lookup({ id: "order_42" })`, - * }) - * ``` - */ -export const execute = >(options: ExecuteOptions): Effect.Effect> => { - const limits = resolveExecutionLimits(options.limits) - ToolRuntime.assertValidTools((options.tools ?? {}) as HostTools>) - const tools = ToolRuntime.make((options.tools ?? {}) as HostTools>, limits.maxToolCalls, limits) - // Lives in the outer scope (not the interpreter) so console output is surfaced on every - // result path, including timeout and failure where the interpreter instance is unreachable. - const logs = new LogCollector(limits.maxAuditBytes) - - if (new TextEncoder().encode(options.code).byteLength > limits.maxSourceBytes) { - return Effect.succeed({ - ok: false, - error: { kind: "InvalidDataValue", message: `Code exceeds the maximum source size of ${limits.maxSourceBytes} bytes.` }, - toolCalls: tools.calls, - logs: logs.entries, - }) - } - - if (options.code.trim().length === 0) { - return Effect.succeed({ - ok: false, - error: { kind: "ParseError", message: "Code cannot be empty." }, - toolCalls: tools.calls, - logs: logs.entries, - }) - } - - const operation = Effect.gen(function*() { - const program = parseProgram(options.code) - const interpreter = new Interpreter>(limits, tools.invoke, undefined, logs) - const value = yield* interpreter.run(program) - const copied = copyIn(value, "Execution result", limits) - if (dataByteLength(copied) > limits.maxDataBytes) { - throw new InterpreterRuntimeError(`Execution result exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, undefined, "InvalidDataValue") - } - return { - ok: true, - value: copyOut(copied), - toolCalls: tools.calls, - logs: logs.entries, - } satisfies ExecuteResult - }).pipe( - Effect.timeoutOrElse({ - duration: limits.timeoutMs, - orElse: () => Effect.succeed({ - ok: false, - error: { kind: "TimeoutExceeded", message: `Execution timed out after ${limits.timeoutMs}ms.` }, - toolCalls: tools.calls, - logs: logs.entries, - } satisfies ExecuteResult), - }), - ) - - return operation.pipe( - Effect.matchCause({ - onFailure: (cause): ExecuteResult => ({ - ok: false, - error: normalizeError(Cause.squash(cause)), - toolCalls: tools.calls, - logs: logs.entries, - }), - onSuccess: (result): ExecuteResult => result, - }), - ) -} - -/** - * Creates an Effect-native runtime over explicit, schema-described capabilities. - * - * Use `run` for host-driven execution or `asTool` to expose one confined code tool to an - * agent framework. Capability requirements remain in the returned Effect environment. - * - * @example - * ```ts - * const rune = Rune.make({ tools: { orders: { lookup } } }) - * const code = rune.asTool() - * ``` - */ -export const make = = {}>(options: RuneOptions = {} as RuneOptions): Rune> => { - ToolRuntime.assertValidTools((options.tools ?? {}) as HostTools>) - const run = (code: string) => execute({ ...options, code }) - - return { - catalog: () => ToolRuntime.catalog((options.tools ?? {}) as HostTools>), - instructions: () => ToolRuntime.instructions((options.tools ?? {}) as HostTools>), - asTool: () => ({ - name: "code", - description: ToolRuntime.instructions((options.tools ?? {}) as HostTools>), - input: CodeInput, - execute: ({ code }) => run(code), - }), - run, - } -} - -export const Rune = { make, execute } diff --git a/packages/opencode/src/session/rune/tool-runtime.ts b/packages/opencode/src/session/rune/tool-runtime.ts deleted file mode 100644 index 398da3100591..000000000000 --- a/packages/opencode/src/session/rune/tool-runtime.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { Effect, Schema } from "effect" -import { isDefinition as isToolDefinition, toTypeScript, type Definition } from "./tool.js" - -export type HostTool = (...args: Array) => Effect.Effect - -export type HostTools = { - [name: string]: HostTool | Definition | HostTools -} - -export type Services = Tools extends (...args: Array) => Effect.Effect - ? R - : Tools extends { readonly _tag: "RuneTool"; readonly run: (input: unknown) => Effect.Effect } - ? R - : Tools extends object - ? string extends keyof Tools ? never : Services - : never - -export type ToolCall = { - readonly name: string -} - -export type ToolDescription = { - readonly path: string - readonly description: string - readonly signature: string -} - -export type SafeObject = Record - -export class ToolReference { - constructor(readonly path: ReadonlyArray) {} -} - -export type DataLimits = { - readonly maxValueDepth: number - readonly maxCollectionLength: number - readonly maxDataBytes: number - readonly maxAuditBytes: number -} - -export class ToolRuntimeError extends Error { - constructor( - readonly kind: "UnknownCapability" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded" | "AuditLimitExceeded", - message: string, - readonly suggestions: ReadonlyArray = [], - ) { - super(message) - this.name = "ToolRuntimeError" - } -} - -const isDefinition = (value: HostTool | Definition | HostTools): value is Definition => - isToolDefinition(value) - -const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"]) - -export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name) - -export const copyIn = (value: unknown, label: string, limits?: DataLimits, depth = 0, seen = new Set()): unknown => { - if (limits && depth > limits.maxValueDepth) { - throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${limits.maxValueDepth}.`) - } - if ( - value === null || - value === undefined || - typeof value === "string" || - typeof value === "boolean" || - // NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real - // engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are - // normalized to `null` when the value leaves the sandbox — see copyOut — exactly as - // JSON.stringify already does at any tool boundary. - typeof value === "number" - ) { - return value - } - - if (typeof value !== "object") { - throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`) - } - - if (seen.has(value)) { - throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`) - } - - seen.add(value) - - if (Array.isArray(value)) { - if (limits && value.length > limits.maxCollectionLength) { - throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`) - } - const copied = value.map((item) => copyIn(item, label, limits, depth + 1, seen)) - seen.delete(value) - return copied - } - - const prototype = Object.getPrototypeOf(value) - if (prototype !== Object.prototype && prototype !== null) { - throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`) - } - - const copied: SafeObject = Object.create(null) as SafeObject - const entries = Object.entries(value) - if (limits && entries.length > limits.maxCollectionLength) { - throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`) - } - for (const [key, item] of entries) { - if (isBlockedMember(key)) { - throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`) - } - copied[key] = copyIn(item, label, limits, depth + 1, seen) - } - seen.delete(value) - return copied -} - -export const copyOut = (value: unknown): unknown => { - // Normalize non-finite numbers to null as the value crosses out of the sandbox (final return - // and tool-call arguments both funnel through here), matching JSON semantics — NaN/Infinity - // have no JSON representation, so JSON.stringify would produce null anyway. - if (typeof value === "number" && !Number.isFinite(value)) { - return null - } - - if (Array.isArray(value)) { - return value.map(copyOut) - } - - if (value !== null && typeof value === "object" && !(value instanceof ToolReference)) { - return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyOut(item)])) - } - - return value -} - -const definitions = (tools: HostTools, path: ReadonlyArray = []): Array<{ path: string; definition: Definition }> => { - const entries: Array<{ path: string; definition: Definition }> = [] - for (const [name, value] of Object.entries(tools)) { - const next = [...path, name] - if (isDefinition(value)) entries.push({ path: next.join("."), definition: value }) - else if (typeof value !== "function") entries.push(...definitions(value, next)) - } - return entries -} - -const describeDefinition = (path: string, definition: Definition): ToolDescription => ({ - path, - description: definition.description, - signature: `tools.${path}(input: ${toTypeScript(definition.input)}): Promise<${toTypeScript(definition.output, true)}>`, - }) - -const visibleDefinitions = (tools: HostTools) => - definitions(tools).flatMap(({ path, definition }) => { - const description = describeDefinition(path, definition) - return [{ path, definition, description }] - }) - -export const catalog = (tools: HostTools): ReadonlyArray => - visibleDefinitions(tools).map(({ description }) => description) - -// 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 => {} - -// Generic Rune-language instructions. Discovery (e.g. searching a large or dynamic -// catalog) is not a runtime feature; an embedder that wants it registers ordinary -// host tools and documents them itself, so nothing is hardcoded here. -export const instructions = (tools: HostTools): string => { - const described = catalog(tools) - const lines = [ - "Write a Rune Program to answer the request. Return code only.", - "Rune Programs can call explicit tools.* capabilities and transform plain data.", - "Tool Capability calls are async; prefer explicit await unless the call is inside Promise.all(...).", - "", - "Available Tool Capabilities:", - ...described.map((tool) => `- ${tool.signature} // ${tool.description}`), - "", - "Common syntax: arrow functions and `function` declarations (hoisted) with closures, default/rest parameters, destructuring (incl. rest/defaults), optional chaining, template literals, conditionals, switch, loops, spread (arrays/objects/strings), try/catch, ternary, the `in` operator, logical assignment (??=/||=/&&=), and bitwise operators (& | ^ ~ << >> >>>). Signal failure with `throw` (any value) or `throw new Error(message)`.", - "Transform data with array methods (map/filter/reduce/reduceRight/flatMap/forEach/find/findIndex/findLast/findLastIndex/sort/toSorted/slice/concat/indexOf/at/flat/reverse/toReversed/with/includes/join, plus push/pop/shift/unshift for accumulation), string methods (toLowerCase/toUpperCase/trim/split/slice/substring/replace/replaceAll/includes/startsWith/endsWith/indexOf/padStart/padEnd/repeat/charCodeAt), number methods (toFixed/toString(radix)/toPrecision), Object.keys/values/entries/fromEntries/hasOwn, Math.* (incl. PI/E), JSON.parse/stringify, Array.from/isArray/of, Number.isInteger/isNaN/parseInt, String.fromCharCode, parseInt/parseFloat, and Number/String/Boolean.", - "Use Promise.all([...]) for parallel tool calls (a direct array of calls, or items.map((item) => tool call)).", - ] - return lines.join("\n") -} - -const resolve = (tools: HostTools, path: ReadonlyArray): HostTool | Definition => { - let value: HostTool | Definition | HostTools = tools - - for (const segment of path) { - if (isBlockedMember(segment) || typeof value === "function" || isDefinition(value) || !Object.hasOwn(value, segment)) { - throw new ToolRuntimeError("UnknownCapability", `Unknown tool '${path.join(".")}'.`, ["Call a capability by its exact tools.* path."]) - } - value = value[segment] as HostTool | Definition | HostTools - } - - if (typeof value !== "function" && !isDefinition(value)) { - throw new ToolRuntimeError("UnknownCapability", `Tool '${path.join(".")}' is not callable.`) - } - - return value -} - -export type ToolRuntime = { - readonly root: ToolReference - readonly calls: Array - readonly invoke: (path: ReadonlyArray, args: Array) => Effect.Effect -} - -export const dataByteLength = (value: unknown): number => - new TextEncoder().encode(JSON.stringify(value) ?? "").byteLength - -export const make = ( - tools: HostTools, - maxToolCalls: number, - dataLimits: DataLimits, -): ToolRuntime => { - const calls: Array = [] - let auditBytes = 0 - - const checkedCopyIn = (value: unknown, label: string): unknown => { - const copied = copyIn(value, label, dataLimits) - if (dataByteLength(copied) > dataLimits.maxDataBytes) { - throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds ${dataLimits.maxDataBytes} bytes.`) - } - return copied - } - - const recordCall = (call: ToolCall): void => { - if (calls.length >= maxToolCalls) { - throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`) - } - const auditEntryBytes = dataByteLength(call) - if (auditBytes + auditEntryBytes > dataLimits.maxAuditBytes) { - throw new ToolRuntimeError("AuditLimitExceeded", `Execution exceeds its audit-trail limit of ${dataLimits.maxAuditBytes} bytes.`) - } - auditBytes += auditEntryBytes - calls.push(call) - } - - return { - root: new ToolReference([]), - calls, - invoke: (path, args) => - Effect.gen(function*() { - const name = path.join(".") - const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`, dataLimits))) - const argumentBytes = dataByteLength(externalArgs) - if (argumentBytes > dataLimits.maxDataBytes) { - throw new ToolRuntimeError("InvalidDataValue", `Arguments for tool '${name}' exceed ${dataLimits.maxDataBytes} bytes.`) - } - const call = { name } - const tool = resolve(tools, path) - let describedInput: unknown - if (isDefinition(tool)) { - if (externalArgs.length !== 1) throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`) - describedInput = yield* Effect.try({ - try: () => Schema.decodeUnknownSync(tool.input)(externalArgs[0]), - catch: (cause) => new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`), - }) - } - recordCall(call) - if (isDefinition(tool)) { - const raw = yield* tool.run(describedInput) - const result = yield* Effect.try({ - try: () => Schema.decodeUnknownSync(tool.output)(raw), - catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${String(cause)}`), - }) - return checkedCopyIn(result, `Result from tool '${name}'`) - } - const result = yield* tool(...externalArgs) - return checkedCopyIn(result, `Result from tool '${name}'`) - }), - } -} - -export * as ToolRuntime from "./tool-runtime.js" diff --git a/packages/opencode/src/session/rune/tool.ts b/packages/opencode/src/session/rune/tool.ts deleted file mode 100644 index 0be76b18ed02..000000000000 --- a/packages/opencode/src/session/rune/tool.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Effect, Schema } from "effect" - -export type Definition = { - readonly _tag: "RuneTool" - readonly description: string - readonly input: Schema.Decoder - readonly output: Schema.Decoder - readonly run: (input: unknown) => Effect.Effect -} - -export type Options, O extends Schema.Decoder, R = never> = { - readonly description: string - readonly input: I - readonly output: O - readonly run: (input: I["Type"]) => Effect.Effect -} - -export const isDefinition = (value: unknown): value is Definition => - typeof value === "object" && value !== null && "_tag" in value && value._tag === "RuneTool" - -type JsonSchema = { - readonly type?: string | ReadonlyArray - readonly enum?: ReadonlyArray - readonly const?: unknown - readonly anyOf?: ReadonlyArray - readonly oneOf?: ReadonlyArray - readonly properties?: Readonly> - readonly required?: ReadonlyArray - readonly items?: JsonSchema - readonly additionalProperties?: boolean | JsonSchema - readonly $ref?: string -} - -const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown" - -const renderSchema = (schema: JsonSchema, definitions: Readonly>): string => { - if (schema.$ref) { - const name = schema.$ref.split("/").pop() - return name && definitions[name] ? renderSchema(definitions[name], definitions) : name ?? "unknown" - } - if (schema.const !== undefined) return renderLiteral(schema.const) - if (schema.enum) return schema.enum.map(renderLiteral).join(" | ") - const alternatives = schema.anyOf ?? schema.oneOf - if (alternatives) { - if (alternatives.some((item) => item.type === "number")) return "number" - return alternatives.map((item) => renderSchema(item, definitions)).join(" | ") - } - if (Array.isArray(schema.type)) return schema.type.map((item) => renderSchema({ type: item }, definitions)).join(" | ") - if (schema.type === "string") return "string" - if (schema.type === "number" || schema.type === "integer") return "number" - if (schema.type === "boolean") return "boolean" - if (schema.type === "null") return "null" - if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, definitions)}>` - if (schema.type === "object" || schema.properties) { - const required = new Set(schema.required ?? []) - const fields = Object.entries(schema.properties ?? {}).map(([name, value]) => - `${name}${required.has(name) ? "" : "?"}: ${renderSchema(value, definitions)}`) - if (schema.additionalProperties && typeof schema.additionalProperties === "object") { - fields.push(`[key: string]: ${renderSchema(schema.additionalProperties, definitions)}`) - } - return `{ ${fields.join("; ")} }` - } - return "unknown" -} - -export const toTypeScript = (schema: Schema.Top, decoded = false): string => { - const visible = decoded ? Schema.toType(schema) : schema - const document = Schema.toJsonSchemaDocument(visible) as { - readonly schema: JsonSchema - readonly definitions?: Readonly> - } - return renderSchema(document.schema, document.definitions ?? {}) -} - -/** - * Defines one schema-described capability available to a Rune Program through `tools.*`. - * - * `input` is decoded before `run` is invoked. `run` returns the encoded representation of - * `output`, which Rune decodes before returning it to the program. The host capability remains - * responsible for authorization and durable side-effect handling. - * - * @example - * ```ts - * const lookup = Tool.make({ - * description: "Look up an order", - * input: Schema.Struct({ id: Schema.String }), - * output: Schema.Struct({ status: Schema.String }), - * run: ({ id }) => Effect.succeed({ status: "open" }), - * }) - * ``` - */ -export const make = , O extends Schema.Decoder, R>(options: Options): Definition => ({ - _tag: "RuneTool", - description: options.description, - input: options.input, - output: options.output, - run: (input) => options.run(input as I["Type"]), -}) - -export * as Tool from "./tool.js" diff --git a/packages/opencode/test/session/code-mode-integration.test.ts b/packages/opencode/test/session/code-mode-integration.test.ts index 0d8adf32c75b..ade51f652611 100644 --- a/packages/opencode/test/session/code-mode-integration.test.ts +++ b/packages/opencode/test/session/code-mode-integration.test.ts @@ -8,7 +8,11 @@ import { MessageID, SessionID } from "@/session/schema" import { Server } from "@modelcontextprotocol/sdk/server/index.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { CallToolRequestSchema, ListToolsRequestSchema, type Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" +import { + CallToolRequestSchema, + ListToolsRequestSchema, + type Tool as MCPToolDef, +} from "@modelcontextprotocol/sdk/types.js" import type { Tool as AITool } from "ai" import { Effect, Layer } from "effect" @@ -113,49 +117,41 @@ 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.$rune.describe('fixtures.add')") - const desc = JSON.parse(out.output) - expect(desc.path).toBe("fixtures.add") - expect(desc.signature).toBe( - "tools.fixtures.add(input: { a: number; b: number }): Promise>", + test("the tool description inlines full signatures with real MCP schemas", () => { + expect(tool.description).toContain("Available tools (COMPLETE list") + expect(tool.description).toContain("- fixtures (4 tools)") + expect(tool.description).toContain( + "tools.fixtures.add(input: { a: number; b: number }): Promise<{ sum: number }>", ) - // describe returns TypeScript for the input/output types, not raw JSON Schema. - expect(desc.input).toBe("{\n a: number\n b: number\n}") - expect(desc.output).toBe("{\n sum: number\n}") - expect(desc.outputSchema).toBeUndefined() + expect(tool.description).toContain("tools.fixtures.get_text(input: { name: string }): Promise") + expect(tool.description).toContain("// Add two numbers and return the structured sum") + // Small catalog: everything is inline, so no discovery tool is advertised. + expect(tool.description).not.toContain("$codemode") + // The workflow section is present with placeholder-only call forms. + expect(tool.description).toContain("## Workflow") + expect(tool.description).toContain("`const res = await tools..(input)`") + expect(tool.description).not.toContain("total_count") }) - test("describe falls back to result: unknown when no outputSchema is declared", async () => { - const out = await run("return await tools.$rune.describe('fixtures.get_text')") - const desc = JSON.parse(out.output) - expect(desc.signature).toContain("Promise>") - }) - - test("search finds a tool by keyword", async () => { - 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") - expect(out.metadata.toolCalls).toEqual([{ tool: "$rune.search", status: "completed", input: { query: "screenshot" } }]) - }) - - test("calls a text tool and unwraps the result envelope", async () => { - const out = await run("const r = await tools.fixtures.get_text({ name: 'world' }); return r.result") + test("calls a text tool and receives its text as the native result", async () => { + const out = await run("const r = await tools.fixtures.get_text({ name: 'world' }); return r") expect(out.output).toBe("hello world") - expect(out.metadata.toolCalls).toEqual([{ tool: "fixtures.get_text", status: "completed", input: { name: "world" } }]) + expect(out.metadata.toolCalls).toEqual([ + { tool: "fixtures.get_text", status: "completed", input: { name: "world" } }, + ]) expect(out.attachments).toBeUndefined() }) - test("exposes structured data from a tool with an outputSchema", async () => { - const out = await run("const r = await tools.fixtures.add({ a: 2, b: 3 }); return r.result.sum") + test("exposes structured data natively from a tool with an outputSchema", async () => { + const out = await run("const r = await tools.fixtures.add({ a: 2, b: 3 }); return r.sum") expect(out.output).toBe("5") }) test("composes multiple structured calls and returns a plain object", async () => { const out = await run(` const first = await tools.fixtures.add({ a: 1, b: 2 }) - const second = await tools.fixtures.add({ a: first.result.sum, b: 10 }) - return { total: second.result.sum } + const second = await tools.fixtures.add({ a: first.sum, b: 10 }) + return { total: second.sum } `) expect(JSON.parse(out.output)).toEqual({ total: 13 }) expect(out.metadata.toolCalls).toEqual([ @@ -164,43 +160,38 @@ describe("code mode integration (real MCP server)", () => { ]) }) - test("forwards an image as an attachment when the whole result is returned", async () => { + test("an image result becomes an execute attachment and a marker in the sandbox", async () => { const out = await run("return await tools.fixtures.screenshot({})") + expect(out.output).toBe("[1 image attached to the result]") expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: `data:image/png;base64,${PNG}` }]) }) - test("an attachment is an opaque handle: metadata only, no readable bytes", async () => { - // The program sees mime/bytes but NOT the data — a stray return can't leak base64. + test("image bytes never enter the sandbox or the model-facing output", async () => { const out = await run(` const shot = await tools.fixtures.screenshot({}) - const a = shot.attachments[0] - return { result: { mime: a.mime, hasUrl: 'url' in a, hasData: 'data' in a, bytes: a.bytes, keys: Object.keys(a).sort() } } + return { sawMarker: typeof shot === 'string' && shot.includes('attached'), value: shot } `) expect(JSON.parse(out.output)).toEqual({ - mime: "image/png", - hasUrl: false, - hasData: false, - bytes: Buffer.from(PNG, "base64").byteLength, - keys: ["bytes", "id", "mime", "type"], + sawMarker: true, + value: "[1 image attached to the result]", }) - // Returning the handle inside `.result` (not as an attachment) surfaces no media - // and — crucially — carries no base64, so nothing large re-enters the conversation. - expect(out.attachments).toBeUndefined() expect(out.output).not.toContain(PNG) + // The stripped image still arrives as a real attachment. + expect(out.attachments).toHaveLength(1) }) - test("drops media when only .result is returned", async () => { - const out = await run("const r = await tools.fixtures.screenshot({}); return { result: 'captured' }") + test("attachments accumulate even when the program returns something else", async () => { + const out = await run("await tools.fixtures.screenshot({}); return 'captured'") expect(out.output).toBe("captured") - expect(out.attachments).toBeUndefined() + expect(out.attachments).toHaveLength(1) }) - test("runs calls in parallel and forwards multiple attachments the model curates", async () => { + test("runs calls in parallel and accumulates every attachment", async () => { const out = await run(` - const [a, b] = await Promise.all([tools.fixtures.screenshot({}), tools.fixtures.screenshot({})]) - return { result: 'two shots', attachments: [...(a.attachments ?? []), ...(b.attachments ?? [])] } + const both = await Promise.all([tools.fixtures.screenshot({}), tools.fixtures.screenshot({})]) + return 'two shots: ' + both.length `) - expect(out.output).toBe("two shots") + expect(out.output).toBe("two shots: 2") expect(out.attachments).toHaveLength(2) expect(out.metadata.toolCalls.map((c) => c.tool)).toEqual(["fixtures.screenshot", "fixtures.screenshot"]) }) @@ -220,10 +211,10 @@ describe("code mode integration (real MCP server)", () => { const out = await run(` console.log("looking up", { name: "world" }) const r = await tools.fixtures.get_text({ name: "world" }) - console.warn("got", r.result) - return r.result + console.warn("got", r) + return r `) - expect(out.output).toBe('hello world\n\nLogs:\n[log] looking up {"name":"world"}\n[warn] got hello world') + expect(out.output).toBe('hello world\n\nLogs:\nlooking up {"name":"world"}\n[warn] got hello world') expect(out.metadata.error).toBeUndefined() }) @@ -235,7 +226,7 @@ describe("code mode integration (real MCP server)", () => { `) expect(out.metadata.error).toBe(true) expect(out.output).toContain("kaboom") - expect(out.output).toContain("Logs:\n[log] before the throw") + expect(out.output).toContain("Logs:\nbefore the throw") }) test("a program that logs nothing gets no Logs section", async () => { @@ -246,26 +237,42 @@ describe("code mode integration (real MCP server)", () => { test("console does not consume the tool-call metadata (logging is not a tool call)", async () => { const out = await run("console.log('hi'); console.error('bye'); return 'ok'") - expect(out.output).toBe("ok\n\nLogs:\n[log] hi\n[error] bye") + expect(out.output).toBe("ok\n\nLogs:\nhi\n[error] bye") expect(out.metadata.toolCalls).toEqual([]) }) - test("asks permission for each MCP call but not for discovery helpers", async () => { + test("asks permission for each MCP call, keyed by the flat catalog name", async () => { const asked: string[] = [] const permCtx: Tool.Context = { ...ctx, ask: (req: any) => Effect.sync(() => void asked.push(req.permission)) } await Effect.runPromise( tool.execute( { code: ` - await tools.$rune.search('add') - await tools.$rune.describe('fixtures.add') await tools.fixtures.add({ a: 1, b: 1 }) + await tools.fixtures.get_text({ name: 'x' }) return 'done' `, }, permCtx, ), ) - expect(asked).toEqual(["fixtures_add"]) + expect(asked).toEqual(["fixtures_add", "fixtures_get_text"]) + }) + + test("streams running/completed metadata for child calls over a real transport", async () => { + const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record }[] }> = [] + const recordingCtx: Tool.Context = { + ...ctx, + metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)), + } + await Effect.runPromise( + tool.execute({ code: "await tools.fixtures.add({ a: 1, b: 2 }); return 'done'" }, recordingCtx), + ) + expect(snapshots).toContainEqual({ + toolCalls: [{ tool: "fixtures.add", status: "running", input: { a: 1, b: 2 } }], + }) + expect(snapshots).toContainEqual({ + toolCalls: [{ tool: "fixtures.add", status: "completed", input: { a: 1, b: 2 } }], + }) }) }) diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index fac8e9e5d080..b6b771862319 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -1,16 +1,12 @@ import { describe, expect, test } from "bun:test" import { Parameters, - attachmentTable, define, - describe as describeTools, formatValue, groupByServer, - rankTools, - renderType, - toEnvelope, + toSandboxResult, withLogs, - type SearchEntry, + type Attachment, } from "@/session/code-mode" import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import { Agent } from "@/agent/agent" @@ -32,7 +28,7 @@ const ctx: Tool.Context = { ask: () => Effect.void, } -// Build a real MCP-derived AI SDK tool over a fake transport, so the proxy exercises +// Build a real MCP-derived AI SDK tool over a fake transport, so the adapter exercises // the same `convertTool` execution path that `mcp.tools()` produces at runtime. function mcpTool( name: string, @@ -68,60 +64,31 @@ describe("code mode execute", () => { await expect(Effect.runPromise(decode({}))).rejects.toThrow() }) - test("lists all namespaces, previews tool signatures within budget, and documents discovery", () => { - const groups = groupByServer( - { - github_create_issue: mcpTool("create_issue", () => "", { - type: "object", - properties: { title: { type: "string" }, body: { type: "string" } }, - required: ["title"], - }), - github_list_issues: mcpTool("list_issues", () => ""), - linear_search: mcpTool("search", () => ""), - }, - ["github", "linear"], - ) - const description = describeTools(groups) - - expect(description).toContain("tools.$rune.search(query") - expect(description).toContain("tools.$rune.describe(path)") - // Small catalog: the list is comprehensive and says so, with clean counts. - expect(description).toContain("This is the COMPLETE list") - expect(description).toContain("- github (2 tools)") - expect(description).toContain("- linear (1 tool)") - // Tools are previewed inline as directly-callable signatures that now include the - // awaited return type (Result) so the model sees the result shape up front. - expect(description).toContain("tools.github.create_issue(input: { title: string; body?: string }): Result") - expect(description).toContain("tools.linear.search(input: object): Result") - // The Result envelope alias is defined once in the prose. - expect(description).toContain("type Result = { result: T; attachments?: Attachment[] }") - // ...but the preview drops the uniform Promise<…> wrapper — that full form comes from describe. - expect(description).not.toContain("): Promise<") - }) - - test("falls back to namespaces-only when the catalog exceeds the preview budget", () => { - const tools: Record = {} - for (let i = 0; i < 60; i++) { - tools[`alpha_op_${i}`] = mcpTool(`op_${i}`, () => "", { - type: "object", - properties: { value: { type: "string" }, count: { type: "number" } }, - }) - } - tools["zeta_only_tool"] = mcpTool("only_tool", () => "") - const groups = groupByServer(tools, ["alpha", "zeta"]) - const description = describeTools(groups) + test("groups multi-underscore server names by longest matching prefix", () => { + const groups = groupByServer({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"]) + expect([...groups.keys()]).toEqual(["my_server"]) + expect(groups.get("my_server")![0]).toMatchObject({ + path: "my_server.do_thing", + local: "do_thing", + key: "my_server_do_thing", + }) + }) - // The list states it is partial, and every namespace is still present with its total. - expect(description).toContain("This is a PARTIAL list") - expect(description).toContain("- alpha (60 tools") - // The later namespace is fully truncated, and says so. - expect(description).toContain("- zeta (1 tool, none shown)") - expect(description).not.toContain("tools.zeta.only_tool(") - // Some early signatures are still previewed. - expect(description).toContain("tools.alpha.op_0(") + test("groupByServer carries the raw MCP schemas for rendering", () => { + const defs: Record = { + weather_current: { + name: "current", + inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, + outputSchema: { type: "object", properties: { tempC: { type: "number" } }, required: ["tempC"] }, + } as any, + } + const groups = groupByServer({ weather_current: mcpTool("current", () => "") }, ["weather"], defs) + const entry = groups.get("weather")![0]! + expect(entry.inputSchema).toEqual(defs.weather_current!.inputSchema as any) + expect(entry.outputSchema).toEqual(defs.weather_current!.outputSchema as any) }) - test("tools.$rune.search and tools.$rune.describe expose the catalog on demand", async () => { + test("small catalogs inline every full signature in the tool description", async () => { const tool = await build({ github_create_issue: mcpTool("create_issue", () => "", { type: "object", @@ -132,58 +99,88 @@ describe("code mode execute", () => { linear_search: mcpTool("search", () => ""), }) - const searched = await Effect.runPromise( - tool.execute({ code: "return await tools.$rune.search('issue', { namespace: 'github' })" }, ctx), + expect(tool.description).toContain("Available tools (COMPLETE list") + expect(tool.description).toContain("- github (2 tools)") + expect(tool.description).toContain("- linear (1 tool)") + expect(tool.description).toContain( + "tools.github.create_issue(input: { title: string; body?: string }): Promise", ) - 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"]) - expect(searched.metadata.toolCalls).toEqual([ - { tool: "$rune.search", status: "completed", input: { query: "issue", namespace: "github" } }, - ]) - - const described = await Effect.runPromise( - 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") - expect(desc.signature).toBe( - "tools.github.create_issue(input: { title: string; body?: string }): Promise>", + expect(tool.description).toContain("tools.github.list_issues(") + expect(tool.description).toContain("tools.linear.search(") + // A schema with no properties renders as an empty object, not `{ }`. + expect(tool.description).toContain("tools.linear.search(input: {}): Promise") + // Fully inlined catalog: no discovery round-trip is needed or advertised. + expect(tool.description).not.toContain("$codemode") + expect(tool.description).not.toContain("Browse one namespace") + // The workflow/rules sections use placeholder call forms only — the example machinery + // never cherry-picks a catalog tool or fabricates result fields. + expect(tool.description).toContain("## Workflow") + expect(tool.description).toContain("1. Pick a tool from the list under `## Available tools`") + expect(tool.description).toContain("if a result is a string, JSON.parse it before reading fields") + expect(tool.description).toContain("Return small: extract only the fields you need") + expect(tool.description).not.toContain("total_count") + }) + + test("signatures render the declared outputSchema as the return type", async () => { + const defs: Record = { + weather_current: { + name: "current", + inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, + outputSchema: { + type: "object", + properties: { tempC: { type: "number" }, summary: { type: "string" } }, + required: ["tempC"], + }, + } as any, + } + const tool = await build({ weather_current: mcpTool("current", () => "") }, defs) + expect(tool.description).toContain( + "tools.weather.current(input: { city: string }): Promise<{ tempC: number; summary?: string }>", ) - expect(described.metadata.toolCalls).toEqual([ - { tool: "$rune.describe", status: "completed", input: { path: "github.create_issue" } }, - ]) - - 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") - expect(missing.metadata.toolCalls).toEqual([ - { tool: "$rune.describe", status: "completed", input: { path: "github.nope" } }, - ]) }) - test("describe resolves a tool path regardless of separator (dot, slash, or underscore)", async () => { - const tool = await build({ "context7_resolve-library-id": mcpTool("resolve-library-id", () => "") }) - for (const path of ["context7.resolve-library-id", "context7/resolve-library-id", "context7_resolve-library-id"]) { - const described = await Effect.runPromise(tool.execute({ code: `return await tools.$rune.describe(${JSON.stringify(path)})` }, ctx)) - expect(JSON.parse(described.output).path).toBe("context7.resolve-library-id") + test("large catalogs inline a budgeted PARTIAL list plus runtime search", async () => { + const tools: Record = {} + const filler = "a searchable description of this operation that consumes catalog budget ".repeat(3) + for (let i = 0; i < 150; i++) { + const client = { callTool: async () => ({ content: [] }) } + tools[`alpha_op_${i}`] = McpCatalog.convertTool( + { + name: `op_${i}`, + description: `${filler}${i}`, + inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } }, + } as any, + client as any, + ) } - }) - - test("describe suggests the real tool for a mistyped path (did-you-mean)", async () => { - const tool = await build({ "context7_resolve-library-id": mcpTool("resolve-library-id", () => "") }) - // Wrong leaf within the right namespace falls back to a namespace-scoped search. - const missing = await Effect.runPromise( - tool.execute({ code: "return await tools.$rune.describe('context7/resolve-library')" }, ctx), + tools["zeta_only_tool"] = mcpTool("only_tool", () => "") + const tool = await build(tools, {}, ["alpha", "zeta"]) + + // Every namespace is listed with counts; signatures inline cheapest-first until the + // budget runs out, and the description states exactly how comprehensive the list is. + expect(tool.description).toContain("Available tools (PARTIAL — ") + expect(tool.description).toMatch(/- alpha \(150 tools, \d+ shown\)/) + expect(tool.description).toContain("- zeta (1 tool, none shown)") + expect(tool.description).toContain("tools.$codemode.search(") + // PARTIAL catalogs put search first in the workflow and advertise namespace browsing. + expect(tool.description).toContain("1. Find a tool (skip when it is already listed below)") + expect(tool.description).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.') + expect(tool.description).not.toContain("total_count") + // Cheapest signatures (single-digit ops) made the cut; the most expensive did not. + expect(tool.description).toContain("tools.alpha.op_0(") + expect(tool.description).not.toContain("tools.alpha.op_149(") + + // The runtime search tool works in-program and returns complete signatures. + const out = await Effect.runPromise( + tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3 })" }, ctx), ) - const error = JSON.parse(missing.output).error - expect(error.code).toBe("tool_not_found") - expect(error.suggestions).toContain("context7.resolve-library-id") - }) - - test("groups multi-underscore server names by longest matching prefix", () => { - const groups = groupByServer({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"]) - expect([...groups.keys()]).toEqual(["my_server"]) - expect(groups.get("my_server")![0]).toMatchObject({ local: "do_thing", key: "my_server_do_thing" }) + const result = JSON.parse(out.output) + // Search-result paths carry the `tools.` prefix so each is directly usable as a call site. + expect(result.items.map((i: any) => i.path)).toContain("tools.zeta.only_tool") + expect(result.items[0].signature).toContain("tools.") + expect(out.metadata.toolCalls).toEqual([ + { tool: "$codemode.search", status: "completed", input: { query: "only tool", limit: 3 } }, + ]) }) test("runs plain JavaScript and returns the value as text", async () => { @@ -193,6 +190,17 @@ describe("code mode execute", () => { expect(output.metadata.toolCalls).toEqual([]) }) + test("Object.keys(tools) enumerates the MCP server namespaces", async () => { + const tool = await build({ + github_list_issues: mcpTool("list_issues", () => ""), + linear_search: mcpTool("search", () => ""), + }) + const output = await Effect.runPromise( + tool.execute({ code: "const namespaces = Object.keys(tools); return { namespaces, count: namespaces.length }" }, ctx), + ) + expect(JSON.parse(output.output)).toEqual({ namespaces: ["github", "linear"], count: 2 }) + }) + test("calls a namespaced MCP tool and flows its text result back into the program", async () => { const seen: Record[] = [] const tool = await build({ @@ -203,15 +211,17 @@ describe("code mode execute", () => { }) const output = await Effect.runPromise( - tool.execute({ code: "const r = await tools.greeter.hello({ name: 'world' }); return r.result.toUpperCase()" }, ctx), + tool.execute({ code: "const r = await tools.greeter.hello({ name: 'world' }); return r.toUpperCase()" }, ctx), ) expect(seen).toEqual([{ name: "world" }]) expect(output.output).toBe("HELLO WORLD") - expect(output.metadata.toolCalls).toEqual([{ tool: "greeter.hello", status: "completed", input: { name: "world" } }]) + expect(output.metadata.toolCalls).toEqual([ + { tool: "greeter.hello", status: "completed", input: { name: "world" } }, + ]) }) - test("exposes structured content as data and composes multiple calls", async () => { + test("exposes structured content as native data and composes multiple calls", async () => { const tool = await build({ math_add: mcpTool("add", (args) => ({ content: [], @@ -224,8 +234,8 @@ describe("code mode execute", () => { { code: ` const first = await tools.math.add({ a: 1, b: 2 }) - const second = await tools.math.add({ a: first.result.sum, b: 10 }) - return { total: second.result.sum } + const second = await tools.math.add({ a: first.sum, b: 10 }) + return { total: second.sum } `, }, ctx, @@ -247,7 +257,7 @@ describe("code mode execute", () => { const output = await Effect.runPromise( tool.execute( - { code: "const [a, b] = await Promise.all([tools.echo.one({}), tools.echo.two({})]); return a.result + b.result" }, + { code: "const [a, b] = await Promise.all([tools.echo.one({}), tools.echo.two({})]); return a + b" }, ctx, ), ) @@ -264,23 +274,19 @@ describe("code mode execute", () => { expect(output.metadata.error).toBe(true) }) - test("reports an unknown tool and points to discovery", async () => { + test("reports an unknown tool as a failed execution", async () => { const tool = await build({ known_tool: mcpTool("tool", () => "ok") }) 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.$rune.search") }) - test("propagates an MCP tool error into the program", async () => { + test("propagates an MCP tool error into the program as a catchable failure", async () => { const tool = await build({ bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "server exploded" }] })), }) const output = await Effect.runPromise( - tool.execute( - { code: "try { await tools.bad.tool({}) } catch (e) { return 'caught: ' + e.message }" }, - ctx, - ), + tool.execute({ code: "try { await tools.bad.tool({}) } catch (e) { return 'caught: ' + e.message }" }, ctx), ) expect(output.output).toBe("caught: server exploded") }) @@ -298,50 +304,45 @@ describe("code mode execute", () => { expect(asked.map((req: any) => req.permission)).toEqual(["a_tool", "b_tool"]) }) - test("streams live per-call metadata as a call starts and finishes", async () => { - const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record }[] }> = [] - const recordingCtx: Tool.Context = { - ...ctx, - metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)), - } - const tool = await build({ greeter_hello: mcpTool("hello", () => ({ content: [{ type: "text", text: "hi" }] })) }) + test("a denied permission fails the child call with a catchable message, not the whole execute", async () => { + const denyCtx: Tool.Context = { ...ctx, ask: () => Effect.die(new Error("permission denied by user")) } + const called: string[] = [] + const tool = await build({ + a_tool: mcpTool("a", () => { + called.push("a") + return { content: [{ type: "text", text: "ok" }] } + }), + }) - await Effect.runPromise(tool.execute({ code: "await tools.greeter.hello({ name: 'Ada' }); return 'done'" }, recordingCtx)) + const output = await Effect.runPromise( + tool.execute({ code: "try { await tools.a.tool({}) } catch (e) { return 'denied: ' + e.message }" }, denyCtx), + ) - // The UI sees the call appear as running, then resolve to completed. - expect(snapshots).toContainEqual({ toolCalls: [{ tool: "greeter.hello", status: "running", input: { name: "Ada" } }] }) - expect(snapshots).toContainEqual({ toolCalls: [{ tool: "greeter.hello", status: "completed", input: { name: "Ada" } }] }) + expect(output.output).toBe("denied: permission denied by user") + expect(output.metadata.error).toBeUndefined() + // The MCP tool itself never ran. + expect(called).toEqual([]) + expect(output.metadata.toolCalls).toEqual([{ tool: "a.tool", status: "error" }]) }) - test("streams discovery helpers with the same per-call metadata shape", async () => { + test("streams live per-call metadata as a call starts and finishes", async () => { const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record }[] }> = [] const recordingCtx: Tool.Context = { ...ctx, metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)), } - const tool = await build({ github_create_issue: mcpTool("create_issue", () => "") }) + const tool = await build({ greeter_hello: mcpTool("hello", () => ({ content: [{ type: "text", text: "hi" }] })) }) await Effect.runPromise( - tool.execute( - { - code: ` - await tools.$rune.search('issue', { namespace: 'github' }) - await tools.$rune.describe('github.create_issue') - return 'done' - `, - }, - recordingCtx, - ), + tool.execute({ code: "await tools.greeter.hello({ name: 'Ada' }); return 'done'" }, recordingCtx), ) + // The UI sees the call appear as running, then resolve to completed. expect(snapshots).toContainEqual({ - toolCalls: [{ tool: "$rune.search", status: "running", input: { query: "issue", namespace: "github" } }], + toolCalls: [{ tool: "greeter.hello", status: "running", input: { name: "Ada" } }], }) expect(snapshots).toContainEqual({ - toolCalls: [ - { tool: "$rune.search", status: "completed", input: { query: "issue", namespace: "github" } }, - { tool: "$rune.describe", status: "completed", input: { path: "github.create_issue" } }, - ], + toolCalls: [{ tool: "greeter.hello", status: "completed", input: { name: "Ada" } }], }) }) @@ -356,66 +357,48 @@ describe("code mode execute", () => { }) await Effect.runPromise( - tool.execute({ code: "try { await tools.bad.tool({ reason: 'test' }) } catch (e) { return 'caught' }" }, recordingCtx), + tool.execute( + { code: "try { await tools.bad.tool({ reason: 'test' }) } catch (e) { return 'caught' }" }, + recordingCtx, + ), ) expect(snapshots).toContainEqual({ toolCalls: [{ tool: "bad.tool", status: "error", input: { reason: "test" } }] }) }) - test("unit: toEnvelope wraps result and extracts media as opaque attachment handles", () => { - const table = attachmentTable() - expect(toEnvelope({ structuredContent: { x: 1 }, content: [] }, table.seal)).toEqual({ result: { x: 1 } }) - expect(toEnvelope({ content: [{ type: "text", text: "hi" }] }, table.seal)).toEqual({ result: "hi" }) - expect(toEnvelope("raw", table.seal)).toEqual({ result: "raw" }) - - // image/audio blocks become OPAQUE handles (mime/bytes, NO url/data); text stays in result - const withImage = toEnvelope( - { - content: [ - { type: "text", text: "see image" }, - { type: "image", data: "AAAA", mimeType: "image/png" }, - ], - }, - table.seal, - ) - expect(withImage.result).toBe("see image") - expect(withImage.attachments).toEqual([{ type: "file", id: "att_1", mime: "image/png", bytes: 3 }]) - // The handle exposes no bytes, but resolves back to the real attachment host-side. - expect((withImage.attachments![0] as any).url).toBeUndefined() - expect(table.resolve(withImage.attachments![0])).toEqual({ - type: "file", - mime: "image/png", - url: "data:image/png;base64,AAAA", + test("accumulates stripped media as execute attachments the sandbox never sees", async () => { + const tool = await build({ + shot_take: mcpTool("take", () => ({ + content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }], + structuredContent: { name: "shot.png" }, + })), }) - // media-only result: undefined result, still surfaces the handle - const mediaOnly = toEnvelope({ content: [{ type: "image", data: "BBBB", mimeType: "image/jpeg" }] }, table.seal) - expect(mediaOnly.result).toBeUndefined() - expect(mediaOnly.attachments).toEqual([{ type: "file", id: "att_2", mime: "image/jpeg", bytes: 3 }]) - }) - - test("unit: attachmentTable resolve drops fabricated or stale handles", () => { - const table = attachmentTable() - expect(table.resolve({ type: "file", id: "att_999", mime: "image/png" })).toBeUndefined() - expect(table.resolve({ type: "file" })).toBeUndefined() - expect(table.resolve("nope")).toBeUndefined() + const out = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx)) + // The program received the structured content; the media rode along host-side. + expect(JSON.parse(out.output)).toEqual({ name: "shot.png" }) + expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }]) + expect(out.output).not.toContain("PNGDATA") }) - test("unit: formatValue", () => { - expect(formatValue("text")).toBe("text") - expect(formatValue({ a: 1 })).toBe(JSON.stringify({ a: 1 }, null, 2)) - expect(formatValue(undefined)).toBe("undefined") + test("a media-only result returns a text marker so the program knows it succeeded", async () => { + const tool = await build({ + shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })), + }) + const out = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx)) + expect(out.output).toBe("[1 image attached to the result]") + expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }]) }) - test("unit: withLogs", () => { - // No logs: output is returned untouched. - expect(withLogs("result", [])).toBe("result") - // Logs are appended as a trailing section, one `[level] message` line each. - expect(withLogs("result", [{ level: "log", message: "a" }, { level: "warn", message: "b" }])).toBe( - "result\n\nLogs:\n[log] a\n[warn] b", + test("attachments still flow when the program returns something else entirely", async () => { + const tool = await build({ + shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })), + }) + const out = await Effect.runPromise( + tool.execute({ code: "await tools.shot.take({}); return 'captured'" }, ctx), ) - // Empty output still gets the section (no leading blank lines). - expect(withLogs("", [{ level: "error", message: "boom" }])).toBe("Logs:\n[error] boom") + expect(out.output).toBe("captured") + expect(out.attachments).toHaveLength(1) }) test("terminates a runaway loop via the operation limit instead of hanging", async () => { @@ -431,342 +414,162 @@ describe("code mode execute", () => { expect(output.metadata.error).toBe(true) }) - test("describe shows the structured return type when the tool declares an outputSchema", async () => { - const tools = { weather_current: mcpTool("current", () => "", { type: "object", properties: { city: { type: "string" } } }) } - const defs: Record = { - weather_current: { - name: "current", - inputSchema: { type: "object", properties: { city: { type: "string" } } }, - outputSchema: { type: "object", properties: { tempC: { type: "number" }, summary: { type: "string" } }, required: ["tempC"] }, - } as any, - } - const tool = await build(tools, defs) - 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>", - ) - // describe now returns the return shape as pretty TypeScript, not raw JSON Schema. - expect(desc.output).toBe("{\n tempC: number\n summary?: string\n}") - expect(desc.outputSchema).toBeUndefined() - }) - - test("describe returns the input type as TypeScript with JSDoc and enum literals", async () => { - const tool = await build({ - docs_resolve: mcpTool("resolve", () => "", { - type: "object", - properties: { - library: { type: "string", description: "The library name to resolve" }, - kind: { enum: ["react", "vue"] }, - }, - required: ["library"], - }), - }) - const described = await Effect.runPromise(tool.execute({ code: "return await tools.$rune.describe('docs.resolve')" }, ctx)) - const desc = JSON.parse(described.output) - expect(desc.input).toBe( - '{\n /** The library name to resolve */\n library: string\n kind?: "react" | "vue"\n}', - ) - expect(desc.inputSchema).toBeUndefined() + test("truncates an oversized result via the CodeMode output limit", async () => { + const tool = await build({}) + const output = await Effect.runPromise(tool.execute({ code: "return 'x'.repeat(40000)" }, ctx)) + expect(output.metadata.error).toBeUndefined() + expect(output.output).toContain("[result truncated:") + expect(output.output.length).toBeLessThan(40_000) }) - test("forwards attachments from a returned tool result and drops them when only .result is returned", async () => { - const tool = await build({ - shot_take: mcpTool("take", () => ({ - content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }], - structuredContent: { name: "shot.png" }, - })), - }) - - const forwarded = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx)) - expect(forwarded.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }]) - expect(JSON.parse(forwarded.output)).toEqual({ name: "shot.png" }) + test("appends logs after the result on success and after the message on error", async () => { + const tool = await build({}) - const suppressed = await Effect.runPromise( - tool.execute({ code: "const r = await tools.shot.take({}); return { result: r.result }" }, ctx), + const ok = await Effect.runPromise( + tool.execute({ code: "console.log('step one'); console.warn('careful'); return 'done'" }, ctx), ) - expect(suppressed.attachments).toBeUndefined() - expect(JSON.parse(suppressed.output)).toEqual({ name: "shot.png" }) - }) - - test("indexes parameter names so tools are searchable by their inputs", async () => { - const tool = await build({ - // The query word appears only as a parameter name, not in path or description. - traces_lookup: mcpTool("lookup", () => "", { - type: "object", - properties: { trace_id: { type: "string", description: "the distributed trace identifier" } }, - }), - other_noop: mcpTool("noop", () => ""), - }) - 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"]) - }) -}) - -describe("rankTools", () => { - const E = (path: string, description: string, params = ""): SearchEntry => ({ - path, - server: path.split(".")[0]!, - description, - searchText: [path, description, params].join("\n").toLowerCase(), - }) - - test("matches multiple non-contiguous terms (not just a contiguous substring)", () => { - const entries = [ - E("github.create_issue", "Create a new issue on a repository"), - E("github.list_pulls", "List pull requests"), - ] - const { items, total } = rankTools(entries, "create issue") - expect(total).toBe(1) - expect(items[0]!.path).toBe("github.create_issue") - }) - - test("ranks an exact tool-name match above a substring match", () => { - const entries = [E("github.search_issues", "Search issues"), E("github.search", "Full text search")] - const { items } = rankTools(entries, "search") - expect(items[0]!.path).toBe("github.search") - }) - - test("ranks a name match above a description-only match", () => { - const entries = [ - E("datadog.list_monitors", "Enumerate alerting definitions"), - E("datadog.get_dashboard", "List the monitors on a dashboard"), - ] - const { items } = rankTools(entries, "monitors") - expect(items[0]!.path).toBe("datadog.list_monitors") - }) - - test("matches against indexed parameter text", () => { - const entries = [E("traces.lookup", "Fetch a span", "trace_id the distributed trace id"), E("other.noop", "Does nothing")] - const { items, total } = rankTools(entries, "trace_id") - expect(total).toBe(1) - expect(items[0]!.path).toBe("traces.lookup") - }) - - test("respects the namespace filter", () => { - const entries = [E("github.search", "search"), E("linear.search", "search")] - const { items, total } = rankTools(entries, "search", "linear") - expect(total).toBe(1) - expect(items[0]!.path).toBe("linear.search") - }) - - test("an empty query (or bare wildcard) lists everything alphabetically", () => { - const entries = [E("b.two", "second"), E("a.one", "first")] - for (const q of ["", "*"]) { - const { items, total } = rankTools(entries, q) - expect(total).toBe(2) - expect(items.map((i) => i.path)).toEqual(["a.one", "b.two"]) - } - }) - - test("honors the limit while reporting the full match total", () => { - const entries = Array.from({ length: 10 }, (_, i) => E(`s.tool_${i}`, "searchable tool")) - const { items, total } = rankTools(entries, "searchable", undefined, 3) - expect(total).toBe(10) - expect(items).toHaveLength(3) - }) + expect(ok.output).toBe("done\n\nLogs:\nstep one\n[warn] careful") - test("returns nothing when no term matches", () => { - const entries = [E("github.search", "search")] - expect(rankTools(entries, "nonexistent")).toEqual({ items: [], total: 0 }) + const err = await Effect.runPromise( + tool.execute({ code: "console.log('before the throw'); throw new Error('boom')" }, ctx), + ) + expect(err.metadata.error).toBe(true) + expect(err.output).toContain("Uncaught: boom") + expect(err.output).toContain("Logs:\nbefore the throw") }) }) -describe("renderType", () => { - test("renders primitives, integers, and arrays", () => { - expect(renderType({ type: "string" })).toBe("string") - expect(renderType({ type: "integer" })).toBe("number") - expect(renderType({ type: "array", items: { type: "string" } })).toBe("string[]") - }) - - test("renders enums and const as literal types", () => { - expect(renderType({ enum: ["a", "b", "c"] })).toBe('"a" | "b" | "c"') - expect(renderType({ const: 42 })).toBe("42") - }) - - test("renders a nullable type array as a union (does not drop null)", () => { - expect(renderType({ type: ["string", "null"] })).toBe("string | null") - }) - - test("parenthesizes a union inside an array", () => { - expect(renderType({ type: "array", items: { type: ["string", "null"] } })).toBe("(string | null)[]") - }) +describe("toSandboxResult", () => { + const collector = () => { + const attachments: Attachment[] = [] + return { attachments, collect: (a: Attachment) => void attachments.push(a) } + } - test("renders additionalProperties as an index signature", () => { - expect(renderType({ type: "object", additionalProperties: { type: "number" } })).toBe("{ [key: string]: number }") - expect(renderType({ type: "object", additionalProperties: true })).toBe("{ [key: string]: any }") + test("prefers structuredContent over text", () => { + const { collect } = collector() + expect(toSandboxResult({ structuredContent: { x: 1 }, content: [{ type: "text", text: "hi" }] }, collect)).toEqual( + { x: 1 }, + ) }) - test("resolves a local $ref against the document root", () => { - const schema = { - type: "object", - properties: { node: { $ref: "#/$defs/Node" } }, - required: ["node"], - $defs: { Node: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } }, - } as any - expect(renderType(schema)).toBe("{ node: { id: string } }") + test("joins text content when no structured content is present", () => { + const { collect } = collector() + expect( + toSandboxResult( + { content: [{ type: "text", text: "one" }, { type: "text", text: "two" }] }, + collect, + ), + ).toBe("one\ntwo") }) - test("collapses a self-referential $ref to its name instead of looping", () => { - const schema = { - $defs: { Node: { type: "object", properties: { next: { $ref: "#/$defs/Node" } } } }, - $ref: "#/$defs/Node", - } as any - // Must terminate; the recursive position falls back to the ref name. - expect(renderType(schema)).toBe("{ next?: Node }") + test("passes non-MCP values through untouched", () => { + const { collect } = collector() + expect(toSandboxResult("raw", collect)).toBe("raw") + expect(toSandboxResult(42, collect)).toBe(42) + expect(toSandboxResult(null, collect)).toBeNull() + expect(toSandboxResult({ some: "object" }, collect)).toEqual({ some: "object" }) }) - test("pretty mode emits an indented block with JSDoc for described fields", () => { - const schema = { - type: "object", - properties: { - name: { type: "string", description: "The library name to resolve" }, - kind: { enum: ["lib", "app"] }, + test("strips media into the accumulator; text stays the sandbox value", () => { + const { attachments, collect } = collector() + const value = toSandboxResult( + { + content: [ + { type: "text", text: "see image" }, + { type: "image", data: "AAAA", mimeType: "image/png" }, + ], }, - required: ["name"], - } as any - expect(renderType(schema, { pretty: true })).toBe( - '{\n /** The library name to resolve */\n name: string\n kind?: "lib" | "app"\n}', + collect, ) + expect(value).toBe("see image") + expect(attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,AAAA" }]) }) - test("renders anyOf / oneOf as a union", () => { - expect(renderType({ anyOf: [{ type: "string" }, { type: "number" }] })).toBe("string | number") - expect(renderType({ oneOf: [{ const: "a" }, { const: "b" }] })).toBe('"a" | "b"') - }) - - test("empty enum / anyOf / type arrays render as never, not an empty string", () => { - expect(renderType({ enum: [] })).toBe("never") - expect(renderType({ anyOf: [] })).toBe("never") - expect(renderType({ type: [] as any })).toBe("never") - }) - - test("renders a tuple's first item type", () => { - expect(renderType({ type: "array", items: [{ type: "string" }, { type: "number" }] as any })).toBe("string[]") - }) - - test("combines named properties with an additionalProperties index signature", () => { - const schema = { - type: "object", - properties: { id: { type: "string" } }, - required: ["id"], - additionalProperties: { type: "number" }, - } as any - expect(renderType(schema)).toBe("{ id: string; [key: string]: number }") - }) - - test("quotes non-identifier property names", () => { - const schema = { type: "object", properties: { "content-type": { type: "string" } } } as any - expect(renderType(schema)).toBe('{ "content-type"?: string }') - }) + test("a media-only result yields a marker; counts and nouns follow the content", () => { + const one = collector() + expect(toSandboxResult({ content: [{ type: "image", data: "A", mimeType: "image/png" }] }, one.collect)).toBe( + "[1 image attached to the result]", + ) - test("nests pretty objects with increasing indentation", () => { - const schema = { - type: "object", - properties: { outer: { type: "object", properties: { inner: { type: "string" } }, required: ["inner"] } }, - required: ["outer"], - } as any - expect(renderType(schema, { pretty: true })).toBe("{\n outer: {\n inner: string\n }\n}") - }) + const two = collector() + expect( + toSandboxResult( + { + content: [ + { type: "image", data: "A", mimeType: "image/png" }, + { type: "image", data: "B", mimeType: "image/jpeg" }, + ], + }, + two.collect, + ), + ).toBe("[2 images attached to the result]") + expect(two.attachments).toHaveLength(2) - test("resolves mutually recursive $refs without looping", () => { - const schema = { - $ref: "#/$defs/A", - $defs: { - A: { type: "object", properties: { b: { $ref: "#/$defs/B" } } }, - B: { type: "object", properties: { a: { $ref: "#/$defs/A" } } }, - }, - } as any - // A -> B -> A: the second A is on the resolution path, so it collapses to its name. - expect(renderType(schema)).toBe("{ b?: { a?: A } }") + const mixed = collector() + expect( + toSandboxResult( + { + content: [ + { type: "image", data: "A", mimeType: "image/png" }, + { type: "audio", data: "B", mimeType: "audio/wav" }, + ], + }, + mixed.collect, + ), + ).toBe("[2 files attached to the result]") }) - test("preserves a multi-line description as a multi-line JSDoc block", () => { - const schema = { - type: "object", - properties: { - query: { type: "string", description: "The search query.\nSupports globs.\n\nExamples: *.ts" }, + test("extracts embedded resources: text inline, blobs as attachments with filenames", () => { + const { attachments, collect } = collector() + const value = toSandboxResult( + { + content: [ + { type: "resource", resource: { uri: "file:///tmp/notes.txt", mimeType: "text/plain", text: "note text" } }, + { type: "resource", resource: { uri: "file:///tmp/doc.pdf", mimeType: "application/pdf", blob: "PDF" } }, + ], }, - required: ["query"], - } as any - expect(renderType(schema, { pretty: true })).toBe( - "{\n /**\n * The search query.\n * Supports globs.\n *\n * Examples: *.ts\n */\n query: string\n}", + collect, ) + expect(value).toBe("note text") + expect(attachments).toEqual([ + { type: "file", mime: "application/pdf", url: "data:application/pdf;base64,PDF", filename: "doc.pdf" }, + ]) }) - test("emits JSDoc tags for schema constraints TypeScript can't express", () => { - const schema = { - type: "object", - properties: { - when: { type: "string", format: "date-time", default: "now", description: "start time" }, - legacy: { type: "boolean", deprecated: true }, - tags: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 5 }, - }, - required: ["when"], - } as any - expect(renderType(schema, { pretty: true })).toBe( - [ - "{", - " /**", - " * start time", - ' * @default "now"', - " * @format date-time", - " */", - " when: string", - " /** @deprecated */", - " legacy?: boolean", - " /**", - " * @minItems 1", - " * @maxItems 5", - " */", - " tags?: string[]", - "}", - ].join("\n"), + test("collects resource_link blocks as external-URL attachments", () => { + const { attachments, collect } = collector() + const value = toSandboxResult( + { content: [{ type: "resource_link", uri: "https://example.com/report.csv", mimeType: "text/csv" }] }, + collect, ) + expect(value).toBe("[1 file attached to the result]") + expect(attachments).toEqual([ + { type: "file", mime: "text/csv", url: "https://example.com/report.csv", filename: "report.csv" }, + ]) }) - test("neutralizes a comment terminator inside a JSDoc description", () => { - const schema = { type: "object", properties: { x: { type: "string", description: "danger */ oops" } } } as any - const out = renderType(schema, { pretty: true }) - expect(out).toContain("/** danger * / oops */") - expect(out).not.toContain("*/ oops") + test("an MCP-shaped result with nothing extractable becomes null", () => { + const { collect } = collector() + expect(toSandboxResult({ content: [] }, collect)).toBeNull() + expect(toSandboxResult({ content: [{ type: "mystery" }] }, collect)).toBeNull() }) +}) - test("is total on a self-referential union (never throws)", () => { - const a: any = { anyOf: [] } - a.anyOf.push(a) // structural cycle with no $ref - expect(() => renderType(a)).not.toThrow() +describe("formatting helpers", () => { + test("formatValue", () => { + expect(formatValue("text")).toBe("text") + expect(formatValue({ a: 1 })).toBe(JSON.stringify({ a: 1 }, null, 2)) + expect(formatValue(null)).toBe("null") + expect(formatValue(undefined)).toBe("undefined") }) - test("unwraps the Pydantic allOf: [{ $ref }] shape with sibling description", () => { - const schema = { - type: "object", - properties: { - config: { allOf: [{ $ref: "#/$defs/Config" }], description: "the config block" }, - }, - required: ["config"], - $defs: { Config: { type: "object", properties: { level: { type: "integer" } }, required: ["level"] } }, - } as any - expect(renderType(schema)).toBe("{ config: { level: number } }") - }) - - test("renders multi-member allOf as an intersection", () => { - const schema = { - allOf: [ - { type: "object", properties: { a: { type: "string" } }, required: ["a"] }, - { type: "object", properties: { b: { type: "number" } }, required: ["b"] }, - ], - } as any - expect(renderType(schema)).toBe("{ a: string } & { b: number }") - }) - - test("renders a base object's properties even when it also carries a require-one-of anyOf", () => { - const schema = { - type: "object", - properties: { a: { type: "string" }, b: { type: "string" } }, - anyOf: [{ required: ["a"] }, { required: ["b"] }], - } as any - expect(renderType(schema)).toBe("{ a?: string; b?: string }") + test("withLogs", () => { + // No logs: output is returned untouched. + expect(withLogs("result", [])).toBe("result") + expect(withLogs("result")).toBe("result") + // Logs are appended as a trailing section, one line each. + expect(withLogs("result", ["a", "[warn] b"])).toBe("result\n\nLogs:\na\n[warn] b") + // Empty output still gets the section (no leading blank lines). + expect(withLogs("", ["[error] boom"])).toBe("Logs:\n[error] boom") }) }) diff --git a/packages/opencode/test/session/rune-parity.test.ts b/packages/opencode/test/session/rune-parity.test.ts deleted file mode 100644 index 31754a4198cf..000000000000 --- a/packages/opencode/test/session/rune-parity.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Effect } from "effect" -import { Rune } from "@/session/rune/rune" -import { ToolRuntime } from "@/session/rune/tool-runtime" - -// Runs a Rune program with no host tools and returns the ExecuteResult. These tests pin the -// JS-parity fixes for the "99% of ordinary defensive JavaScript just works" goal: cases where -// Rune used to throw but idiomatic JS yields undefined / succeeds. -const run = (code: string) => Effect.runPromise(Rune.execute({ code, tools: {} })) -const value = async (code: string) => { - const result = await run(code) - if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) - return result.value -} -const error = async (code: string) => { - const result = await run(code) - if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) - return result.error -} - -describe("H2: string property access reads as undefined (not a throw)", () => { - test("unknown property on a string is undefined", async () => { - expect(await value(`const s = "hi"; return s.login`)).toBeUndefined() - }) - - test("optional chaining + fallback on a string does not throw", async () => { - expect(await value(`const s = "hi"; return s?.login ?? "fallback"`)).toBe("fallback") - }) - - test("the real MCP pattern: result is a JSON string, defensive read falls through", async () => { - // me.result is a string; me.result?.login is undefined, so we fall back to the raw string. - expect(await value(`const me = { result: '{"login":"x"}' }; return me.result?.login ?? me.result`)).toBe( - '{"login":"x"}', - ) - }) - - test("unknown property on a number is undefined", async () => { - expect(await value(`return (5).foo ?? "n"`)).toBe("n") - }) - - test("supported string methods still work", async () => { - expect(await value(`return "AB".toLowerCase()`)).toBe("ab") - expect(await value(`return "hello".length`)).toBe(5) - }) -}) - -describe("H3: array property access reads as undefined (not a throw)", () => { - test("unknown property on an array is undefined", async () => { - expect(await value(`return [1,2,3].foo`)).toBeUndefined() - }) - - test("optional chaining on an array does not throw", async () => { - expect(await value(`return [1,2,3]?.foo ?? "fb"`)).toBe("fb") - }) - - test("real-but-unsupported array methods still give the rewrite hint", async () => { - const err = await error(`return [1,2,3].splice(0,1)`) - expect(err.kind).toBe("UnsupportedSyntax") - expect(err.message).toContain("splice") - }) - - test("supported array methods and indexing still work", async () => { - expect(await value(`return [1,2,3].map(x => x + 1)`)).toEqual([2, 3, 4]) - expect(await value(`return [1,2,3][9]`)).toBeUndefined() - }) -}) - -describe("H6: object spread of null/undefined is a no-op", () => { - test("spreading null is a no-op", async () => { - expect(await value(`const o = null; return { ...o, a: 1 }`)).toEqual({ a: 1 }) - }) - - test("spreading an absent argument merges cleanly", async () => { - expect(await value(`function f(opts){ return { ...opts, a: 1 } } return f(undefined)`)).toEqual({ a: 1 }) - }) - - test("spreading a real object still works", async () => { - expect(await value(`const o = { a: 1 }; return { ...o, b: 2 }`)).toEqual({ a: 1, b: 2 }) - }) - - test("spreading an array into an object still errors", async () => { - const err = await error(`return { ...[1,2], a: 1 }`) - expect(err.kind).toBe("InvalidDataValue") - }) -}) - -describe("H4: typeof on an undeclared identifier is 'undefined'", () => { - test("feature-detection guard does not throw", async () => { - expect(await value(`return typeof foo === "undefined" ? "safe" : "no"`)).toBe("safe") - }) - - test("typeof of a declared binding is unaffected", async () => { - expect(await value(`const x = 5; return typeof x`)).toBe("number") - expect(await value(`const s = "a"; return typeof s`)).toBe("string") - }) - - test("referencing an undeclared identifier outside typeof still throws", async () => { - const err = await error(`return foo + 1`) - expect(err.message).toContain("foo") - }) -}) - -describe("H1: NaN/Infinity flow as intermediates and normalize to null at the boundary", () => { - test("guards run instead of the program crashing on a transient NaN", async () => { - expect(await value(`return parseInt("abc") || 0`)).toBe(0) - expect(await value(`const x = Number("abc"); return Number.isNaN(x) ? 0 : x`)).toBe(0) - expect(await value(`const o = {}; o.count = (o.count || 0) + 1; return o.count`)).toBe(1) - // average of an empty list, guarded — the classic divide-by-zero that used to throw pre-guard - expect(await value(`const a = []; return a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0`)).toBe(0) - }) - - test("a non-finite value becomes null when it leaves the sandbox", async () => { - expect(await value(`return 5/0`)).toBeNull() - expect(await value(`return 0/0`)).toBeNull() - expect(await value(`return Math.max()`)).toBeNull() - // nested, too — normalization walks the returned structure - expect(await value(`return { a: Number("x"), b: 2, c: [1/0] }`)).toEqual({ a: null, b: 2, c: [null] }) - }) - - test("NaN and Infinity are usable identifiers and inspectable in-sandbox", async () => { - expect(await value(`return Number.isNaN(NaN)`)).toBe(true) - expect(await value(`return Infinity > 1e9`)).toBe(true) - expect(await value(`return Number.isFinite(1/0)`)).toBe(false) - expect(await value(`return [3,1,2].reduce((a,b)=>Math.max(a,b), -Infinity)`)).toBe(3) - // JSON.stringify inside the sandbox matches JS: non-finite serializes to null - expect(await value(`return JSON.stringify({ x: Number("z") })`)).toBe('{"x":null}') - }) - - test("copyOut normalizes non-finite numbers to null (the shared return + tool-arg boundary)", () => { - // Tool-call arguments funnel through copyOut too, so this one function pins both boundaries. - expect(ToolRuntime.copyOut(NaN)).toBeNull() - expect(ToolRuntime.copyOut(Infinity)).toBeNull() - expect(ToolRuntime.copyOut(-Infinity)).toBeNull() - expect(ToolRuntime.copyOut(42)).toBe(42) - expect(ToolRuntime.copyOut({ a: NaN, b: [Infinity, 1] })).toEqual({ a: null, b: [null, 1] }) - }) -}) - -describe("H5: builtin coercion functions work as array callbacks", () => { - test("filter(Boolean) drops falsy values", async () => { - expect(await value(`return [0, 1, "", 2, null, 3].filter(Boolean)`)).toEqual([1, 2, 3]) - }) - - test("map(String) coerces each element", async () => { - expect(await value(`return [1, 2, 3].map(String)`)).toEqual(["1", "2", "3"]) - }) - - test("arrow callbacks still work (no regression)", async () => { - expect(await value(`return [1, 2, 3, 4].filter(x => x % 2 === 0)`)).toEqual([2, 4]) - expect(await value(`return [1, 2, 3].reduce((a, b) => a + b, 0)`)).toBe(6) - }) - - test("a non-callable callback is still rejected", async () => { - const err = await error(`return [1,2,3].map(42)`) - expect(err.message).toContain("callback") - }) -}) From 2a139003204970111328d7f4c9697982df4757b2 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 16:45:03 -0500 Subject: [PATCH 28/41] feat(codemode): simplify limits, enrich search, condense instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-review refinement pass over the initial package: - Limits are exactly the three public knobs. timeoutMs and maxToolCalls have no defaults (absent = unbounded; budgets are host policy); the internal limit system (maxOperations, maxDataBytes, maxSourceBytes, maxAuditBytes, maxCollectionLength) is deleted — concurrency (8) and boundary depth (32) survive as fixed constants. The timeout alone interrupts pure busy loops; a regression test locks that in. - The inline catalog budget is estimated tokens (chars/4, default 2000) instead of bytes, selection is round-robin across namespaces so no server is starved to a bare count, and every namespace is always listed regardless of budget. - Search-result signatures are JSDoc-annotated: per-field descriptions plus @default/@format/@deprecated/@minItems/@maxItems tags, rendered from either schema kind; catalog lines stay compact. The renderer gained a recursion ceiling and $ref cycle guard. Search terms also match naive singular forms. - Instructions condensed ~40%: Workflow/Rules deduped, the Syntax allowlist inverted to name only what is unusual or missing (and that TypeScript annotations are stripped), console/media rules replaced by a neutral unknown-shape warning. --- packages/codemode/README.md | 50 +- packages/codemode/codemode.md | 352 ++++++++-- packages/codemode/src/codemode.ts | 745 ++++----------------- packages/codemode/src/token.ts | 10 + packages/codemode/src/tool-runtime.ts | 225 ++++--- packages/codemode/src/tool.ts | 140 +++- packages/codemode/src/values.ts | 6 +- packages/codemode/test/codemode.test.ts | 173 +++-- packages/codemode/test/enumeration.test.ts | 22 +- packages/codemode/test/promise.test.ts | 16 +- packages/codemode/test/signature.test.ts | 242 +++++++ packages/codemode/test/stdlib.test.ts | 28 +- 12 files changed, 1104 insertions(+), 905 deletions(-) create mode 100644 packages/codemode/src/token.ts create mode 100644 packages/codemode/test/signature.test.ts diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 5e648b8bdcff..4176d6e0aa4f 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -61,7 +61,7 @@ const result = yield* runtime.execute(` `) ``` -`result` is always an `ExecuteResult`. Program, validation, budget, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption. +`result` is always an `ExecuteResult`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption. Successful result values are JSON-safe data. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well. @@ -150,14 +150,14 @@ interface ExecuteFailure { ## Discovery -The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count, and as many complete tool signatures (each with a one-line description) as fit a byte budget are inlined — cheapest-first within a namespace, namespaces processed alphabetically. Once one signature does not fit, inlining stops for every remaining namespace, which then show counts only. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL — N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`). +The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature line against the shared budget, and a namespace whose next line does not fit drops out while the others keep going — so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL — N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`). -The default budget is 16,000 UTF-8 bytes. Override it when constructing a runtime: +The default budget is 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). Override it when constructing a runtime: ```ts const runtime = CodeMode.make({ tools, - discovery: { maxInlineCatalogBytes: 24_000 }, + discovery: { maxInlineCatalogTokens: 6_000 }, }) ``` @@ -173,11 +173,27 @@ const matches = await tools.$codemode.search({ }) ``` -`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path) and capped at `limit` results (default 10). +`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches — so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path) and capped at `limit` results (default 10). -Each result contains the path, description, and generated TypeScript signature, so no second lookup is needed. Result paths carry the `tools.` prefix (`tools.orders.lookup`), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (with or without the `tools.` prefix) is treated as a lookup and returns that tool alone. +Each result contains the path, description, and generated TypeScript signature, so no second lookup is needed. The result signature is the pretty, JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** … */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). The inline catalog in the instructions keeps the compact single-line form. -The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call it by path; `JSON.parse` string results; return only the needed fields), a `## Rules` section (parse text results as JSON, return small instead of raw payloads, filter and aggregate large collections in code, inspect intermediates with `console.*` — captured as result logs — run independent calls through `Promise.all`, enumerate `tools` with `Object.keys`/`for...in`, and browse a namespace via search when it is advertised), a `## Syntax` section, and the budgeted `## Available tools` catalog. Every call form uses explicit `.`/`` placeholders — never a real or fabricated tool name. +```ts +tools.github.list_issues(input: { + /** Repository owner */ + owner: string + /** Cursor from the previous response's pageInfo */ + after?: string + /** + * Results per page + * @default 30 + */ + perPage?: number +}): Promise +``` + +Result paths carry the `tools.` prefix (`tools.orders.lookup`), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (with or without the `tools.` prefix) is treated as a lookup and returns that tool alone. + +The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; `JSON.parse` string results; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (filter and aggregate collections in code; treat `Promise` results as shapeless until verified; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace via search when it is advertised), a short `## Syntax` section that assumes standard JavaScript and names only what is unusual (TypeScript annotations stripped; the data-boundary serialization of Date/Map/Set/RegExp) or missing (classes, generators, `for await...of`, `.then`/`.catch`/`.finally`, `x instanceof Error`, `splice`), and the budgeted `## Available tools` catalog. Every call form uses explicit `.`/`` placeholders — never a real or fabricated tool name. A host cannot define its own `$codemode` top-level namespace. @@ -196,7 +212,7 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports: - First-class promises — an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic. - `throw value` and `throw new Error(message)` for explicit program failure. -At every data boundary (final result, tool arguments, `JSON.stringify`, and the internal data checkpoints of `Object.*`/coercion helpers) the four value types serialize exactly as `JSON.stringify` would: a Date becomes its ISO string (`null` when invalid) and RegExp/Map/Set become `{}`. Map and Set contents are charged against the internal data-size and collection-length budgets like any other collection. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`. +At every data boundary (final result, tool arguments, `JSON.stringify`, and the internal data checkpoints of `Object.*`/coercion helpers) the four value types serialize exactly as `JSON.stringify` would: a Date becomes its ISO string (`null` when invalid) and RegExp/Map/Set become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`. It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` — `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available. @@ -204,14 +220,16 @@ CodeMode is an orchestration language, not a general JavaScript runtime. ## Execution Limits -The public limits are three knobs: +The limits are exactly three knobs: | Limit | Default | Bounds | | --- | ---: | --- | -| `timeoutMs` | 10,000 | Wall-clock execution time. | -| `maxToolCalls` | 100 | Tool calls admitted during the execution. | +| `timeoutMs` | none — no timeout | Wall-clock execution time. | +| `maxToolCalls` | none — unlimited | Tool calls admitted during the execution. | | `maxOutputBytes` | 32,000 | Model-facing output: the serialized result value plus captured logs. | +`timeoutMs` and `maxToolCalls` have no defaults on purpose: execution budgets are host policy, not library policy — a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set none. `maxOutputBytes` keeps a default because truncation never breaks correctness, while its absence would silently flood model context. + Pass only the overrides you need: ```ts @@ -224,13 +242,13 @@ const runtime = CodeMode.make({ }) ``` -Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` override leaves the default intact. +Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` value is the same as leaving the limit unset. Exceeding `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept from the start until the remaining budget is exhausted (with a final marker line noting the cut), and the result carries `truncated: true`. -The timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). Tool implementations remain responsible for making their external operations interruptible or independently bounded. +When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) — no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded. -Interpreter internals stay bounded by fixed internal budgets (operation count 100,000; in-sandbox data 256,000 bytes; collection length 10,000; value depth 32; source size 32,000 bytes; audit data 1,000,000 bytes; tool-call concurrency 8). These are not part of the public contract. +Two interpreter internals are fixed constants rather than knobs: at most 8 tool calls run concurrently, and values crossing a data boundary may nest at most 32 levels deep (deeper values fail as `InvalidDataValue`, which reads better than a native stack-overflow error). Neither is part of the public contract. ## Diagnostics @@ -243,10 +261,8 @@ Failures are data: | `UnknownTool` | A program referenced a tool the host did not provide. | | `InvalidToolInput` | Tool input failed schema decoding or safe-data copying. | | `InvalidToolOutput` | Tool output failed schema decoding or safe-data copying. | -| `InvalidDataValue` | Program data violated the plain-data or size contract. | -| `OperationLimitExceeded` | Interpreter work exceeded the internal operation budget. | +| `InvalidDataValue` | Program data violated the plain-data contract (depth, circularity, blocked properties, non-data values). | | `ToolCallLimitExceeded` | Calls exceeded `maxToolCalls`. | -| `AuditLimitExceeded` | Retained call metadata exceeded the internal audit budget. | | `TimeoutExceeded` | Execution exceeded `timeoutMs`. | | `ToolFailure` | A tool refused or failed. | | `ExecutionFailure` | The program threw or another execution error occurred. | diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 83aaf57422f7..1735aecadd6a 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -63,11 +63,15 @@ From issue #34787 and design discussion. Do not relitigate these casually. ### Discovery / search - **Search only — no separate `describe`.** `tools.$codemode.search({ query?, namespace?, limit? })` over the final tool tree, owned by this package. -- Search result item shape: `{ path, description, signature }`. The `signature` string embeds - the full input/output TypeScript types, so separate `input`/`output` fields were dropped as - redundant (the issue listed them; we resolved the redundancy in favor of signature-only). - Result `path`s carry the `tools.` prefix (post-wave fix) so each is directly usable as the - call site; the internal `ToolDescription.path` stays unprefixed. +- Search result item shape: `{ path, description, signature }` in an `{ items, total }` + wrapper. The `signature` string embeds the full input/output TypeScript types — in search + results it is the pretty, JSDoc-annotated multiline form (Fix 7), so per-field schema + `description`s and constraints (`@default`, `@format`, `@deprecated`, `@minItems`, + `@maxItems`) ride along as field comments. The original spec's separate `input`/`output` + raw-schema fields are deliberately NOT added: shapes are already fully expressed in the + TypeScript signature and schema annotations now arrive as JSDoc — intent satisfied, letter + deviated. Result `path`s carry the `tools.` prefix (post-wave fix) so each is directly + usable as the call site; the internal `ToolDescription.path` stays unprefixed. - Default limit: **10** (done). Exact-path lookup goes through search too: a query equal to a tool path (with or without the `tools.` prefix) returns that tool alone (done). - Signatures render **native payloads**: `Promise`, NOT `Promise>`. @@ -94,11 +98,21 @@ From issue #34787 and design discussion. Do not relitigate these casually. image bytes into context or drop attachments. ### Runtime behavior -- Public limits are simple: `{ timeoutMs, maxToolCalls, maxOutputBytes }`. The other knobs - (maxOperations, maxDataBytes, maxValueDepth, maxCollectionLength, maxSourceBytes, - maxAuditBytes, maxConcurrency) are internal constants, still reachable for tests via the - `@internal` `InternalExecutionLimits` type (exported from `codemode.ts`, not from the package - index). (Done in Wave 2.) +- Limits are EXACTLY the three public knobs: `{ timeoutMs, maxToolCalls, maxOutputBytes }` — + matching the original locked spec exactly. `timeoutMs` and `maxToolCalls` have NO defaults + (absent = no timeout / unlimited calls — budgets are host policy; user direction, Fix 6); + `maxOutputBytes` defaults to 32,000 because truncation never breaks correctness and its + absence silently floods model context. OpenCode's adapter policy (user direction): NO + limits at all — no timeout, unlimited tool calls (each child call is permission-gated; + user cancel interrupts the execution fiber and its children), default output truncation + only. + The internal limit system that Wave 2 kept behind + an `@internal` `InternalExecutionLimits` type (maxOperations, maxDataBytes, maxValueDepth, + maxCollectionLength, maxSourceBytes, maxAuditBytes, maxConcurrency) was deleted outright in + Fix 5 (see Post-wave fixes). Two internals survive as fixed constants, not knobs: + `TOOL_CALL_CONCURRENCY = 8` (the fork semaphore) and `MAX_VALUE_DEPTH = 32` (the `copyIn` + boundary depth check, kept only because it beats a native stack-overflow RangeError as an + error message; still reports `InvalidDataValue`). - CodeMode owns truncation of its own returned output (`maxOutputBytes`). OpenCode's native tool-output truncation (50KB / 2000 lines in `tool.ts` + `truncate.ts`) stays on as the outer safety net for now; we may remove that layering later. @@ -118,11 +132,11 @@ From issue #34787 and design discussion. Do not relitigate these casually. ## 3. Current status (what is already done on `codemode-v2`) Waves 0–5 and the post-wave fixes below are committed on `codemode-v2` (two commits: the -generic package, then the OpenCode integration). Verification: from -`packages/codemode`, `bun test` (156 pass / 0 fail across -`codemode/parity/stdlib/promise/enumeration`) and `bun run typecheck`; from +generic package, then the OpenCode integration); Fixes 4–8 are uncommitted working-tree +changes. Verification: from `packages/codemode`, `bun test` (169 pass / 0 fail across +`codemode/parity/stdlib/promise/enumeration/signature`) and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and `bun test test/session/` (all green — the -adapter suites are `code-mode.test.ts`, 35 tests, and `code-mode-integration.test.ts`, +adapter suites are `code-mode.test.ts`, 34 tests, and `code-mode-integration.test.ts`, 16 tests). ### Wave 0 — scaffold (done) @@ -156,7 +170,8 @@ spec. The seeded interpreter was deliberately strict; these behaviors replaced t - Opaque-by-default: all four join `isRuntimeReference`, with explicit carve-outs (member access allowlists, Date in binary/unary ops, Map/Set in spread/for...of, console formatting, - byte accounting in `runtimeValueBytes`, `containsOpaqueReference` for operator guards). + `containsOpaqueReference` for operator guards; the `runtimeValueBytes` byte-accounting + carve-out died with that machinery in Fix 5). - **JSON semantics at every boundary and checkpoint**: Date → ISO string (invalid → null), RegExp/Map/Set → `{}`. `copyIn` also converts host `Date`/`RegExp`/`Map`/`Set` instances the same way (a host tool may legitimately return them). @@ -170,8 +185,8 @@ spec. The seeded interpreter was deliberately strict; these behaviors replaced t README). - Map/Set: full method sets; `keys/values/entries` return **arrays** (not iterators); `for...of` + spread work; `Object.fromEntries(map)`, `Array.from(map|set)`; SameValueZero - keys (NaN findable); mutations maintain incremental byte totals and enforce - `maxCollectionLength`/`maxDataBytes`. + keys (NaN findable). (The incremental byte totals and `maxCollectionLength`/`maxDataBytes` + enforcement this wave added were deleted in Fix 5.) - Rode along, same spirit: `typeof` never throws for any value (`typeof fn` → `"function"`), `!` works on any value, `for...of` over strings, `{...sandboxValue}` no-op, template interpolation renders `/regex/` and ISO dates directly. @@ -203,18 +218,17 @@ wave; both packages typecheck clean. (`ToolError`/`ToolRuntimeError` message, else "Tool execution failed"). Interrupted calls fire no end event (timeout kills the whole execution anyway). - **Limits collapse**: public `ExecutionLimits` = `{ timeoutMs?, maxToolCalls?, - maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). All other knobs are internal defaults - (unchanged values) on `ResolvedExecutionLimits`, still validated + reachable through the - `@internal` `InternalExecutionLimits` type exported from `src/codemode.ts` only — tests pass - internal knobs via typed variables (assignment from a wider variable skips excess-property - checks). + maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other knobs as + internal defaults reachable through an `@internal` `InternalExecutionLimits` type; Fix 5 + later deleted that type and the internal limit system entirely. - **`maxOutputBytes` truncation** (CodeMode-owned, never fails): applied via `boundOutput` in a final `Effect.map` over every result path (success/timeout/normalized failure). Oversized serialized values become truncated text + ` [result truncated: N bytes exceeds the M-byte output limit; return a smaller value]`; logs keep leading lines within the remaining budget + `[logs truncated: showing K of N lines]`; result gains `truncated: true` (also added to - `ExecuteResultSchema`). UTF-8-safe truncation (no split code points). The in-sandbox - `maxDataBytes` check on the final result still throws first if the raw value exceeds it. + `ExecuteResultSchema`). UTF-8-safe truncation (no split code points). (The in-sandbox + `maxDataBytes` check that used to throw first on oversized raw values died in Fix 5 — + truncation is now the only result-size mechanism.) - **Search polish**: default limit 12 → **10** (`defaultSearchLimit`); exact-path lookup — a trimmed query equal to one tool path (optionally `tools.`-prefixed) returns that tool alone (`total: 1`), bypassing ranking. Tokenization/ranking/shape unchanged. @@ -243,14 +257,16 @@ per-MCP registration; MCP resource tools unaffected) is unchanged. are stripped into a per-execution `Attachment[]` accumulator, and a media-only result becomes a marker payload (`"[1 image attached to the result]"`, noun/count adjusted). An MCP-shaped result with nothing extractable becomes `null`; non-MCP values pass through. - No handles, no `Result` envelope, no base64 in the sandbox, no raised `maxDataBytes`. + No handles, no `Result` envelope, no base64 in the sandbox, no data-size tuning (the + `maxDataBytes` budget that existed at the time was deleted in Fix 5). - **Execute result**: `{ output: formatValue(value) + trailing "Logs:" section (success AND error — logs are plain pre-formatted lines now), attachments: accumulated }` through the existing `Tool.ExecuteResult.attachments` → `message-v2.ts` vision plumbing; attachments ride on both success and error results. Diagnostic `suggestions` not already contained in the message are appended to error output. Native outer truncation stays on (adapter never sets `metadata.truncated`); CodeMode's own `maxOutputBytes` (32 KB default) cuts first. - Limits: `{ timeoutMs: 30_000 }` (matches the default MCP request timeout), rest defaults. + Limits: `{ timeoutMs: 30_000 }` at the time (matched the default MCP request timeout); + killed in Fix 6 — the adapter now passes no limits at all. - **Progress**: `onToolCallStart`/`onToolCallEnd` → `ctx.metadata({ toolCalls })` with `{ tool, status: running|completed|error, input? }` per call index — the exact shape the TUI `Execute` component (`packages/tui/src/routes/session/index.tsx`) already renders. @@ -275,13 +291,16 @@ packages typecheck clean. - **Budgeted catalog** (`discoveryPlan` in `tool-runtime.ts`): the all-or-nothing inline/search modes are gone — `DiscoveryMode` deleted, `DiscoveryOptions` is just - `{ maxInlineCatalogBytes? }` (default 16,000 UTF-8 bytes). Port of the old opencode + `{ maxInlineCatalogBytes? }` (default 16,000 UTF-8 bytes; later converted to + `maxInlineCatalogTokens`, default 4,000 estimated tokens — see Post-wave fixes). Port of + the old opencode `describe()` `PREVIEW_BUDGET` algorithm, adapted to `ToolDescription`: every namespace is ALWAYS listed with its tool count; full signature lines (` - // `) are inlined cheapest-first (line byte length, path tiebreak) within each namespace, namespaces processed alphabetically; once one line does not fit, inlining stops for every remaining namespace - (counts only), exactly like the ported algorithm. The header states comprehensiveness + (counts only), exactly like the ported algorithm (this stop-everything behavior was later + replaced by round-robin fairness in Fix 8). The header states comprehensiveness precisely: "Available tools (COMPLETE list — …)" vs "Available tools (PARTIAL — N of M shown; find the rest with tools.$codemode.search)"; namespace labels are `(N tools)` / `(N tools, K shown)` / `(N tools, none shown)`. An empty tree renders "No tools are @@ -315,7 +334,8 @@ packages typecheck clean. resource tools unaffected); the live description read back as "Available tools (PARTIAL — 56 of 88 shown; find the rest with tools.$codemode.search):" with correct per-namespace labels (context7/github/memory fully shown; playwright/sentry/sequential-thinking "none - shown" — alphabetical exhaustion); programs executed with in-program `$codemode.search` + shown" — the alphabetical-exhaustion starvation Fix 8 later replaced with round-robin + fairness); programs executed with in-program `$codemode.search` calls and returned the correct answer. NOT verified e2e (headless only; covered by unit/integration tests instead): TUI child-call rendering, attachments becoming visible images, output truncation. @@ -340,10 +360,10 @@ adapter needed **no changes**. - **Mechanics**: `SandboxPromise` in `values.ts` (fiber-backed for tool calls; fiberless `immediate` effect for `Promise.resolve`/`reject`). Forks run `semaphore.withPermit(invoke)` with `startImmediately: true` — a per-execution - `Semaphore.makeUnsafe(maxConcurrency)` (8) caps live calls (the "Effect.all or equivalent" - cap lives where the work is, so combinator joins can be sequential without losing - parallelism), and the budget/audit charge (`recordCall`) plus `onToolCallStart` fire at the - call site before any await. `await` of a non-promise is a passthrough no-op; a returned + `Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)` (fixed 8, see Fix 5) caps live calls (the + "Effect.all or equivalent" cap lives where the work is, so combinator joins can be + sequential without losing parallelism), and the tool-call-count charge (`recordCall`) plus + `onToolCallStart` fire at the call site before any await. `await` of a non-promise is a passthrough no-op; a returned top-level promise resolves like an async-function return (`return tools.a.b(x)` works without await). - **Promise combinators are normal functions over values**: `Promise.all`/`allSettled`/`race` @@ -409,12 +429,11 @@ adapter needed **no changes**. - `for...in` (ForInStatement) iterates own enumerable string keys of plain objects, index strings of arrays, and namespace/tool names of tool references — sharing the interpreter's `enumerableKeys` helper with the `Object.keys` tool path. const/let declarations and bare - identifiers bind the key; break/continue work; iterations charge the operation budget like - the other loops. Anything else (strings, Map/Set, numbers, null, ...) is a clear error - suggesting `for...of` or `Object.keys` — deliberately smaller than real JS (which yields - indices for strings and zero iterations for Maps/Sets/null). + identifiers bind the key; break/continue work. Anything else (strings, Map/Set, numbers, + null, ...) is a clear error suggesting `for...of` or `Object.keys` — deliberately smaller + than real JS (which yields indices for strings and zero iterations for Maps/Sets/null). - `supportedSyntaxMessage`, the instructions loops line, and README "Supported Programs" - mention the new surface; tests in `test/enumeration.test.ts` (15, incl. the exact + mention the new surface; tests in `test/enumeration.test.ts` (14, incl. the exact transcript program) plus one adapter-level assertion that `Object.keys(tools)` returns MCP server names. @@ -432,7 +451,8 @@ adapter needed **no changes**. path + description). Queries tokenize on camelCase boundaries + non-alphanumeric separators (empties and `*` dropped). Additive per-term scoring: exact path or path-segment match 20, path substring 8, description substring 4, searchable-text - substring 2; summed across terms, filtered to score > 0, sorted score desc then path asc. + substring 2; summed across terms, filtered to score > 0, sorted score desc then path asc + (Fix 8 later made each field check accept the term OR a naive singular variant). An empty query now browses ALPHABETICALLY by path (was declaration order). Kept: `{ path, description, signature }` result items, default limit 10, exact-path instant lookup, input validation errors. @@ -455,7 +475,8 @@ adapter needed **no changes**. The flat prose instructions (which mixed a real catalog tool with fabricated result fields in the worked example) are replaced by structured markdown in `discoveryPlan`, ordered so the workflow sits at the top (the least likely part of a long description to - be truncated or skimmed away) and the catalog at the bottom: + be truncated or skimmed away) and the catalog at the bottom (the per-section content + described here was later condensed by Fix 8 — Workflow/Rules deduped, Syntax inverted): - **Intro** (2 lines): "Write a CodeMode program… Return code only." + "Execute JavaScript in a confined runtime with access to the tools listed below under `tools.*`." (the second line drops the tools clause when the tree is empty). @@ -490,32 +511,255 @@ adapter needed **no changes**. zero-tool minimal sections) — 156 pass / 0 fail; adapter suites gain the same assertions on the built description (still 35 + 16, green). +**Fix 4 — token-budgeted catalog (was bytes)** (user direction: signatures need a token +budget; namespaces must always be present): + - `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so + the package stays dependency-free; keep in sync if the core heuristic changes. + - `DiscoveryOptions.maxInlineCatalogBytes` → `maxInlineCatalogTokens` (default 4,000 + estimated tokens ≈ the old 16,000 bytes at 4 chars/token — behavior parity, not a size + reduction). `discoveryPlan` charges `estimate(catalogLine(tool))` per line; cheapest-first + + stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in + Fix 8). Namespace stub lines were and remain unbudgeted — every + namespace always appears with its tool count, even at budget 0 (asserted in package and + adapter tests). + - Ripple: chars/4 rounding erases small line-length differences, so equal-cost lines fall + to the lexicographic path tiebreak; the adapter's PARTIAL test now asserts the + lexicographic tail (`op_99`) is excluded instead of `op_149`. Fixed-prose measurements + (2026-07): preamble ~44 + Workflow ~146 + Rules ~362 + Syntax ~453 ≈ 1,100 tokens fixed; + worst-case net description ≈ fixed + 4,000 ≈ 5,100 estimated tokens. + +**Fix 5 — internal limits removed** (user direction: only the three PUBLIC limits survive as +configurable knobs; the internal limit system dies): + - `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at + the time; Fix 6 later removed the first two defaults. Same validation: safe integers, + timeoutMs >= 1, others >= 0, RangeError otherwise) is now + the ENTIRE limit surface — exactly the shape §2's original locked spec named. + `ResolvedExecutionLimits` shrank to those three fields; the `@internal` + `InternalExecutionLimits` type is deleted. + - **Deleted outright**: `maxOperations` and the whole operation-budget machinery + (`recordWork`/`recordOperation`/`budget.operations`, plus the `workUnits`/ + `cheapArrayMethods` cost helpers); `maxSourceBytes` (the pre-parse source-size check); + `maxDataBytes` (every byte-accounting path: `runtimeValueBytes`, `boundedProgramValue`, + the container-size caches (`containerSizes`/`objectCounts`), Map/Set incremental `bytes` + fields in `values.ts`, string-growth `limitString` checks, tool-argument/result byte + checks in `tool-runtime.ts`, and the final-result size check); `maxAuditBytes` (log and + audit-trail byte accounting — `toolCalls` records and the start/end hooks are unchanged); + `maxCollectionLength` (every array-length/object-field-count check — this knob was + actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded` + and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and + `ExecuteResultSchema` (fine — the package is unreleased). + - **Fixed constants, not knobs**: `TOOL_CALL_CONCURRENCY = 8` (codemode.ts; the fork + semaphore) and `MAX_VALUE_DEPTH = 32` (tool-runtime.ts; the `copyIn` depth check — kept + only because it produces a clearer error than a native stack-overflow RangeError; still + `InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone — + `copyIn(value, label)` needs no limits argument, and `ToolRuntime.make` takes just + `(tools, maxToolCalls, hooks?, searchIndex?)`. + - **Verified fact**: timeout interruption does NOT depend on the operation budget — the + Effect fiber runtime auto-yields between interpreter steps, so `timeoutMs` interrupts + even a pure `while (true) {}` loop (empirically verified: a 200ms timeout fired at + ~225ms with maxOperations set to MAX_SAFE_INTEGER before the deletion). A regression + test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` → + `TimeoutExceeded`, elapsed well under a few seconds). + - **Kept (correctness, not budgets)**: circular detection (`copyIn` walks + + `rejectCircularInsertion` on mutations), plain-objects-only, blocked properties + (`__proto__`/`constructor`/`prototype`), data-only checks, and all three public-limit + behaviors unchanged. + - Behavior deltas beyond the intended kills: in-sandbox structures deeper than 32 levels + now fail at the data boundary (`copyIn`) instead of at construction; array index + assignment allows any non-negative integer index (holes permitted, message now "must be + a non-negative integer"); interpreter-produced deep/hostile structures that overflow the + native stack during a walk still normalize to the existing "Execution exceeded the + maximum nesting depth." data diagnostic — failures remain data everywhere. + - Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth ×2, + enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/ + maxConcurrency-RangeError assertions, and the adapter's runaway-loop-via-operation-limit + test — superseded by the package timeout regression test); rewrote the helpers that used + `InternalExecutionLimits` as a convenience to plain `ExecutionLimits` + (promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter + suites: 34 + 16. + +**Fix 6 — no default timeout / tool-call cap** (user direction): `timeoutMs` and +`maxToolCalls` lost their defaults (were 10_000 / 100) — absent now means no timeout / +unlimited calls. Budgets are host policy, not library policy; `maxOutputBytes` keeps its +32,000 default because truncation never breaks correctness and its absence would silently +flood model context. `ResolvedExecutionLimits` carries `number | undefined` for both, the +timeout wrapper is only applied when configured, and `ToolRuntime.make` treats undefined +`maxToolCalls` as uncapped. Validation is unchanged when values ARE provided (safe integers, +timeoutMs >= 1, others >= 0). The OpenCode adapter is unaffected in behavior it sets +(explicit 30s timeout) but now runs with unlimited tool calls. Immediately after, per user +direction, the adapter's 30s timeout was killed too: `CODE_LIMITS` is deleted and OpenCode +passes NO limits — no timeout, no tool-call cap. Rationale: user cancel interrupts the +execution fiber and structured concurrency takes the program and in-flight child calls down +with it; every child call is permission-gated; output truncation (32KB default) is the only +active bound. New regression test: 150 tool calls succeed with no limits configured (would +have tripped the old default 100). Package suite: 155 pass / 0 fail. + +**Fix 7 — JSDoc-annotated search signatures**: `tools.$codemode.search` result signatures are +now the pretty, indented multiline form with per-field JSDoc — ported from the pre-rebuild +rune renderer in this repo's git history (`renderType(def, { pretty })`/`docTags`/`jsdoc`/ +`renderObject`), adapted to the current renderer's conventions (`Array`, `unknown` +fallback, existing `$defs`/`$ref` handling and empty-object `{}` collapse; the old +`Result`/`returnType` machinery was deliberately not ported — payloads stay native). +Semantics: each described input/output field carries its schema `description` as a +`/** … */` comment at the right indent (nested objects recurse deeper); constraints TS can't +express surface as JSDoc tags — `@deprecated`, `@default ` (unserializable defaults +skipped), `@format`, `@minItems`/`@maxItems`; `*/` inside text is neutralized to `* /`; +multiline descriptions become `*`-prefixed blocks with blank edges trimmed; undescribed, +untagged fields get no comment. Implementation: `renderSchema` in `tool.ts` grew a +`RenderContext` (`{ definitions, pretty }`), a `MAX_RENDER_DEPTH = 8` recursion ceiling plus +a `$ref` `seen` guard (the renderer previously had neither — a cyclic `$defs` would have +looped; it now degrades to the ref name/`unknown`), and try/catch totality on the public +helpers (`toTypeScript`/`jsonSchemaToTypeScript`/`inputTypeScript`/`outputTypeScript` never +throw — pathological schemas render `unknown`); each helper takes an optional trailing +`pretty = false` parameter, so existing callers are unchanged and compact output stays +byte-identical (inline `catalogLine`s and the token budget depend on it). `SearchEntry` +gained an eagerly-computed `signature` field (built once per tool at index-build time in +`toSearchEntry` — rendering is cheap and the search hot path stays allocation-free); both +ranked results and exact-path lookups serve it. Works for both tool kinds: Effect Schema +annotations (`Schema.String.annotate({ description })`) flow through the emitted JSON +Schema, and raw JSON Schema (MCP) property metadata is read directly — both covered in +`test/signature.test.ts` (12 tests) plus one strengthened adapter assertion (MCP property +description appears as JSDoc in a live search result; the tool description/catalog contains +no `/**`). README search section updated with an example. Package suite: 167 pass / 0 fail; +adapter suites: 34 + 16. + +**Fix 8 — condensed instructions + round-robin catalog fairness + plural-aware search** +(user direction: the fixed instruction prose was too verbose; two discovery fixes ride +along). All in `tool-runtime.ts`; no interpreter changes. + - **Syntax section inverted**: the three dense allowlist lines (~453 estimated tokens) + are replaced by four short lines (~188) built on "models already know JavaScript; name + only what is unusual or missing": (1) standard modern JS works — functions/closures, + destructuring, template literals, loops, try/catch, spread, optional chaining, the + usual Array/String/Object/Math/JSON methods, plus Date/RegExp/Map/Set and + Promise.all/allSettled/race/resolve/reject; (2) TypeScript type annotations are + stripped before execution, decorators are not supported; (3) NOT supported (each fails + with a message naming the alternative): classes, generators, for await...of, + .then/.catch/.finally (use await with try/catch), `x instanceof Error` (caught errors + are plain `{ name, message }` objects), splice; (4) the data-boundary note (Dates → + ISO strings; Map/Set/RegExp → `{}`). Every claim was verified against the interpreter + before writing: probed empirically — classes/generators/for-await/.then/.catch/ + .finally/`instanceof Error`/splice/decorators/BigInt/labeled statements/tagged + templates/object getters all fail with clear diagnostics; TS annotations/`as`/ + interfaces/type aliases are stripped and TS **enums actually work** (transpileModule + compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned. + `supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched. + - **Workflow/Rules deduped**: the call-by-exact-path, JSON.parse-string-results, and + return-small content now lives ONLY in the numbered Workflow steps (with their + compliance-driving justifications inline: "most tools return JSON as a string", "raw + payloads get truncated and waste context"); Rules keeps only bullets adding new + content — filter/aggregate collections in code, console.* intermediates (logs ride + back), Promise.all parallelism, Object.keys/for...in enumeration, browse-namespace + (PARTIAL only), and the media rule compressed to one line. The no-.then/.catch + guidance moved to the Syntax not-supported line. Content upgrades: the PARTIAL search + step gained query-style guidance (`— short phrases like "list issues" work best`; a + clearly-a-query-string example, not a tool name), and the exact-path guidance is now + "call it with the result's `path` as-is (never guess segments)" / COMPLETE: "use it + as-is rather than guessing segments". + - **Fixed-prose measurements** (instructions split on `"\n## "`, catalog budget 0, + bytes/3.7 — same method as Fix 4; chars/4 in parentheses): + preamble 44 → 44 (41 → 41), Workflow 146 → 187 (135 → 171), Rules 362 → 191 + (332 → 176), Syntax 453 → 188 (419 → 174); fixed prose total 1,005 → 610 (927 → 562), + ≈ 40% reduction with no behavioral content dropped. Workflow grew slightly because it + absorbed the deduped parse/return-small justifications. + - **Round-robin namespace inlining** (`discoveryPlan`): the ported stop-on-first-miss + behavior (alphabetically-late namespaces starved to "none shown" while an early + namespace inlines everything) is replaced by round-robin fairness — in each round + (namespaces alphabetical), every namespace still holding un-inlined tools attempts to + place its next-cheapest line against the shared token budget; a namespace whose next + line does not fit is done while the others keep going; stop when all are done. Every + namespace gets some representation before any namespace gets everything. Kept: + `estimate` (chars/4) budget accounting, unbudgeted namespace stub lines, per-namespace + `(N tools)`/`(N tools, K shown)`/`(N tools, none shown)` labels, COMPLETE vs PARTIAL + header, alphabetical namespace order in the output, cheapest-first within each + namespace's shown set. + - **Plural/singular search fix**: `tokenize`d terms matched one-directionally (term must + be substring of indexed text), so query "issues" missed a tool whose text only says + "issue". Now each term expands to `termForms` — the term plus naive singular variants + (trailing "es" stripped when length > 3, trailing "s" when length > 2) — and each of + the four field checks passes when ANY form matches. Weights, exact-path lookup, and + namespace scoping untouched. A true plural path match still outranks a singular-only + description match (path substring 8 + searchable 2 > description 4 + searchable 2). + - **Tests**: package instruction/structure assertions updated to the new text; new + syntax-section test (leads with "Standard modern JavaScript works", names the + verified not-supported list, keeps the data-boundary note); the budget-exhaustion + test rewritten to assert the new fairness (alpha.expensive not fitting must NOT + prevent beta.cheap from showing: PARTIAL 2 of 3, `- beta (1 tool)` fully shown); new + plural/singular test (query "issues" finds a singular-only tool; ranking still + prefers the true "issues" path match). Adapter: description assertions updated; the + large-catalog PARTIAL test now asserts `zeta_only_tool` IS shown (`- zeta (1 tool)` + + its inlined line) — it was "none shown" under starvation. README updated (budgeted + catalog paragraph → round-robin; search paragraph → singular variants; + instructions-structure paragraph → new section contents). Package suite: 169 pass / + 0 fail; adapter suites: 34 + 16. + +**Fix 9 — prompting trims per user review of Fix 8** (user reviewed the condensed +instructions and directed further cuts): + - Default `maxInlineCatalogTokens` 4,000 → **2,000** (user wants ~2k tokens of signatures + auto-inlined; round-robin fairness from Fix 8 spreads it across all namespaces). + - Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single + `unknown`-treatment warning: "A result typed `Promise` has no guaranteed + shape — verify what actually came back before relying on its fields." (Deliberately + does NOT suggest console.log — user review: naming it there nudges models to log AND + return the same data; the prompt stays console-neutral, neither for nor against.) + The media-stripping MECHANISM is unchanged and still tested; only the prose about it + is gone — the `[N images attached]` marker is self-explanatory in context. + - Kept as-is per user: the JSON.parse workflow step (maps to the original motivating + transcript failure; NOT copied from prior art — see §5 note), the browse-namespace rule + (undecided), no no-fetch/ambient-authority rule added (proposed, not approved). + - Explicitly REJECTED for now: auto-parsing JSON-looking text results at the adapter + boundary ("could get weird" — type flips, program-sees vs tool-sent divergence). Logged + as a next-iteration follow-up below. + --- ## 4. Remaining work (detailed TODO) -### Backlog / loose ends (non-blocking, any order) +### Next DSL-expansion pass +Batch these together — per user direction: important, but deliberately deferred to one +focused interpreter-surface pass rather than picked off piecemeal. - [ ] Medium-tier JS parity items deferred from the original audit: caught errors are plain `{ name, message }` objects, not `instanceof Error` (and `Error` isn't a value — `x instanceof Error` is unsupported syntax); `splice` (still a "rewrite using map/filter" hint) and array `entries()/keys()/values()`; `localeCompare`/`normalize`/`trimLeft`/`trimRight`; friendlier regex-y error messages. +- [ ] `Date`/`Map`/`Set`/`RegExp` values passing through `Object.*` helpers and coercion + checkpoints take their JSON forms (e.g. `Object.values({ d: date })` yields the ISO + string, not the Date — calling `.getTime()` on it then fails). Currently deliberate + (documented in README) but flagged as important: fix in this pass by letting sandbox + values survive `Object.*`/spread checkpoints instead of JSON-serializing them. - [ ] `console.log(NaN)` prints `"null"` (goes through the boundary chokepoint) — could special-case number formatting in `formatConsoleArgument`. - [ ] Sandbox values nested inside logged containers print `[CodeMode reference]` (`console.log({ m: map })`) — could deep-format instead. -- [ ] `Date`/`Map`/`Set`/`RegExp` values passing through `Object.*` helpers and coercion - checkpoints take their JSON forms (e.g. `Object.values({ d: date })` yields the ISO - string, not the Date). Deliberate (documented in README); revisit only if it bites. + +### Next iteration: text-result handling (deliberate follow-up, user-directed) +- [ ] Revisit how MCP text results reach the program. Today: `structuredContent` when the + server sends it, else joined text as a plain string (the program JSON.parses it, + guided by a workflow step). Considered and deferred: (a) conservative boundary + auto-parse (text starting with `{`/`[` that parses cleanly becomes an object) — + rejected for now as potentially confusing (type flips; program sees something other + than what the tool sent); (b) raw-envelope passthrough with the envelope shape + stamped into every output schema — rejected (more digging per call, verbose + signatures). Result quality is dominated by whether servers declare output schemas; + revisit once real usage shows which failure modes matter. + +### Backlog / loose ends (non-blocking, any order) +- [ ] Media-only marker could name what it attached when MCP provides names: `image`/`audio` + blocks carry no filename (mime + data only) so the generic + `[N images attached to the result]` stays, but `resource`/`resource_link` blocks have + URIs/names we could surface, e.g. `[2 files attached: chart.png, data.csv]`. Minor. - [ ] Decide whether OpenCode's outer native truncation gets disabled for `execute` once `maxOutputBytes` exists (issue says CodeMode reimplements it; "maybe we kill \[the outer one\] later"). -- [ ] Two timing-based tests in `test/promise.test.ts` (a `< 150ms` parallelism bound and - 100ms-timeout interruption) could flake on a very slow CI host; primary assertions are - counter-based, but loosen margins if they ever flake. -- [ ] Interactive e2e still unverified (Wave 4 verified headless only): TUI child-call - rendering via `metadata.toolCalls`, and stripped media becoming visible images through - `Tool.ExecuteResult.attachments`. Run one interactive session before merging. +- [x] Flaky wall-clock assertion removed from `test/promise.test.ts`: the parallelism test + now relies solely on the deterministic `trace.maxActive > 1` counter (which proves + true temporal overlap). The timeout tests were never flaky — 100ms timeout vs 60s + tool sleeps (600x margin) with counter-based assertions. +- [ ] Attachment propagation believed correct but unverified end-to-end at the OpenCode + wiring layer (codemode strips → `Tool.ExecuteResult.attachments` → processor + normalizes → `FilePart`s visible to the model). Code-reviewed as sound; confirm with + one interactive session (an image-returning MCP tool) when convenient. Same session + can eyeball TUI child-call rendering via `metadata.toolCalls`. - [x] Commit hygiene: Waves 0–5 + post-wave fixes committed on `codemode-v2` as two units (the generic package; the OpenCode integration). Future work: commit only when explicitly asked; push with `--no-verify` per repo convention. The scratch @@ -534,10 +778,10 @@ adapter needed **no changes**. - Realistically **all MCP tools render `Promise`** (no outputSchema), so the instructions prose is the only lever for result-shape behavior in the dominant case. - **`copyIn` has two roles**: host↔sandbox boundary AND intra-sandbox data checkpoint - (`boundedData`). Sandbox value types are converted to JSON forms wherever it runs — that's - the documented model. If you add a new value type, follow the Wave 1b-i pattern: class in - `values.ts`, opaque-by-default via `isRuntimeReference`, explicit carve-outs, real byte - accounting in `runtimeValueBytes`, JSON form in `copyIn`, console formatting, tests. + (`boundedData` is now just a `copyIn` alias). Sandbox value types are converted to JSON + forms wherever it runs — that's the documented model. If you add a new value type, follow + the Wave 1b-i pattern: class in `values.ts`, opaque-by-default via `isRuntimeReference`, + explicit carve-outs, JSON form in `copyIn`, console formatting, tests. - The interpreter throws synchronously inside `Effect.gen`/`Effect.sync` freely; everything is normalized by `catchCause` → `normalizeError` into `Diagnostic` data. Program failures are **data, never Effect failures**; only interruption propagates. diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 4eafc10f7841..59e4f1f7dfc6 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -4,7 +4,6 @@ import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageT import { copyIn, copyOut, - dataByteLength, isBlockedMember, ToolReference, ToolRuntime, @@ -25,50 +24,28 @@ export { ToolError, toolError } from "./tool-error.js" /** Resource budgets enforced independently during each CodeMode program execution. */ export type ExecutionLimits = { - /** Maximum wall-clock execution time in milliseconds. */ + /** Maximum wall-clock execution time in milliseconds. No default: absent means no timeout. */ readonly timeoutMs?: number - /** Maximum number of tool calls admitted by the runtime. */ + /** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */ readonly maxToolCalls?: number /** * Maximum UTF-8 bytes of model-facing output: the serialized result value plus captured - * logs. Excess output is truncated with an explanatory marker instead of failing. + * logs (default 32,000). Excess output is truncated with an explanatory marker instead of + * failing. */ readonly maxOutputBytes?: number } -/** - * Internal execution knobs kept as fixed defaults behind the public limits. - * Not part of the public contract; exposed only for tests and embedders that must tune - * interpreter internals. - * - * @internal - */ -export type InternalExecutionLimits = ExecutionLimits & { - /** Maximum interpreter work, including collection and string traversal. */ - readonly maxOperations?: number - /** Maximum number of tool calls executing simultaneously. */ - readonly maxConcurrency?: number - /** Maximum UTF-8 source size. */ - readonly maxSourceBytes?: number - /** Maximum size of program values and the final result. */ - readonly maxDataBytes?: number - /** Maximum retained tool-call audit data. */ - readonly maxAuditBytes?: number - /** Maximum nesting depth for arrays and objects crossing data boundaries. */ - readonly maxValueDepth?: number - /** Maximum array length or object field count. */ - readonly maxCollectionLength?: number -} - /** Controls how much of the tool catalog is inlined in agent instructions. */ export type DiscoveryOptions = { /** - * Byte budget for inlined full tool signatures in agent instructions. Every namespace is - * always listed with its tool count; as many full signatures as fit this budget are inlined - * (cheapest-first within a namespace, namespaces alphabetical), and the instructions state - * whether the list is COMPLETE or PARTIAL. `tools.$codemode.search` is always registered. + * Estimated-token budget (chars/4, default 4000) for inlined full tool signatures in agent + * instructions. Every namespace is always listed with its tool count regardless of budget; + * as many full signatures as fit this budget are inlined (cheapest-first within a + * namespace, namespaces alphabetical), and the instructions state whether the list is + * COMPLETE or PARTIAL. `tools.$codemode.search` is always registered. */ - readonly maxInlineCatalogBytes?: number + readonly maxInlineCatalogTokens?: number } type ToolTree = { @@ -76,16 +53,11 @@ type ToolTree = { } type ResolvedExecutionLimits = { - readonly timeoutMs: number - readonly maxToolCalls: number + /** Undefined means no timeout. */ + readonly timeoutMs: number | undefined + /** Undefined means unlimited tool calls. */ + readonly maxToolCalls: number | undefined readonly maxOutputBytes: number - readonly maxOperations: number - readonly maxConcurrency: number - readonly maxSourceBytes: number - readonly maxDataBytes: number - readonly maxAuditBytes: number - readonly maxValueDepth: number - readonly maxCollectionLength: number } /** Options for one CodeMode execution. */ @@ -147,7 +119,7 @@ export const ExecuteInputSchema = Schema.Struct({ code: Schema.String }) const DiagnosticKindSchema = Schema.Literals([ "ParseError", "UnsupportedSyntax", "UnknownTool", "InvalidToolInput", "InvalidToolOutput", "InvalidDataValue", - "OperationLimitExceeded", "ToolCallLimitExceeded", "AuditLimitExceeded", "TimeoutExceeded", + "ToolCallLimitExceeded", "TimeoutExceeded", "ToolFailure", "ExecutionFailure", ]) @@ -286,7 +258,7 @@ class ProgramThrow { constructor(readonly value: unknown) {} } -/** Stable categories produced by program, schema, tool, and budget failures. */ +/** Stable categories produced by program, schema, tool, and limit failures. */ export type DiagnosticKind = | "ParseError" | "UnsupportedSyntax" @@ -294,9 +266,7 @@ export type DiagnosticKind = | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" - | "OperationLimitExceeded" | "ToolCallLimitExceeded" - | "AuditLimitExceeded" | "TimeoutExceeded" | "ToolFailure" | "ExecutionFailure" @@ -308,13 +278,6 @@ const arrayMethods = new Set([ ]) const retryableArrayMethods = new Set(["splice", "fill", "copyWithin", "keys", "values", "entries"]) -/** - * Array methods whose cost is O(1) (or bounded by the argument count), so they must - * NOT be charged the receiver's length. Charging `push` per element would make an - * accumulation loop quadratic in the operation budget and trip it on legitimate code. - */ -const cheapArrayMethods = new Set(["push", "pop", "at"]) - const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"]) const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"]) @@ -363,45 +326,24 @@ const supportedSyntaxMessage = const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError => new InterpreterRuntimeError(`Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`, node, "UnsupportedSyntax", [supportedSyntaxMessage]) -const defaultExecutionLimits = (): ResolvedExecutionLimits => ({ - timeoutMs: 10_000, - maxToolCalls: 100, - maxOutputBytes: 32_000, - maxOperations: 100_000, - maxConcurrency: 8, - maxSourceBytes: 32_000, - maxDataBytes: 256_000, - maxAuditBytes: 1_000_000, - maxValueDepth: 32, - maxCollectionLength: 10_000, -}) +/** How many eagerly forked tool calls may run at once. Fixed; not a configurable knob. */ +const TOOL_CALL_CONCURRENCY = 8 -const resolveLimit = (name: keyof InternalExecutionLimits, value: number | undefined, fallback: number, minimum: number): number => { - if (value === undefined) return fallback - if (!Number.isSafeInteger(value) || value < minimum) { +const validateLimit = (name: keyof ExecutionLimits, value: Value, minimum: number): Value => { + if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum)) { throw new RangeError(`${name} must be a safe integer greater than or equal to ${minimum}.`) } return value } -const resolveExecutionLimits = (publicLimits?: ExecutionLimits): ResolvedExecutionLimits => { - const defaults = defaultExecutionLimits() - // The public contract is the three documented knobs; the remaining internal knobs are still - // read (and validated) when supplied through InternalExecutionLimits, primarily by tests. - const limits = publicLimits as InternalExecutionLimits | undefined - return { - timeoutMs: resolveLimit("timeoutMs", limits?.timeoutMs, defaults.timeoutMs, 1), - maxToolCalls: resolveLimit("maxToolCalls", limits?.maxToolCalls, defaults.maxToolCalls, 0), - maxOutputBytes: resolveLimit("maxOutputBytes", limits?.maxOutputBytes, defaults.maxOutputBytes, 0), - maxOperations: resolveLimit("maxOperations", limits?.maxOperations, defaults.maxOperations, 0), - maxConcurrency: resolveLimit("maxConcurrency", limits?.maxConcurrency, defaults.maxConcurrency, 1), - maxSourceBytes: resolveLimit("maxSourceBytes", limits?.maxSourceBytes, defaults.maxSourceBytes, 0), - maxDataBytes: resolveLimit("maxDataBytes", limits?.maxDataBytes, defaults.maxDataBytes, 0), - maxAuditBytes: resolveLimit("maxAuditBytes", limits?.maxAuditBytes, defaults.maxAuditBytes, 0), - maxValueDepth: resolveLimit("maxValueDepth", limits?.maxValueDepth, defaults.maxValueDepth, 0), - maxCollectionLength: resolveLimit("maxCollectionLength", limits?.maxCollectionLength, defaults.maxCollectionLength, 0), - } -} +// timeoutMs and maxToolCalls have NO defaults: absent means no timeout / unlimited calls — +// budgets are host policy, not library policy. maxOutputBytes keeps a default because +// truncation never breaks correctness and its absence silently floods model context. +const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimits => ({ + timeoutMs: validateLimit("timeoutMs", limits?.timeoutMs, 1), + maxToolCalls: validateLimit("maxToolCalls", limits?.maxToolCalls, 0), + maxOutputBytes: validateLimit("maxOutputBytes", limits?.maxOutputBytes, 0) ?? 32_000, +}) class InterpreterRuntimeError extends Error { readonly node?: AstNode @@ -604,13 +546,10 @@ const caughtErrorValue = (thrown: unknown): unknown => // pure (string/Object/Math/JSON/coercion) and so live as free functions; array // Methods that run CodeMode callbacks live on the interpreter (they need invokeFunction). -const boundedData = (value: unknown, label: string, node: AstNode, limits: ResolvedExecutionLimits): unknown => { - const copied = copyIn(value, label, limits) - if (dataByteLength(copied) > limits.maxDataBytes) { - throw new InterpreterRuntimeError(`${label} exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, node, "InvalidDataValue") - } - return copied -} +// The intra-sandbox data checkpoint: copies a value through `copyIn`, which validates the +// plain-data contract (depth, circularity, plain objects only, blocked properties) and +// serializes sandbox value types to their JSON forms. +const boundedData = (value: unknown, label: string): unknown => copyIn(value, label) const isRuntimeReference = (value: unknown): boolean => value instanceof CodeModeFunction || value instanceof ToolReference || value instanceof IntrinsicReference || @@ -661,80 +600,6 @@ const typeofValue = (value: unknown): string => { return typeof value } -const runtimeValueBytes = ( - value: unknown, - label: string, - node: AstNode, - limits: ResolvedExecutionLimits, - depth = 0, - seen = new Set(), -): number => { - if (depth > limits.maxValueDepth) { - throw new InterpreterRuntimeError(`${label} exceeds the maximum value depth of ${limits.maxValueDepth}.`, node, "InvalidDataValue") - } - // Sandbox values are charged real sizes (a Map/Set can hold arbitrary program data, so a - // zero-cost reference would be an unaccounted-memory hole), and their walks are cycle-checked. - if (value instanceof SandboxDate) return 26 - if (value instanceof SandboxRegExp) return dataByteLength(value.regex.source) + value.regex.flags.length + 2 - if (value instanceof SandboxMap || value instanceof SandboxSet) { - if (seen.has(value)) throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") - seen.add(value) - const entries = value instanceof SandboxMap - ? Array.from(value.map.entries()) - : Array.from(value.set.values(), (item): [unknown, unknown] => [item, null]) - if (entries.length > limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - let bytes = 2 - for (const [key, item] of entries) { - bytes += runtimeValueBytes(key, label, node, limits, depth + 1, seen) + runtimeValueBytes(item, label, node, limits, depth + 1, seen) + 2 - } - seen.delete(value) - return bytes - } - if (isRuntimeReference(value)) return 0 - if (value === null || value === undefined || typeof value === "string" || typeof value === "boolean" || typeof value === "number") { - return dataByteLength(value) - } - if (typeof value !== "object") { - throw new InterpreterRuntimeError(`${label} must contain data or CodeMode references only.`, node, "InvalidDataValue") - } - if (seen.has(value)) throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") - seen.add(value) - let bytes = 2 - if (Array.isArray(value)) { - if (value.length > limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - for (const item of value) bytes += runtimeValueBytes(item, label, node, limits, depth + 1, seen) + 1 - } else { - const entries = Object.entries(value) - if (entries.length > limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - for (const [key, item] of entries) { - if (isBlockedMember(key)) throw new InterpreterRuntimeError(`${label} contains blocked property '${key}'.`, node, "InvalidDataValue") - bytes += dataByteLength(key) + runtimeValueBytes(item, label, node, limits, depth + 1, seen) + 1 - } - } - seen.delete(value) - return bytes -} - -const boundedProgramValue = (value: unknown, label: string, node: AstNode, limits: ResolvedExecutionLimits): unknown => { - if (runtimeValueBytes(value, label, node, limits) > limits.maxDataBytes) { - throw new InterpreterRuntimeError(`${label} exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, node, "InvalidDataValue") - } - return value -} - -// A cheap proxy for the work an O(n) built-in performed, used to charge the operation budget. -const workUnits = (value: unknown): number => { - if (typeof value === "string" || Array.isArray(value)) return value.length - if (value !== null && typeof value === "object") return Object.keys(value).length - return 1 -} - // A string method's pattern argument as a host regex: a sandbox regex passes its own host // instance through (so `g` lastIndex semantics follow the spec across calls); a string becomes // a pattern, exactly as String.prototype.match/matchAll/search do (`extraFlags` adds matchAll's @@ -768,7 +633,7 @@ const matchToValue = (match: RegExpMatchArray): Array => { return result } -const invokeStringMethod = (value: string, name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { +const invokeStringMethod = (value: string, name: string, args: Array, node: AstNode): unknown => { const str = (index: number): string => { const arg = args[index] if (typeof arg !== "string") throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node) @@ -781,22 +646,6 @@ const invokeStringMethod = (value: string, name: string, args: Array, n } const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index)) const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index)) - const byteLength = (text: string): number => new TextEncoder().encode(text).byteLength - const limitString = (bytes: number): void => { - if (bytes > limits.maxDataBytes) { - throw new InterpreterRuntimeError(`String.${name} exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, node, "InvalidDataValue") - } - } - const replacementCount = (search: string): number => { - if (search === "") return value.length + 1 - let count = 0 - let offset = 0 - while ((offset = value.indexOf(search, offset)) !== -1) { - count += 1 - offset += search.length - } - return count - } let result: unknown switch (name) { @@ -811,22 +660,11 @@ const invokeStringMethod = (value: string, name: string, args: Array, n break } if (args[0] instanceof SandboxRegExp) { - const parts = value.split((args[0] as SandboxRegExp).regex, optNum(1)) - if (parts.length > limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`String.split exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - result = parts + result = value.split((args[0] as SandboxRegExp).regex, optNum(1)) break } - const separator = str(0) const requestedLimit = optNum(1) - const effectiveLimit = requestedLimit === undefined ? undefined : requestedLimit >>> 0 - const maximumParts = separator === "" ? value.length : replacementCount(separator) + 1 - const parts = effectiveLimit === undefined ? maximumParts : Math.min(maximumParts, effectiveLimit) - if (parts > limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`String.split exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - result = value.split(separator, effectiveLimit) + result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0) break } case "slice": result = value.slice(optNum(0), optNum(1)); break @@ -846,22 +684,14 @@ const invokeStringMethod = (value: string, name: string, args: Array, n if (name === "replaceAll" && !pattern.global) { throw new InterpreterRuntimeError("String.replaceAll requires a regular expression with the global (g) flag.", node) } - // Growth cannot be pre-computed for patterns; the input and replacement are already - // byte-bounded, so bound the produced string instead. - const replaced = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement) - limitString(byteLength(replaced)) - result = replaced + result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement) break } if (name === "replace") { result = value.replace(str(0), str(1)) break } - const search = str(0) - const replacement = str(1) - const growth = Math.max(0, byteLength(replacement) - byteLength(search)) - limitString(byteLength(value) + replacementCount(search) * growth) - result = value.replaceAll(search, replacement) + result = value.replaceAll(str(0), str(1)) break } case "match": { @@ -870,8 +700,8 @@ const invokeStringMethod = (value: string, name: string, args: Array, n if (matched === null) return null // A global match is a plain array of matched strings; a non-global match carries // index/groups own properties, so bypass the copying data checkpoint to keep them. - if (pattern.global) return boundedData(matched, "String.match result", node, limits) - return boundedProgramValue(matchToValue(matched), "String.match result", node, limits) + if (pattern.global) return boundedData(matched, "String.match result") + return matchToValue(matched) } case "matchAll": { const pattern = toHostRegex(args[0], name, node, "g") @@ -880,11 +710,7 @@ const invokeStringMethod = (value: string, name: string, args: Array, n } // Materialized as an array (not an iterator); each entry is a match array with // index/groups own properties. Match count is bounded by the subject length. - const matches = Array.from(value.matchAll(pattern), matchToValue) - if (matches.length > limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`String.matchAll exceeds the maximum collection length of ${limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - return boundedProgramValue(matches, "String.matchAll result", node, limits) + return Array.from(value.matchAll(pattern), matchToValue) } case "search": { result = value.search(toHostRegex(args[0], name, node)) @@ -893,22 +719,11 @@ const invokeStringMethod = (value: string, name: string, args: Array, n case "repeat": { const count = num(0) if (!Number.isFinite(count) || count < 0) throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node) - limitString(byteLength(value) * Math.floor(count)) result = value.repeat(count) break } - case "padStart": { - const length = num(0) - limitString(Math.max(0, length)) - result = value.padStart(length, optStr(1)) - break - } - case "padEnd": { - const length = num(0) - limitString(Math.max(0, length)) - result = value.padEnd(length, optStr(1)) - break - } + case "padStart": result = value.padStart(num(0), optStr(1)); break + case "padEnd": result = value.padEnd(num(0), optStr(1)); break case "charAt": result = value.charAt(optNum(0) ?? 0); break case "at": result = value.at(optNum(0) ?? 0); break case "substring": result = value.substring(optNum(0) ?? 0, optNum(1)); break @@ -919,17 +734,15 @@ const invokeStringMethod = (value: string, name: string, args: Array, n case "codePointAt": result = value.codePointAt(optNum(0) ?? 0); break case "toString": result = value; break case "concat": { - const pieces = args.map((_, index) => str(index)) - limitString(byteLength(value) + pieces.reduce((size, piece) => size + byteLength(piece), 0)) - result = value.concat(...pieces) + result = value.concat(...args.map((_, index) => str(index))) break } default: throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node) } - return boundedData(result, `String.${name} result`, node, limits) + return boundedData(result, `String.${name} result`) } -const invokeNumberMethod = (value: number, name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { +const invokeNumberMethod = (value: number, name: string, args: Array, node: AstNode): unknown => { const optNum = (index: number): number | undefined => { const arg = args[index] if (arg === undefined) return undefined @@ -955,7 +768,7 @@ const invokeNumberMethod = (value: number, name: string, args: Array, n } default: throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node) } - return boundedData(result, `Number.${name} result`, node, limits) + return boundedData(result, `Number.${name} result`) } // JavaScript's String(...) without tripping over CodeMode's null-prototype data objects. @@ -982,7 +795,7 @@ const coerceToNumber = (value: unknown): number => { return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value) } -const invokeCoercion = (ref: CoercionFunction, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { +const invokeCoercion = (ref: CoercionFunction, args: Array, node: AstNode): unknown => { // Sandbox values coerce before the data checkpoint (which would JSON-serialize them): // Number(date) is its time value, String(date) its ISO form, Boolean(x) is true. const raw = args[0] @@ -993,7 +806,7 @@ const invokeCoercion = (ref: CoercionFunction, args: Array, node: AstNo if (ref.name === "parseInt") return parseInt(coerceToString(raw)) return parseFloat(coerceToString(raw)) } - const value = boundedData(args[0], `${ref.name} input`, node, limits) + const value = boundedData(args[0], `${ref.name} input`) if (ref.name === "Number") return coerceToNumber(value) if (ref.name === "Boolean") return Boolean(value) if (ref.name === "parseInt") { @@ -1005,9 +818,9 @@ const invokeCoercion = (ref: CoercionFunction, args: Array, node: AstNo return coerceToString(value) } -const invokeObjectMethod = (name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { +const invokeObjectMethod = (name: string, args: Array, node: AstNode): unknown => { const requireObject = (): Record => { - const value = boundedData(args[0], `Object.${name} input`, node, limits) + const value = boundedData(args[0], `Object.${name} input`) if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node) } @@ -1022,7 +835,7 @@ const invokeObjectMethod = (name: string, args: Array, node: AstNode, l // Object.keys(array) yields index strings (["0", "1", ...]) exactly as in JS; objects // yield their own enumerable keys. (Tool references never reach here — the interpreter // resolves them against the host tool tree first.) - const value = boundedData(args[0], "Object.keys input", node, limits) + const value = boundedData(args[0], "Object.keys input") if (Array.isArray(value)) return Object.keys(value) if (value === null || typeof value !== "object") { throw new InterpreterRuntimeError("Object.keys expects a data object or array.", node) @@ -1036,7 +849,7 @@ const invokeObjectMethod = (name: string, args: Array, node: AstNode, l const out: Record = Object.create(null) for (const source of args) { if (source === null || source === undefined) continue - const value = boundedData(source, "Object.assign input", node, limits) + const value = boundedData(source, "Object.assign input") if (value === null || typeof value !== "object" || Array.isArray(value)) throw new InterpreterRuntimeError("Object.assign expects data objects.", node) for (const [key, item] of Object.entries(value)) guardedSet(out, key, item) } @@ -1050,7 +863,7 @@ const invokeObjectMethod = (name: string, args: Array, node: AstNode, l for (const [key, item] of (args[0] as SandboxMap).map.entries()) guardedSet(out, coerceToString(key), item) return out } - const pairs = boundedData(args[0], "Object.fromEntries input", node, limits) + const pairs = boundedData(args[0], "Object.fromEntries input") if (!Array.isArray(pairs)) throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node) const out: Record = Object.create(null) for (const pair of pairs) { @@ -1090,7 +903,7 @@ const invokeMathMethod = (name: string, args: Array, node: AstNode): nu } } -const invokeJsonMethod = (name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { +const invokeJsonMethod = (name: string, args: Array, node: AstNode): unknown => { switch (name) { case "stringify": { const replacer = args[1] @@ -1100,7 +913,7 @@ const invokeJsonMethod = (name: string, args: Array, node: AstNode, lim const space = args[2] const indent = typeof space === "number" || typeof space === "string" ? space : undefined // copyIn first so only Data Values serialize, never a CodeModeFunction/ToolReference. - return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value", limits)), null, indent) + return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value")), null, indent) } case "parse": { const text = args[0] @@ -1111,13 +924,13 @@ const invokeJsonMethod = (name: string, args: Array, node: AstNode, lim } catch { throw new InterpreterRuntimeError("JSON.parse received invalid JSON.", node) } - return copyIn(parsed, "JSON.parse result", limits) + return copyIn(parsed, "JSON.parse result") } default: throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node) } } -const invokeArrayStatic = (name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { +const invokeArrayStatic = (name: string, args: Array, node: AstNode): unknown => { switch (name) { case "isArray": return Array.isArray(args[0]) @@ -1135,7 +948,7 @@ const invokeArrayStatic = (name: string, args: Array, node: AstNode, li // Map/Set materialize directly (the data checkpoint would serialize them to {}). if (args[0] instanceof SandboxMap) return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item]) if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values()) - const source = boundedData(args[0], "Array.from input", node, limits) + const source = boundedData(args[0], "Array.from input") if (typeof source === "string") return Array.from(source) if (Array.isArray(source)) return [...source] if (source !== null && typeof source === "object" && typeof (source as { length?: unknown }).length === "number") { @@ -1222,7 +1035,7 @@ const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unkn } } -const invokeRegExpMethod = (value: SandboxRegExp, name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { +const invokeRegExpMethod = (value: SandboxRegExp, name: string, args: Array, node: AstNode): unknown => { switch (name) { // test/exec run on the sandbox regex's own host instance, so `g`-flag lastIndex advances // across calls per the spec. @@ -1230,18 +1043,18 @@ const invokeRegExpMethod = (value: SandboxRegExp, name: string, args: Array, node: AstNode, limits: ResolvedExecutionLimits): unknown => { +const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array, node: AstNode): unknown => { if (ref.namespace === "console") throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node) - if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node, limits) + if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node) if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node) - if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node, limits) + if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node) if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node) if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node) if (ref.namespace === "Date") { @@ -1251,7 +1064,7 @@ const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array, no if (ref.namespace === "RegExp" || ref.namespace === "Map" || ref.namespace === "Set") { throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node) } - return invokeJsonMethod(ref.name, args, node, limits) + return invokeJsonMethod(ref.name, args, node) } // Iterable spread sources: arrays, strings (code points), Maps (entry pairs), and Sets (values). @@ -1292,46 +1105,31 @@ const collectPatternNames = (pattern: AstNode, out: Array = []): Array { private scopes: Array> - private readonly limits: ResolvedExecutionLimits private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect // Enumerable namespace/tool names at a node of the host tool tree, threaded from // ToolRuntime.make like invokeTool: the interpreter never holds the tree itself. private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray - private readonly budget: { operations: number } private readonly logs: Array - private readonly logBudget: { bytes: number } private lastValue: unknown - // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency limit). + // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap). private readonly callPermits: Semaphore.Semaphore // Fiber-backed promises whose settlement no program construct has observed yet. Successful // program completion drains these (like a runtime waiting on in-flight work at exit) and // surfaces a never-awaited failure as an unhandled-rejection diagnostic. private readonly pendingSettlements = new Set() - // Cached byte size (and, for objects, key count) of each live container, maintained incrementally - // by the mutation helpers so appending in a loop is O(1)/op rather than re-walking the whole - // container each time (which made push/index-assign/key-assign loops O(n^2) — a CPU DoS). These - // are a fast path under the authoritative copyIn/copyOut boundary checks, never a replacement. - private readonly containerSizes = new WeakMap() - private readonly objectCounts = new WeakMap() constructor( - limits: ResolvedExecutionLimits, invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, toolKeys: (path: ReadonlyArray) => ReadonlyArray, - budget: { operations: number } = { operations: 0 }, logs: Array = [], - logBudget: { bytes: number } = { bytes: 0 }, ) { const globalScope = new Map() this.scopes = [globalScope] - this.limits = limits this.invokeTool = invokeTool this.toolKeys = toolKeys - this.budget = budget this.logs = logs - this.logBudget = logBudget this.lastValue = undefined - this.callPermits = Semaphore.makeUnsafe(limits.maxConcurrency) + this.callPermits = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() }) globalScope.set("undefined", { mutable: false, value: undefined }) @@ -1460,8 +1258,6 @@ class Interpreter { } private evaluateStatement(node: AstNode): Effect.Effect { - this.recordOperation(node) - switch (node.type) { case "ExpressionStatement": return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value })) @@ -1746,7 +1542,6 @@ class Interpreter { if (iterable === undefined) { throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node) } - if (iterable !== right) self.recordWork(iterable.length, node) let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined let assignmentName: string | undefined @@ -1805,20 +1600,15 @@ class Interpreter { // any own non-index properties, e.g. match results' index/groups — exactly Object.keys in // JS), and a tool reference the namespace/tool names at its path in the host tool tree. // Returns undefined for everything else so callers can raise a contextual error. - private enumerableKeys(value: unknown, node: AstNode): Array | undefined { + private enumerableKeys(value: unknown): Array | undefined { if (value instanceof ToolReference) { - const keys = [...this.toolKeys(value.path)] - this.recordWork(keys.length, node) - return keys + return [...this.toolKeys(value.path)] } if (Array.isArray(value)) { - this.recordWork(value.length, node) return Object.keys(value) } if (value !== null && typeof value === "object" && !isRuntimeReference(value)) { - const keys = Object.keys(value) - this.recordWork(keys.length, node) - return keys + return Object.keys(value) } return undefined } @@ -1836,7 +1626,7 @@ class Interpreter { // Anything else (strings, Maps, Sets, numbers, null, ...) is a deliberate error rather // than real JS's surprising behavior (indices for strings, zero iterations for // Maps/Sets/null): the hint points at the constructs that do what the program means. - const keys = self.enumerableKeys(right, node) + const keys = self.enumerableKeys(right) if (keys === undefined) { throw new InterpreterRuntimeError( "for...in requires a plain object, array, or tools reference in CodeMode. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.", @@ -2056,8 +1846,6 @@ class Interpreter { } private evaluateExpression(node: AstNode): Effect.Effect { - this.recordOperation(node) - switch (node.type) { case "Literal": { // A regex literal parses as a Literal node carrying { pattern, flags }; construct the @@ -2066,7 +1854,7 @@ class Interpreter { if (isRecord(regex) && typeof regex.pattern === "string") { return Effect.sync(() => this.constructRegExp([regex.pattern, typeof regex.flags === "string" ? regex.flags : ""], node)) } - return Effect.sync(() => boundedData(node.value, "Literal", node, this.limits)) + return Effect.sync(() => boundedData(node.value, "Literal")) } case "Identifier": return Effect.sync(() => this.getIdentifierValue(getString(node, "name"), node)) @@ -2174,8 +1962,6 @@ class Interpreter { throw new InterpreterRuntimeError("RegExp flags must be a string.", node) } const flags = flagsArg ?? (first instanceof SandboxRegExp ? first.regex.flags : "") - boundedData(pattern, "RegExp pattern", node, this.limits) - this.recordWork(pattern.length, node) try { return new SandboxRegExp(pattern, flags) } catch (error) { @@ -2194,12 +1980,11 @@ class Interpreter { if (entries === undefined) { throw new InterpreterRuntimeError("new Map(...) expects an array of [key, value] pairs, a Map, or no argument.", node) } - this.recordWork(entries.length, node) for (const pair of entries) { if (!Array.isArray(pair)) { throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs.", node) } - this.mapSet(target, pair[0], pair[1], node) + target.map.set(pair[0], pair[1]) } return target } @@ -2217,48 +2002,11 @@ class Interpreter { if (items === undefined) { throw new InterpreterRuntimeError("new Set(...) expects an array, Set, string, or no argument.", node) } - this.recordWork(items.length, node) - for (const item of items) this.setAdd(target, item, node) + for (const item of items) target.set.add(item) return target } - // Map/Set mutations maintain the instance's incremental byte total (the same fast path the - // array/object mutation helpers use) and enforce collection-length and data-size limits - // before mutating; the authoritative copyIn/copyOut boundary checks remain underneath. - private mapSet(target: SandboxMap, key: unknown, value: unknown, node: AstNode): void { - const valueBytes = this.nestedValueBytes(value, "Map entry", node) - if (target.map.has(key)) { - const previous = this.nestedValueBytes(target.map.get(key), "Map entry", node) - const next = target.bytes - previous + valueBytes - if (next > this.limits.maxDataBytes) { - throw new InterpreterRuntimeError(`Map exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") - } - target.bytes = next - } else { - if (target.map.size >= this.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Map exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - const next = target.bytes + this.nestedValueBytes(key, "Map entry", node) + valueBytes + 2 - if (next > this.limits.maxDataBytes) { - throw new InterpreterRuntimeError(`Map exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") - } - target.bytes = next - } - target.map.set(key, value) - } - private setAdd(target: SandboxSet, value: unknown, node: AstNode): void { - if (target.set.has(value)) return - if (target.set.size >= this.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Set exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - const next = target.bytes + this.nestedValueBytes(value, "Set entry", node) + 2 - if (next > this.limits.maxDataBytes) { - throw new InterpreterRuntimeError(`Set exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") - } - target.bytes = next - target.set.add(value) - } private evaluateBinaryExpression(node: AstNode): Effect.Effect { const operator = getString(node, "operator") @@ -2313,7 +2061,7 @@ class Interpreter { result = Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey); break default: throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node) } - return boundedData(result, "Binary expression result", node, self.limits) + return boundedData(result, "Binary expression result") }) } @@ -2360,7 +2108,7 @@ class Interpreter { case "~": result = ~operand; break default: throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node) } - return boundedData(result, "Unary expression result", node, this.limits) + return boundedData(result, "Unary expression result") }) } @@ -2376,13 +2124,13 @@ class Interpreter { if (left.type === "Identifier") { const name = getString(left, "name") if (operator === "=") return self.setIdentifierValue(name, rightValue, left) - const next = boundedData(self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node), "Assignment result", node, self.limits) + const next = boundedData(self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node), "Assignment result") return self.setIdentifierValue(name, next, left) } if (left.type === "MemberExpression") { if (operator === "=") return yield* self.writeMember(left, rightValue) return yield* self.modifyMember(left, (current) => { - const next = boundedData(self.applyCompoundAssignment(operator, current, rightValue, node), "Assignment result", node, self.limits) + const next = boundedData(self.applyCompoundAssignment(operator, current, rightValue, node), "Assignment result") return Effect.succeed({ write: true, next, result: next }) }) } @@ -2428,7 +2176,7 @@ class Interpreter { return Effect.sync(() => { const name = getString(argument, "name") const current = Number(this.getIdentifierValue(name, argument)) - const next = boundedData(current + increment, "Update result", node, this.limits) as number + const next = current + increment this.setIdentifierValue(name, next, argument) return prefix ? next : current }) @@ -2437,7 +2185,7 @@ class Interpreter { if (argument.type === "MemberExpression") { return this.modifyMember(argument, (current) => { const value = Number(current) - const next = boundedData(value + increment, "Update result", node, this.limits) as number + const next = value + increment return Effect.succeed({ write: true, next, result: prefix ? next : value }) }) } @@ -2476,14 +2224,10 @@ class Interpreter { if (callable.namespace === "Object" && args[0] instanceof ToolReference) { return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node) } - const globalResult = invokeGlobalMethod(callable, args, node, self.limits) - self.recordWork(workUnits(globalResult), node) - return boundedData(globalResult, `${callable.namespace}.${callable.name} result`, node, self.limits) + return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`) } if (callable instanceof CoercionFunction) { - const coercionResult = invokeCoercion(callable, args, node, self.limits) - self.recordWork(workUnits(coercionResult), node) - return boundedData(coercionResult, `${callable.name} result`, node, self.limits) + return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`) } throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee) }) @@ -2495,8 +2239,7 @@ class Interpreter { // with a pointer at the working idioms instead of the generic plain-objects-only message. private invokeObjectMethodOnTools(name: string, ref: ToolReference, node: AstNode): unknown { if (name === "keys") { - const keys = this.enumerableKeys(ref, node)! - return boundedData(keys, "Object.keys result", node, this.limits) + return boundedData(this.enumerableKeys(ref)!, "Object.keys result") } throw new InterpreterRuntimeError( `Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`, @@ -2507,13 +2250,7 @@ class Interpreter { private invokeConsole(name: string, args: Array, node: AstNode): undefined { if (!consoleMethods.has(name)) throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node) - const line = publicErrorMessage(this.formatConsoleMessage(name, args, node)) - const bytes = dataByteLength(line) - if (this.logBudget.bytes + bytes > this.limits.maxAuditBytes) { - throw new InterpreterRuntimeError(`Execution exceeds its log limit of ${this.limits.maxAuditBytes} bytes.`, node, "AuditLimitExceeded") - } - this.logBudget.bytes += bytes - this.logs.push(line) + this.logs.push(publicErrorMessage(this.formatConsoleMessage(name, args, node))) return undefined } @@ -2537,7 +2274,7 @@ class Interpreter { return `Set(${value.set.size}) ${this.formatConsoleArgument(Array.from(value.set.values()), node)}` } if (containsRuntimeReference(value)) return "[CodeMode reference]" - const copied = copyOut(copyIn(value, "console argument", this.limits), true) + const copied = copyOut(copyIn(value, "console argument"), true) if (typeof copied === "string") return copied if (copied === null || typeof copied === "number" || typeof copied === "boolean") return String(copied) try { @@ -2550,7 +2287,7 @@ class Interpreter { private formatConsoleTable(value: unknown, columnsArgument: unknown, node: AstNode): string { if (value === undefined) return "undefined" if (containsRuntimeReference(value)) return "[CodeMode reference]" - const data = copyOut(copyIn(value, "console.table argument", this.limits), true) + const data = copyOut(copyIn(value, "console.table argument"), true) const columns = this.consoleTableColumns(columnsArgument, node) const rows = this.consoleTableRows(data, columns) const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values)))) @@ -2561,7 +2298,7 @@ class Interpreter { private consoleTableColumns(value: unknown, node: AstNode): ReadonlyArray | undefined { if (value === undefined) return undefined if (containsRuntimeReference(value)) return undefined - const columns = copyOut(copyIn(value, "console.table columns", this.limits), true) + const columns = copyOut(copyIn(value, "console.table columns"), true) return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined } @@ -2601,16 +2338,9 @@ class Interpreter { const spread = yield* self.evaluateExpression(getNode(argNode, "argument")) const items = spreadItems(spread) if (items === undefined) throw new InterpreterRuntimeError("Spread arguments require an array, string, Map, or Set in CodeMode.", argNode) - if (args.length + items.length > self.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Call arguments exceed the maximum collection length of ${self.limits.maxCollectionLength}.`, argNode, "InvalidDataValue") - } args.push(...items) - self.recordWork(items.length, argNode) } else { args.push(yield* self.evaluateExpression(argNode)) - if (args.length > self.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Call arguments exceed the maximum collection length of ${self.limits.maxCollectionLength}.`, argNode, "InvalidDataValue") - } } } return args @@ -2641,7 +2371,6 @@ class Interpreter { node, ) } - this.recordWork(items.length, node) switch (ref.name) { case "all": { @@ -2652,7 +2381,7 @@ class Interpreter { return Effect.gen(function*() { const values: Array = [] for (const settle of settles) values.push(yield* settle) - return boundedProgramValue(values, "Promise.all result", node, self.limits) + return values }) } case "allSettled": { @@ -2678,7 +2407,7 @@ class Interpreter { : Cause.squash(exit.cause) outcomes.push(Object.assign(Object.create(null) as SafeObject, { status: "rejected", reason: caughtErrorValue(thrown) })) } - return boundedProgramValue(outcomes, "Promise.allSettled result", node, self.limits) + return outcomes }) } case "race": { @@ -2741,29 +2470,19 @@ class Interpreter { private invokeIntrinsic(ref: IntrinsicReference, args: Array, node: AstNode): Effect.Effect { if (typeof ref.receiver === "string") { - this.recordWork(ref.receiver.length, node) - const result = invokeStringMethod(ref.receiver, ref.name, args, node, this.limits) - if (typeof result === "string") this.recordWork(result.length, node) - return Effect.succeed(result) + return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node)) } if (typeof ref.receiver === "number") { - return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node, this.limits)) + return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node)) } if (Array.isArray(ref.receiver)) { - if (!cheapArrayMethods.has(ref.name)) this.recordWork(ref.receiver.length, node) - const self = this - return Effect.map(this.invokeArrayMethod(ref.receiver, ref.name, args, node), (result) => { - if (Array.isArray(result)) self.recordWork(result.length, node) - return result - }) + return this.invokeArrayMethod(ref.receiver, ref.name, args, node) } if (ref.receiver instanceof SandboxDate) { return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node)) } if (ref.receiver instanceof SandboxRegExp) { - // Regex work scales with the subject; the pattern itself runs on the host engine. - this.recordWork(typeof args[0] === "string" ? args[0].length : 1, node) - return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node, this.limits)) + return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node)) } if (ref.receiver instanceof SandboxMap) { return this.invokeMapMethod(ref.receiver, ref.name, args, node) @@ -2782,46 +2501,29 @@ class Interpreter { } return (callbackArgs) => callback instanceof CoercionFunction - ? Effect.succeed(invokeCoercion(callback, callbackArgs, node, this.limits)) + ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) : this.invokeFunction(callback, callbackArgs) } private invokeMapMethod(target: SandboxMap, name: string, args: Array, node: AstNode): Effect.Effect { - const self = this switch (name) { case "get": return Effect.succeed(target.map.get(args[0])) case "has": return Effect.succeed(target.map.has(args[0])) case "set": return Effect.sync(() => { - this.recordOperation(node) - this.mapSet(target, args[0], args[1], node) + target.map.set(args[0], args[1]) return target }) - case "delete": return Effect.sync(() => { - if (!target.map.has(args[0])) return false - target.bytes -= this.nestedValueBytes(args[0], "Map entry", node) + this.nestedValueBytes(target.map.get(args[0]), "Map entry", node) + 2 - return target.map.delete(args[0]) - }) + case "delete": return Effect.sync(() => target.map.delete(args[0])) case "clear": return Effect.sync(() => { target.map.clear() - target.bytes = 2 return undefined }) - case "keys": return Effect.sync(() => { - this.recordWork(target.map.size, node) - return boundedProgramValue(Array.from(target.map.keys()), "Map.keys result", node, this.limits) - }) - case "values": return Effect.sync(() => { - this.recordWork(target.map.size, node) - return boundedProgramValue(Array.from(target.map.values()), "Map.values result", node, this.limits) - }) - case "entries": return Effect.sync(() => { - this.recordWork(target.map.size, node) - return boundedProgramValue(Array.from(target.map.entries(), ([key, item]): Array => [key, item]), "Map.entries result", node, this.limits) - }) + case "keys": return Effect.sync(() => Array.from(target.map.keys())) + case "values": return Effect.sync(() => Array.from(target.map.values())) + case "entries": return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array => [key, item])) case "forEach": { const apply = this.applyCollectionCallback(args[0], "Map.forEach", node) return Effect.gen(function*() { - self.recordWork(target.map.size, node) // Snapshot iteration, matching the array-method callback contract. for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target]) return undefined @@ -2832,37 +2534,23 @@ class Interpreter { } private invokeSetMethod(target: SandboxSet, name: string, args: Array, node: AstNode): Effect.Effect { - const self = this switch (name) { case "has": return Effect.succeed(target.set.has(args[0])) case "add": return Effect.sync(() => { - this.recordOperation(node) - this.setAdd(target, args[0], node) + target.set.add(args[0]) return target }) - case "delete": return Effect.sync(() => { - if (!target.set.has(args[0])) return false - target.bytes -= this.nestedValueBytes(args[0], "Set entry", node) + 2 - return target.set.delete(args[0]) - }) + case "delete": return Effect.sync(() => target.set.delete(args[0])) case "clear": return Effect.sync(() => { target.set.clear() - target.bytes = 2 return undefined }) case "keys": - case "values": return Effect.sync(() => { - this.recordWork(target.set.size, node) - return boundedProgramValue(Array.from(target.set.values()), `Set.${name} result`, node, this.limits) - }) - case "entries": return Effect.sync(() => { - this.recordWork(target.set.size, node) - return boundedProgramValue(Array.from(target.set.values(), (item): Array => [item, item]), "Set.entries result", node, this.limits) - }) + case "values": return Effect.sync(() => Array.from(target.set.values())) + case "entries": return Effect.sync(() => Array.from(target.set.values(), (item): Array => [item, item])) case "forEach": { const apply = this.applyCollectionCallback(args[0], "Set.forEach", node) return Effect.gen(function*() { - self.recordWork(target.set.size, node) for (const item of Array.from(target.set.values())) yield* apply([item, item, target]) return undefined }) @@ -2872,12 +2560,6 @@ class Interpreter { } private invokeArrayMethod(target: Array, name: string, args: Array, node: AstNode): Effect.Effect { - const boundedCollection = (items: Array): Array => { - if (items.length > this.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Array.${name} exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - return boundedProgramValue(items, `Array.${name} result`, node, this.limits) as Array - } const optNumber = (value: unknown, label: string): number | undefined => { if (value === undefined) return undefined if (typeof value !== "number") throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node) @@ -2888,8 +2570,8 @@ class Interpreter { if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) { throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node) } - const input = boundedData(target, "Array.join input", node, this.limits) as Array - return Effect.succeed(boundedData(input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : args[0] as string), "Array.join result", node, this.limits)) + const input = boundedData(target, "Array.join input") as Array + return Effect.succeed(input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : args[0] as string)) } case "includes": if (args.length === 0 || args.length > 2) throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node) @@ -2901,18 +2583,18 @@ class Interpreter { case "at": return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0)) case "slice": - return Effect.succeed(boundedCollection(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end")))) + return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end"))) case "concat": - return Effect.succeed(boundedCollection(target.concat(...args))) + return Effect.succeed(target.concat(...args)) case "flat": - return Effect.succeed(boundedCollection(target.flat(optNumber(args[0], "depth") ?? 1))) + return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1)) case "reverse": - return Effect.succeed(boundedCollection([...target].reverse())) + return Effect.succeed([...target].reverse()) case "sort": case "toSorted": return this.sortArray(target, args[0], node) case "toReversed": - return Effect.succeed(boundedCollection([...target].reverse())) + return Effect.succeed([...target].reverse()) case "with": { const index = optNumber(args[0], "index") ?? 0 const resolved = index < 0 ? target.length + index : index @@ -2921,42 +2603,23 @@ class Interpreter { } const copied = [...target] copied[resolved] = args[1] - return Effect.succeed(boundedCollection(copied)) + return Effect.succeed(copied) } case "push": { - if (target.length + args.length > this.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Array.push exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - // Validate before mutating (so no rollback is needed) and charge only the new elements, - // keeping a push loop O(1)/element instead of re-walking the whole array each call. - let added = 0 - for (const item of args) { - this.rejectCircularInsertion(target, item, "Array.push result", node) - added += this.nestedValueBytes(item, "Array.push result", node) + 1 - } - this.growContainerBytes(target, added, node, "Array.push result") + // Validate before mutating (so no rollback is needed): inserting a container into + // itself would create a cycle no later walk could survive. + for (const item of args) this.rejectCircularInsertion(target, item, "Array.push result", node) target.push(...args) return Effect.succeed(target.length) } case "unshift": { - if (target.length + args.length > this.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Array.unshift exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - let added = 0 - for (const item of args) { - this.rejectCircularInsertion(target, item, "Array.unshift result", node) - added += this.nestedValueBytes(item, "Array.unshift result", node) + 1 - } - this.growContainerBytes(target, added, node, "Array.unshift result") + for (const item of args) this.rejectCircularInsertion(target, item, "Array.unshift result", node) target.unshift(...args) return Effect.succeed(target.length) } - // Removals only shrink the array; drop the cached size so the next growth recomputes it. case "pop": - this.containerSizes.delete(target) return Effect.succeed(target.pop()) case "shift": - this.containerSizes.delete(target) return Effect.succeed(target.shift()) } @@ -2970,7 +2633,7 @@ class Interpreter { // synchronous; only CodeModeFunctions can await tool calls. const apply = (callbackArgs: Array): Effect.Effect => callback instanceof CoercionFunction - ? Effect.succeed(invokeCoercion(callback, callbackArgs, node, self.limits)) + ? Effect.succeed(invokeCoercion(callback, callbackArgs, node)) : self.invokeFunction(callback, callbackArgs) return Effect.gen(function*() { // Iterate a snapshot taken at call time so a callback that mutates the array can't @@ -2980,7 +2643,7 @@ class Interpreter { case "map": { const values: Array = [] for (const [index, item] of items.entries()) values.push(yield* apply([item, index, items])) - return boundedCollection(values) + return values } case "flatMap": { const values: Array = [] @@ -2988,16 +2651,15 @@ class Interpreter { const mapped = yield* apply([item, index, items]) if (Array.isArray(mapped)) values.push(...mapped) else values.push(mapped) - boundedCollection(values) } - return boundedCollection(values) + return values } case "filter": { const values: Array = [] for (const [index, item] of items.entries()) { if (yield* apply([item, index, items])) values.push(item) } - return boundedCollection(values) + return values } case "find": for (const [index, item] of items.entries()) { @@ -3074,16 +2736,12 @@ class Interpreter { throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node) } if (!(comparator instanceof CodeModeFunction)) { - return Effect.sync(() => boundedProgramValue( + return Effect.sync(() => [...target].sort((a, b) => { const left = coerceToString(a) const right = coerceToString(b) return left < right ? -1 : left > right ? 1 : 0 - }), - "Array.sort result", - node, - this.limits, - ) as Array) + })) } const self = this const mergeSort = (items: Array): Effect.Effect, unknown, R> => { @@ -3108,13 +2766,11 @@ class Interpreter { // Per spec, undefined elements sort to the end and the comparator is never called on them. const defined = target.filter((item) => item !== undefined) const undefinedCount = target.length - defined.length - return Effect.map(mergeSort(defined), (items) => - boundedProgramValue([...items, ...Array(undefinedCount).fill(undefined)], "Array.sort result", node, this.limits) as Array) + return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)]) } private evaluateObjectExpression(node: AstNode): Effect.Effect, unknown, R> { const objectValue: Record = Object.create(null) as Record - const keys = new Set() const properties = getArray(node, "properties") const self = this return Effect.gen(function*() { @@ -3133,10 +2789,6 @@ class Interpreter { for (const [key, value] of Object.entries(spread)) { if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property) objectValue[key] = value - keys.add(key) - if (keys.size > self.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Object expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, property, "InvalidDataValue") - } } continue } @@ -3169,13 +2821,9 @@ class Interpreter { throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, keyNode) } objectValue[String(key)] = yield* self.evaluateExpression(valueNode) - keys.add(String(key)) - if (keys.size > self.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Object expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, property, "InvalidDataValue") - } } - return boundedProgramValue(objectValue, "Object expression result", node, self.limits) as Record + return objectValue }) } @@ -3188,9 +2836,6 @@ class Interpreter { for (const elementValue of elements) { if (elementValue === null) { values.push(undefined) - if (values.length > self.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Array expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, node, "InvalidDataValue") - } continue } const element = asNode(elementValue, "elements") @@ -3199,15 +2844,11 @@ class Interpreter { const items = spreadItems(spread) if (items === undefined) throw new InterpreterRuntimeError("Array spread requires an array, string, Map, or Set in CodeMode.", element) values.push(...items) - self.recordWork(items.length, element) } else { values.push(yield* self.evaluateExpression(element)) } - if (values.length > self.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Array expression exceeds the maximum collection length of ${self.limits.maxCollectionLength}.`, node, "InvalidDataValue") - } } - return boundedProgramValue(values, "Array expression result", node, self.limits) as Array + return values }) } @@ -3228,15 +2869,13 @@ class Interpreter { } output += rawValue.cooked - boundedData(output, "Template literal result", node, self.limits) if (index < expressions.length) { const raw = yield* self.evaluateExpression(asNode(expressions[index], "expressions")) // Sandbox values stringify directly (ISO date, /regex/ form) — the data checkpoint // would JSON-serialize them first (a regex would render "[object Object]" via {}). - const value = isSandboxValue(raw) ? raw : boundedData(raw, "Template interpolation", node, self.limits) + const value = isSandboxValue(raw) ? raw : boundedData(raw, "Template interpolation") output += coerceToString(value) - boundedData(output, "Template literal result", node, self.limits) } } @@ -3501,25 +3140,8 @@ class Interpreter { }) } - // Writes `next` to a resolved member, enforcing index/capacity/byte limits and rolling - // back the mutation if the bound is exceeded (so a caught error can't leave it grown). - // Byte size of a container, cached after the first walk and maintained incrementally by the - // mutation helpers. O(1) on a cache hit; O(container) once on the first touch. - private cachedContainerBytes(container: object, node: AstNode): number { - const cached = this.containerSizes.get(container) - if (cached !== undefined) return cached - const bytes = runtimeValueBytes(container, "value", node, this.limits) - this.recordWork(workUnits(container), node) - this.containerSizes.set(container, bytes) - return bytes - } - - // Bytes a value contributes when nested one level inside a container; also enforces that the - // nested value's depth stays within maxValueDepth. O(value), independent of the container size. - private nestedValueBytes(value: unknown, label: string, node: AstNode): number { - return runtimeValueBytes(value, label, node, this.limits, 1) - } - + // Rejects inserting a value that (transitively) contains the container it is being inserted + // into — the mutation that would create a circular structure no later walk could survive. private rejectCircularInsertion(container: object, value: unknown, label: string, node: AstNode, seen = new Set()): void { if (value === container) throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return @@ -3529,73 +3151,20 @@ class Interpreter { seen.delete(value) } - // Add `addedBytes` of new entries to a container, rejecting (before any mutation) if that would - // exceed maxDataBytes, then record the container's new cached size. - private growContainerBytes(container: object, addedBytes: number, node: AstNode, label: string): void { - const next = this.cachedContainerBytes(container, node) + addedBytes - if (next > this.limits.maxDataBytes) { - throw new InterpreterRuntimeError(`${label} exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") - } - this.containerSizes.set(container, next) - } - private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void { if (Array.isArray(reference.target)) { const target = reference.target const index = key as number - if (!Number.isInteger(index) || index < 0 || index >= this.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Array assignment index must be between 0 and ${this.limits.maxCollectionLength - 1}.`, node, "InvalidDataValue") + if (!Number.isInteger(index) || index < 0) { + throw new InterpreterRuntimeError("Array assignment index must be a non-negative integer.", node, "InvalidDataValue") } this.rejectCircularInsertion(target, next, "Array assignment result", node) - const addedBytes = this.nestedValueBytes(next, "Array assignment result", node) - if (index === target.length) { - // Append — the hot path; O(1) incremental size update (this is the O(n^2)-loop fix). - this.growContainerBytes(target, addedBytes + 1, node, "Array assignment result") - target[index] = next - } else if (index < target.length) { - // Replace an existing slot (value or hole): adjust by the byte delta. - const oldBytes = this.nestedValueBytes(target[index], "Array assignment result", node) - const nextSize = this.cachedContainerBytes(target, node) + addedBytes - oldBytes - if (nextSize > this.limits.maxDataBytes) { - throw new InterpreterRuntimeError(`Array assignment result exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") - } - this.containerSizes.set(target, nextSize) - target[index] = next - } else { - // index > length introduces holes; fall back to a full revalidation and reset the cache. - const previousLength = target.length - target[index] = next - try { - boundedProgramValue(target, "Array assignment result", node, this.limits) - this.containerSizes.set(target, runtimeValueBytes(target, "value", node, this.limits)) - } catch (error) { - delete target[index] - target.length = previousLength - throw error - } - } + target[index] = next return } const target = reference.target as SafeObject const objectKey = key as string this.rejectCircularInsertion(target, next, "Object assignment result", node) - const addedBytes = this.nestedValueBytes(next, "Object assignment result", node) - if (Object.hasOwn(target, objectKey)) { - const oldBytes = this.nestedValueBytes(target[objectKey], "Object assignment result", node) - const nextSize = this.cachedContainerBytes(target, node) + addedBytes - oldBytes - if (nextSize > this.limits.maxDataBytes) { - throw new InterpreterRuntimeError(`Object assignment result exceeds the maximum data size of ${this.limits.maxDataBytes} bytes.`, node, "InvalidDataValue") - } - this.containerSizes.set(target, nextSize) - target[objectKey] = next - return - } - const count = (this.objectCounts.get(target) ?? Object.keys(target).length) + 1 - if (count > this.limits.maxCollectionLength) { - throw new InterpreterRuntimeError(`Object assignment exceeds the maximum collection length of ${this.limits.maxCollectionLength}.`, node, "InvalidDataValue") - } - this.growContainerBytes(target, dataByteLength(objectKey) + addedBytes + 1, node, "Object assignment result") - this.objectCounts.set(target, count) target[objectKey] = next } @@ -3681,19 +3250,6 @@ class Interpreter { this.scopes.pop() } - private recordOperation(node: AstNode): void { - this.recordWork(1, node) - } - - // Charge `units` of work to the operation budget so O(n) built-ins (collection/string - // walks and spreads) are bounded by maxOperations, not only by the wall-clock timeout. - private recordWork(units: number, node?: AstNode): void { - this.budget.operations += Math.max(1, Math.ceil(units)) - - if (this.budget.operations > this.limits.maxOperations) { - throw new InterpreterRuntimeError(`Execution exceeded its operation limit of ${this.limits.maxOperations}.`, node, "OperationLimitExceeded") - } - } } /** @@ -3716,18 +3272,10 @@ const executeWithLimits = >( ...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }), ...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }), } - const tools = ToolRuntime.make((options.tools ?? {}) as HostTools>, limits.maxToolCalls, limits, hooks, searchIndex) + const tools = ToolRuntime.make((options.tools ?? {}) as HostTools>, limits.maxToolCalls, hooks, searchIndex) const logs: Array = [] const logged = () => logs.length > 0 ? { logs: [...logs] } : {} - if (new TextEncoder().encode(options.code).byteLength > limits.maxSourceBytes) { - return Effect.succeed({ - ok: false, - error: { kind: "InvalidDataValue", message: `Code exceeds the maximum source size of ${limits.maxSourceBytes} bytes.` }, - toolCalls: tools.calls, - }) - } - if (options.code.trim().length === 0) { return Effect.succeed({ ok: false, @@ -3738,12 +3286,9 @@ const executeWithLimits = >( const operation = Effect.gen(function*() { const program = parseProgram(options.code) - const interpreter = new Interpreter>(limits, tools.invoke, tools.keys, { operations: 0 }, logs, { bytes: 0 }) + const interpreter = new Interpreter>(tools.invoke, tools.keys, logs) const value = yield* interpreter.run(program) - const result = copyOut(copyIn(value, "Execution result", limits), true) as DataValue - if (dataByteLength(result) > limits.maxDataBytes) { - throw new InterpreterRuntimeError(`Execution result exceeds the maximum data size of ${limits.maxDataBytes} bytes.`, undefined, "InvalidDataValue") - } + const result = copyOut(copyIn(value, "Execution result"), true) as DataValue return { ok: true, value: result, @@ -3751,15 +3296,21 @@ const executeWithLimits = >( toolCalls: tools.calls, } satisfies ExecuteResult }).pipe( - Effect.timeoutOrElse({ - duration: limits.timeoutMs, - orElse: () => Effect.succeed({ - ok: false, - error: { kind: "TimeoutExceeded", message: `Execution timed out after ${limits.timeoutMs}ms.` }, - ...logged(), - toolCalls: tools.calls, - } satisfies ExecuteResult), - }), + (program) => { + const timeoutMs = limits.timeoutMs + if (timeoutMs === undefined) return program + return program.pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => Effect.succeed({ + ok: false, + error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` }, + ...logged(), + toolCalls: tools.calls, + } satisfies ExecuteResult), + }), + ) + }, ) return operation.pipe( @@ -3856,7 +3407,7 @@ export const make = = {}>(options: const tools = (options.tools ?? {}) as HostTools> ToolRuntime.assertValidTools(tools) const limits = resolveExecutionLimits(options.limits) - const discovery = ToolRuntime.discoveryPlan(tools, options.discovery?.maxInlineCatalogBytes) + const discovery = ToolRuntime.discoveryPlan(tools, options.discovery?.maxInlineCatalogTokens) const executeProgram = (code: string) => executeWithLimits({ ...options, code }, limits, discovery.searchIndex) const catalog = discovery.catalog const instructions = discovery.instructions diff --git a/packages/codemode/src/token.ts b/packages/codemode/src/token.ts new file mode 100644 index 000000000000..1e06bec1e48c --- /dev/null +++ b/packages/codemode/src/token.ts @@ -0,0 +1,10 @@ +/** + * Token estimation for budgeting model-facing text. Copied from + * `@opencode-ai/core/util/token` (chars / 4) so this package stays + * dependency-free; keep the two in sync if the heuristic ever changes. + */ +export * as Token from "./token.js" + +const CHARS_PER_TOKEN = 4 + +export const estimate = (input: string) => Math.max(0, Math.round(input.length / CHARS_PER_TOKEN)) diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index c693cc477b0a..ad76ed8b768f 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -9,6 +9,7 @@ import { outputTypeScript, type Definition, } from "./tool.js" +import { estimate } from "./token.js" import { SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js" export type HostTool = (...args: Array) => Effect.Effect @@ -64,7 +65,7 @@ export type ToolDescription = { export type SafeObject = Record const reservedNamespace = "$codemode" -const defaultMaxInlineCatalogBytes = 16_000 +const defaultMaxInlineCatalogTokens = 2_000 const defaultSearchLimit = 10 const searchSignature = "tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>" @@ -72,16 +73,16 @@ export class ToolReference { constructor(readonly path: ReadonlyArray) {} } -export type DataLimits = { - readonly maxValueDepth: number - readonly maxCollectionLength: number - readonly maxDataBytes: number - readonly maxAuditBytes: number -} +/** + * Maximum nesting depth for values crossing a data boundary. Fixed (not a configurable + * limit) purely because it produces a clearer diagnostic than a native stack-overflow + * RangeError would. + */ +const MAX_VALUE_DEPTH = 32 export class ToolRuntimeError extends Error { constructor( - readonly kind: "UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded" | "AuditLimitExceeded", + readonly kind: "UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded", message: string, readonly suggestions: ReadonlyArray = [], ) { @@ -106,9 +107,9 @@ const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"]) export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name) -export const copyIn = (value: unknown, label: string, limits?: DataLimits, depth = 0, seen = new Set()): unknown => { - if (limits && depth > limits.maxValueDepth) { - throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${limits.maxValueDepth}.`) +export const copyIn = (value: unknown, label: string, depth = 0, seen = new Set()): unknown => { + if (depth > MAX_VALUE_DEPTH) { + throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`) } if ( value === null || @@ -160,10 +161,7 @@ export const copyIn = (value: unknown, label: string, limits?: DataLimits, depth seen.add(value) if (Array.isArray(value)) { - if (limits && value.length > limits.maxCollectionLength) { - throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`) - } - const copied = value.map((item) => copyIn(item, label, limits, depth + 1, seen)) + const copied = value.map((item) => copyIn(item, label, depth + 1, seen)) seen.delete(value) return copied } @@ -174,15 +172,11 @@ export const copyIn = (value: unknown, label: string, limits?: DataLimits, depth } const copied: SafeObject = Object.create(null) as SafeObject - const entries = Object.entries(value) - if (limits && entries.length > limits.maxCollectionLength) { - throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum collection length of ${limits.maxCollectionLength}.`) - } - for (const [key, item] of entries) { + for (const [key, item] of Object.entries(value)) { if (isBlockedMember(key)) { throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`) } - copied[key] = copyIn(item, label, limits, depth + 1, seen) + copied[key] = copyIn(item, label, depth + 1, seen) } seen.delete(value) return copied @@ -240,14 +234,17 @@ export type DiscoveryPlan = { export type SearchEntry = { readonly description: ToolDescription + /** + * JSDoc-annotated multiline signature shown on search-result items; the compact + * single-line form (inline catalog lines) stays in `description.signature`. + */ + readonly signature: string /** Top-level namespace (first path segment), matched by the search `namespace` option. */ readonly namespace: string /** Lowercased path + description + input property names/descriptions, for substring matching. */ readonly searchText: string } -const utf8ByteLength = (value: string) => new TextEncoder().encode(value).byteLength - /** * Split a query into lowercased search terms. camelCase boundaries are split * (`resolveLibrary` -> `resolve library`) and every non-alphanumeric character is a @@ -261,6 +258,20 @@ const tokenize = (query: string): Array => .split(/[^a-z0-9]+/) .filter((term) => term.length > 0 && term !== "*") +/** + * A term plus its naive singular variants (trailing "s"/"es" stripped), so a plural + * query term ("issues") still matches indexed text that only carries the singular + * ("issue"). Matching is one-directional substring containment, so the variants are + * needed only on the query side; scoring weights are unchanged — each field check + * passes when ANY form matches. + */ +const termForms = (term: string): Array => { + const forms = [term] + if (term.endsWith("es") && term.length > 3) forms.push(term.slice(0, -2)) + if (term.endsWith("s") && term.length > 2) forms.push(term.slice(0, -1)) + return forms +} + const firstLine = (text: string) => text.split("\n", 1)[0]!.trim() /** One-line description used on inline catalog lines; the full text stays in search results. */ @@ -276,6 +287,7 @@ const catalogLine = (tool: ToolDescription) => { const toSearchEntry = (path: string, definition: Definition, description: ToolDescription): SearchEntry => ({ description, + signature: `tools.${path}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`, namespace: path.split(".", 1)[0]!, searchText: [ path, @@ -297,16 +309,21 @@ export const assertValidTools = (tools: HostTools): void => { /** * Budgeted catalog: every namespace is always listed with its tool count; full call - * signatures are inlined cheapest-first within each namespace (namespaces processed - * alphabetically) until `maxInlineCatalogBytes` is used, and the section states exactly - * how comprehensive it is — overall (COMPLETE vs PARTIAL) and per namespace. + * signatures are inlined against the `maxInlineCatalogTokens` budget (estimated tokens, + * chars/4) round-robin across namespaces — in each round (namespaces alphabetical), every + * namespace still holding un-inlined tools attempts to place its next-cheapest line, and + * a namespace whose next line does not fit is done while the others keep going — so every + * namespace gets some representation before any namespace gets everything. The section + * states exactly how comprehensive it is — overall (COMPLETE vs PARTIAL) and per + * namespace. Namespace stub lines are never budgeted: every namespace appears with its + * tool count even at budget 0. */ export const discoveryPlan = ( tools: HostTools, - maxInlineCatalogBytes = defaultMaxInlineCatalogBytes, + maxInlineCatalogTokens = defaultMaxInlineCatalogTokens, ): DiscoveryPlan => { - if (!Number.isSafeInteger(maxInlineCatalogBytes) || maxInlineCatalogBytes < 0) { - throw new RangeError("discovery.maxInlineCatalogBytes must be a non-negative safe integer") + if (!Number.isSafeInteger(maxInlineCatalogTokens) || maxInlineCatalogTokens < 0) { + throw new RangeError("discovery.maxInlineCatalogTokens must be a non-negative safe integer") } const visible = visibleDefinitions(tools) const described = visible.map(({ description }) => description) @@ -320,33 +337,38 @@ export const discoveryPlan = ( } const ordered = [...namespaces].sort(([left], [right]) => left.localeCompare(right)) - // Select which signatures fit the budget (cheapest first within each namespace, - // namespaces alphabetical) before emitting, so the list can state exactly how - // comprehensive it is. Once one line does not fit, inlining stops for every - // remaining namespace — later namespaces show counts only. - const shown = new Map>() + // Select which signatures fit the budget before emitting, so the list can state + // exactly how comprehensive it is. Round-robin fairness: in each round (namespaces + // alphabetical), every namespace still holding un-inlined tools tries to place its + // next-cheapest line against the shared budget; a namespace whose next line does not + // fit is done — the others keep going — so every namespace gets some representation + // before any namespace gets everything. + const selections = ordered.map(([namespace, group]) => ({ + namespace, + picked: new Set(), + queue: [...group].sort( + (left, right) => estimate(catalogLine(left)) - estimate(catalogLine(right)) || left.path.localeCompare(right.path), + ), + })) let used = 0 - let budgetLeft = true - let totalShown = 0 - for (const [namespace, group] of ordered) { - const picked = new Set() - if (budgetLeft) { - const cheapestFirst = [...group].sort( - (left, right) => utf8ByteLength(catalogLine(left)) - utf8ByteLength(catalogLine(right)) || left.path.localeCompare(right.path), - ) - for (const tool of cheapestFirst) { - const cost = utf8ByteLength(catalogLine(tool)) + 1 - if (used + cost > maxInlineCatalogBytes) { - budgetLeft = false - break - } - picked.add(tool) - used += cost - } + let active = selections.filter((selection) => selection.queue.length > 0) + while (active.length > 0) { + const stillActive: typeof active = [] + for (const selection of active) { + const tool = selection.queue[0]! + const cost = estimate(catalogLine(tool)) + if (used + cost > maxInlineCatalogTokens) continue + selection.queue.shift() + selection.picked.add(tool) + used += cost + if (selection.queue.length > 0) stillActive.push(selection) } - shown.set(namespace, picked) - totalShown += picked.size + active = stillActive } + const shown = new Map>( + selections.map(({ namespace, picked }) => [namespace, picked]), + ) + const totalShown = selections.reduce((total, { picked }) => total + picked.size, 0) const complete = totalShown === described.length const empty = described.length === 0 @@ -372,17 +394,17 @@ export const discoveryPlan = ( "", ...(complete ? [ - "1. Pick a tool from the list under `## Available tools` — each line is the exact call signature, followed by the tool's description.", - "2. Call it by path: `const res = await tools..(input)`", - '3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res`', - "4. Return only what you need: `return { : data. }`", + "1. Pick a tool from the list under `## Available tools` — each line is the exact call signature; use it as-is rather than guessing segments.", + "2. Call it: `const res = await tools..(input)`", + '3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string.', + "4. Return only the fields you need: `return { : data. }` — raw payloads get truncated and waste context.", ] : [ - '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "" })`', - "2. Read the matches: each item is `{ path, description, signature }` — the signature is the exact call form; read the description before using an unfamiliar tool.", - "3. Call it by path: `const res = await tools..(input)`", - '4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res`', - "5. Return only what you need: `return { : data. }`", + '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "" })` — short phrases like "list issues" work best.', + "2. Read the matches: each item is `{ path, description, signature }` — read the description before using an unfamiliar tool.", + "3. Call it with the result's `path` as-is (never guess segments): `const res = await tools..(input)`", + '4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string.', + "5. Return only the fields you need: `return { : data. }` — raw payloads get truncated and waste context.", ]), ] @@ -392,26 +414,21 @@ export const discoveryPlan = ( "", "## Rules", "", - complete - ? "- Call a tool by its path: `await tools..(input)`. The signatures listed below are exact — use them as-is rather than guessing segments." - : "- Call a tool by its path: `await tools..(input)`. The `path` in search results is exact — use it as-is rather than guessing segments.", - "- Most tools return TEXT that is actually JSON — if a result is a string, JSON.parse it before reading fields.", - "- Return small: extract only the fields you need. Do NOT return raw or large tool payloads — they get truncated and waste context.", - "- Filter, aggregate, and transform large collections in code instead of returning them or calling per-item tools one message at a time.", - "- Inspect intermediate values with console.log/warn/error/dir/table — logs come back with the result; `return` only the final, minimal answer.", - "- Run independent calls in parallel: `await Promise.all(items.map((item) => tools..(item)))` (allSettled/race/resolve/reject also work). No .then/.catch — use await with try/catch.", + "- Filter, aggregate, and transform collections in code — never return them raw or call a tool per item across messages.", + "- A result typed `Promise` has no guaranteed shape — verify what actually came back before relying on its fields.", + "- Run independent calls in parallel: `await Promise.all(items.map((item) => tools..(item)))`.", "- `Object.keys(tools)` lists namespaces; `Object.keys(tools.)` lists its tools; `for...in` works on both.", ...(complete ? [] : ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.']), - "- Files/images produced by tools never enter the program — they are attached to the final result automatically; a call that returns only media yields a small text marker instead, and your returned value plus logs is what the model reads.", ] const syntax = [ "", "## Syntax", "", - "Common syntax: arrow functions and `function` declarations (hoisted) with closures, default/rest parameters, destructuring (incl. rest/defaults), optional chaining, template literals, conditionals, switch, loops (for...of over arrays/strings/Maps/Sets, for...in over object keys), spread (arrays/objects/strings/Maps/Sets), try/catch, ternary, the `in` operator, logical assignment (??=/||=/&&=), and bitwise operators (& | ^ ~ << >> >>>). Signal failure with `throw` (any value) or `throw new Error(message)`.", - "Transform data with array methods (map/filter/reduce/reduceRight/flatMap/forEach/find/findIndex/findLast/findLastIndex/sort/toSorted/slice/concat/indexOf/at/flat/reverse/toReversed/with/includes/join, plus push/pop/shift/unshift for accumulation), string methods (toLowerCase/toUpperCase/trim/split/slice/substring/replace/replaceAll/includes/startsWith/endsWith/indexOf/padStart/padEnd/repeat/charCodeAt/match/matchAll/search), number methods (toFixed/toString(radix)/toPrecision), Object.keys/values/entries/fromEntries/hasOwn, Math.* (incl. PI/E), JSON.parse/stringify, Array.from/isArray/of, Number.isInteger/isNaN/parseInt, String.fromCharCode, parseInt/parseFloat, and Number/String/Boolean.", - "Also available: Date (Date.now(), new Date(...), getTime/toISOString/getFullYear/..., date arithmetic and comparison), regular expressions (/literals/ and new RegExp(...), with test/exec and string match/matchAll/replace/replaceAll/split/search), and Map/Set (new Map()/new Set(), get/set/add/has/delete/size/forEach; keys/values/entries return arrays). Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to {} like JSON.stringify.", + "Standard modern JavaScript works: functions/closures, destructuring, template literals, loops, try/catch, spread, optional chaining, the usual Array/String/Object/Math/JSON methods, plus Date, RegExp, Map, Set, and Promise.all/allSettled/race/resolve/reject.", + "TypeScript type annotations are allowed and stripped before execution (decorators are not supported).", + "Not supported (each fails with a message naming the alternative): classes, generators, for await...of, .then/.catch/.finally (use await with try/catch), `x instanceof Error` (caught errors are plain `{ name, message }` objects), splice.", + "Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.", ] const toolSection: Array = [""] @@ -509,21 +526,17 @@ export type ToolRuntime = { readonly keys: (path: ReadonlyArray) => ReadonlyArray } -export const dataByteLength = (value: unknown): number => - new TextEncoder().encode(JSON.stringify(value) ?? "").byteLength - const failureMessage = (error: unknown): string => error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed" export const make = ( tools: HostTools, - maxToolCalls: number, - dataLimits: DataLimits, + /** Undefined means unlimited tool calls. */ + maxToolCalls: number | undefined, hooks?: ToolCallHooks, searchIndex?: ReadonlyArray, ): ToolRuntime => { const calls: Array = [] - let auditBytes = 0 const searchEnabled = searchIndex !== undefined // Wraps the settling portion of a tool call so onToolCallEnd observes success and failure @@ -539,29 +552,16 @@ export const make = ( ) } - const checkedCopyIn = (value: unknown, label: string): unknown => { - const copied = copyIn(value, label, dataLimits) - if (dataByteLength(copied) > dataLimits.maxDataBytes) { - throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds ${dataLimits.maxDataBytes} bytes.`) - } - return copied - } - const decodeOutput = (value: unknown, name: string) => Effect.try({ - try: () => checkedCopyIn(value, `Result from tool '${name}'`), + try: () => copyIn(value, `Result from tool '${name}'`), catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`), }) const recordCall = (call: ToolCall): void => { - if (calls.length >= maxToolCalls) { + if (maxToolCalls !== undefined && calls.length >= maxToolCalls) { throw new ToolRuntimeError("ToolCallLimitExceeded", `Execution exceeded its tool-call limit of ${maxToolCalls}.`) } - const auditEntryBytes = dataByteLength(call) - if (auditBytes + auditEntryBytes > dataLimits.maxAuditBytes) { - throw new ToolRuntimeError("AuditLimitExceeded", `Execution exceeds its audit-trail limit of ${dataLimits.maxAuditBytes} bytes.`) - } - auditBytes += auditEntryBytes calls.push(call) } @@ -572,11 +572,7 @@ export const make = ( invoke: (path, args) => Effect.gen(function*() { const name = path.join(".") - const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`, dataLimits))) - const argumentBytes = dataByteLength(externalArgs) - if (argumentBytes > dataLimits.maxDataBytes) { - throw new ToolRuntimeError("InvalidDataValue", `Arguments for tool '${name}' exceed ${dataLimits.maxDataBytes} bytes.`) - } + const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`))) const call = { name } const recordAndObserve = (input: unknown) => Effect.sync(() => { @@ -612,11 +608,12 @@ export const make = ( 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) - const terms = tokenize(query) + const terms = tokenize(query).map(termForms) // Additive field-weighted scoring, summed across terms: exact path or path // segment (20) > path substring (8) > description substring (4) > any - // searchable text, incl. input parameter names/descriptions (2). An empty - // query browses everything, alphabetical by path. + // searchable text, incl. input parameter names/descriptions (2). Each term + // matches a field when any of its forms (the term or a singular variant) + // does. An empty query browses everything, alphabetical by path. const ranked = exact !== undefined ? [exact] : scoped @@ -624,12 +621,12 @@ export const make = ( const path = entry.description.path.toLowerCase() const description = entry.description.description.toLowerCase() const score = terms.reduce( - (total, term) => + (total, forms) => total + - (path === term || path.endsWith(`.${term}`) ? 20 : 0) + - (path.includes(term) ? 8 : 0) + - (description.includes(term) ? 4 : 0) + - (entry.searchText.includes(term) ? 2 : 0), + (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) + + (forms.some((form) => path.includes(form)) ? 8 : 0) + + (forms.some((form) => description.includes(form)) ? 4 : 0) + + (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0), 0, ) return { entry, score } @@ -639,9 +636,15 @@ export const make = ( right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path)) .map(({ entry }) => entry) // Result paths carry the `tools.` prefix so each `path` is directly usable - // as the call site (`await tools.github.list({ ... })`). - const items = ranked.slice(0, limit).map(({ description }) => ({ ...description, path: `tools.${description.path}` })) - return checkedCopyIn({ items, total: ranked.length }, "Result from tool '$codemode.search'") + // as the call site (`await tools.github.list({ ... })`); the signature is + // the pretty, JSDoc-annotated form (schema descriptions and constraints + // ride along as field comments). + const items = ranked.slice(0, limit).map(({ description, signature }) => ({ + ...description, + path: `tools.${description.path}`, + signature, + })) + return copyIn({ items, total: ranked.length }, "Result from tool '$codemode.search'") }, catch: (cause) => cause, }), diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index a47a1a242134..c42dc2760973 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -18,6 +18,11 @@ export type JsonSchema = { readonly items?: JsonSchema readonly additionalProperties?: boolean | JsonSchema readonly description?: string + readonly default?: unknown + readonly format?: string + readonly deprecated?: boolean + readonly minItems?: number + readonly maxItems?: number readonly $ref?: string readonly $defs?: Readonly> readonly definitions?: Readonly> @@ -57,10 +62,65 @@ const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder & const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown" -const renderSchema = (schema: JsonSchema, definitions: Readonly>): string => { +/** + * Recursion ceiling for schema rendering. Object, array, and union recursion all increment + * depth, so this bounds every recursion path — pathological or structurally cyclic schemas + * degrade to `unknown` instead of overflowing the stack (rendering must never throw). + */ +const MAX_RENDER_DEPTH = 8 + +type RenderContext = { + readonly definitions: Readonly> + /** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */ + readonly pretty: boolean +} + +/** + * Schema constraints a TypeScript type cannot express natively but a model benefits from, + * surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). + */ +const docTags = (schema: JsonSchema): Array => { + const tags: Array = [] + if (schema.deprecated === true) tags.push("@deprecated") + if (schema.default !== undefined) { + try { + const rendered = JSON.stringify(schema.default) + if (rendered !== undefined) tags.push(`@default ${rendered}`) + } catch { + // unserializable default: skip rather than emit a broken tag + } + } + if (typeof schema.format === "string") tags.push(`@format ${schema.format}`) + if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`) + if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`) + return tags +} + +/** + * Format a schema `description` plus `tags` as a JSDoc comment at the given indent, + * preserving multi-line text (a single line stays `/** … *\/`; multiple lines become a + * `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and + * blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so + * callers can prepend it directly to the field line. + */ +const jsdoc = (description: string | undefined, tags: ReadonlyArray, pad: string): string => { + const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) => + line.replaceAll("*/", "* /").replace(/\s+$/, "")) + while (lines.length > 0 && lines[0]!.trim() === "") lines.shift() + while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop() + if (lines.length === 0) return "" + if (lines.length === 1) return `${pad}/** ${lines[0]} */\n` + const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n") + return `${pad}/**\n${body}\n${pad} */\n` +} + +const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: ReadonlySet = new Set()): string => { + if (depth > MAX_RENDER_DEPTH) return "unknown" if (schema.$ref) { const name = schema.$ref.split("/").pop() - return name && definitions[name] ? renderSchema(definitions[name], definitions) : name ?? "unknown" + if (!name || !ctx.definitions[name]) return name ?? "unknown" + if (seen.has(name)) return name // recursive type: reference by name rather than loop + return renderSchema(ctx.definitions[name], ctx, depth, new Set([...seen, name])) } if (schema.const !== undefined) return renderLiteral(schema.const) if (schema.enum) return schema.enum.map(renderLiteral).join(" | ") @@ -76,38 +136,61 @@ const renderSchema = (schema: JsonSchema, definitions: Readonly renderSchema(item, definitions)).join(" | ") + return alternatives.map((item) => renderSchema(item, ctx, depth + 1, seen)).join(" | ") + } + if (Array.isArray(schema.type)) { + return schema.type.map((item) => renderSchema({ type: item }, ctx, depth + 1, seen)).join(" | ") } - if (Array.isArray(schema.type)) return schema.type.map((item) => renderSchema({ type: item }, definitions)).join(" | ") if (schema.type === "string") return "string" if (schema.type === "number" || schema.type === "integer") return "number" if (schema.type === "boolean") return "boolean" if (schema.type === "null") return "null" - if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, definitions)}>` + if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, ctx, depth + 1, seen)}>` if (schema.type === "object" || schema.properties) { const required = new Set(schema.required ?? []) - const fields = Object.entries(schema.properties ?? {}).map(([name, value]) => - `${name}${required.has(name) ? "" : "?"}: ${renderSchema(value, definitions)}`) - if (schema.additionalProperties && typeof schema.additionalProperties === "object") { - fields.push(`[key: string]: ${renderSchema(schema.additionalProperties, definitions)}`) + const properties = Object.entries(schema.properties ?? {}) + const additional = schema.additionalProperties + const indexType = additional && typeof additional === "object" ? renderSchema(additional, ctx, depth + 1, seen) : undefined + const field = ([name, value]: readonly [string, JsonSchema]) => + `${name}${required.has(name) ? "" : "?"}: ${renderSchema(value, ctx, depth + 1, seen)}` + + if (!ctx.pretty) { + const fields = properties.map(field) + if (indexType !== undefined) fields.push(`[key: string]: ${indexType}`) + return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }` } - return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }` + + // Pretty: an indented block, each described field preceded by its JSDoc comment. + if (properties.length === 0 && indexType === undefined) return "{}" + const pad = " ".repeat(depth + 1) + const lines = properties.map((entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)}`) + if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType}`) + return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}` } return "unknown" } -export const toTypeScript = (schema: Schema.Top, decoded = false): string => { - const visible = decoded ? Schema.toType(schema) : schema - const document = Schema.toJsonSchemaDocument(visible) as { - readonly schema: JsonSchema - readonly definitions?: Readonly> +export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => { + try { + const visible = decoded ? Schema.toType(schema) : schema + const document = Schema.toJsonSchemaDocument(visible) as { + readonly schema: JsonSchema + readonly definitions?: Readonly> + } + return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty }) + } catch { + return "unknown" } - return renderSchema(document.schema, document.definitions ?? {}) } /** Renders a raw JSON Schema document as a TypeScript type string. */ -export const jsonSchemaToTypeScript = (schema: JsonSchema): string => - renderSchema(schema, { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }) +export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => { + try { + return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty }) + } catch { + return "unknown" + } +} /** One input property of a tool, extracted best-effort from its input schema. */ export type InputProperty = { @@ -153,17 +236,24 @@ export const inputProperties = (definition: Definition): Array(definition: Definition): string => - isEffectSchema(definition.input) ? toTypeScript(definition.input) : jsonSchemaToTypeScript(definition.input) +/** + * The model-visible TypeScript type of a tool's input. `pretty` renders an indented + * multiline block with schema descriptions and constraints as JSDoc comments on the + * fields; the default stays the compact single-line form. + */ +export const inputTypeScript = (definition: Definition, pretty = false): string => + isEffectSchema(definition.input) ? toTypeScript(definition.input, false, pretty) : jsonSchemaToTypeScript(definition.input, pretty) -/** The model-visible TypeScript type of a tool's result; tools without an output schema return `unknown`. */ -export const outputTypeScript = (definition: Definition): string => +/** + * The model-visible TypeScript type of a tool's result; tools without an output schema + * return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs. + */ +export const outputTypeScript = (definition: Definition, pretty = false): string => definition.output === undefined ? "unknown" : isEffectSchema(definition.output) - ? toTypeScript(definition.output, true) - : jsonSchemaToTypeScript(definition.output) + ? toTypeScript(definition.output, true, pretty) + : jsonSchemaToTypeScript(definition.output, pretty) /** * Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure); diff --git a/packages/codemode/src/values.ts b/packages/codemode/src/values.ts index 74960e0a3762..e7ea42634aa3 100644 --- a/packages/codemode/src/values.ts +++ b/packages/codemode/src/values.ts @@ -38,16 +38,14 @@ export class SandboxRegExp { } } -/** A keyed collection with SameValueZero keys; `bytes` caches its incremental size accounting. */ +/** A keyed collection with SameValueZero keys. */ export class SandboxMap { readonly map = new Map() - bytes = 2 } -/** A unique-value collection; `bytes` caches its incremental size accounting. */ +/** A unique-value collection. */ export class SandboxSet { readonly set = new Set() - bytes = 2 } export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet => diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 92c64b7ff7a3..da5a40146bf2 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test" import { Cause, Effect, Schema } from "effect" -import { CodeMode, ExecuteInputSchema, ExecuteResultSchema, Tool, toolError } from "../src/index.js" -import type { InternalExecutionLimits } from "../src/codemode.js" +import { CodeMode, ExecuteInputSchema, ExecuteResultSchema, Tool, toolError, type ExecutionLimits } from "../src/index.js" import type { Definition } from "../src/tool.js" const run = (tool: Definition) => @@ -239,7 +238,7 @@ describe("CodeMode console capture", () => { describe("CodeMode output budget", () => { test("truncates an oversized result value with a marker instead of failing", async () => { - const limits: InternalExecutionLimits = { maxOutputBytes: 40 } + const limits: ExecutionLimits = { maxOutputBytes: 40 } const result = await Effect.runPromise(CodeMode.execute({ code: `return { data: "${"x".repeat(200)}" }`, limits, @@ -254,7 +253,7 @@ describe("CodeMode output budget", () => { }) test("keeps leading logs within the remaining budget and marks the cut", async () => { - const limits: InternalExecutionLimits = { maxOutputBytes: 40 } + const limits: ExecutionLimits = { maxOutputBytes: 40 } const result = await Effect.runPromise(CodeMode.execute({ code: ` console.log("first line") @@ -398,7 +397,8 @@ describe("CodeMode public contract", () => { // A fully inlined catalog does not advertise search in the instructions... expect(runtime.instructions()).not.toMatch(/\$codemode/) - // ...but the search tool stays registered, so a speculative call still works. + // ...but the search tool stays registered, so a speculative call still works. Search + // results carry the pretty multiline signature; the inline catalog stays compact. const result = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "order" })`)) expect(result.ok).toBe(true) if (result.ok) { @@ -406,7 +406,7 @@ describe("CodeMode public contract", () => { items: [{ path: "tools.orders.lookup", description: "Look up an order by ID", - signature: "tools.orders.lookup(input: { id: string }): Promise<{ id: string; status: string }>", + signature: "tools.orders.lookup(input: {\n id: string\n}): Promise<{\n id: string\n status: string\n}>", }], total: 1, }) @@ -423,10 +423,11 @@ describe("CodeMode public contract", () => { expect(instructions.indexOf("## Workflow")).toBeLessThan(instructions.indexOf("## Rules")) expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Syntax")) expect(instructions.indexOf("## Syntax")).toBeLessThan(instructions.indexOf("\n## Available tools (COMPLETE list")) - // Rules carry the result-shape guidance. - expect(instructions).toContain("if a result is a string, JSON.parse it before reading fields") - expect(instructions).toContain("Return small: extract only the fields you need") - expect(instructions).toContain("Call a tool by its path: `await tools..(input)`.") + // The workflow carries the result-shape guidance; Rules only add content beyond it. + expect(instructions).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string') + expect(instructions).toContain("Return only the fields you need") + expect(instructions).toContain("raw payloads get truncated and waste context") + expect(instructions).toContain("`const res = await tools..(input)`") // Placeholders use the ./ style ONLY — no fabricated tool // names, and no real catalog tools cherry-picked into example lines. expect(instructions).toContain("`return { : data. }`") @@ -437,16 +438,31 @@ describe("CodeMode public contract", () => { expect(instructions).toContain("1. Pick a tool from the list under `## Available tools`") expect(instructions).not.toContain("Browse one namespace") - const partial = CodeMode.make({ tools, discovery: { maxInlineCatalogBytes: 0 } }).instructions() - // PARTIAL: the workflow starts with search and the browse-namespace rule appears. + const partial = CodeMode.make({ tools, discovery: { maxInlineCatalogTokens: 0 } }).instructions() + // PARTIAL: the workflow starts with search (with query-style guidance that is clearly + // a query string, never a tool name) and the browse-namespace rule appears. expect(partial).toContain( - '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "" })`', + '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "" })` — short phrases like "list issues" work best.', ) expect(partial).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.') expect(partial).not.toContain("total_count") expect(partial).not.toContain("tools.orders.lookup({") }) + test("the syntax section names what is unusual or missing, not an allowlist", () => { + const instructions = CodeMode.make({ tools }).instructions() + // Models already know JavaScript; the section leads with that. + expect(instructions).toContain("Standard modern JavaScript works") + expect(instructions).toContain("TypeScript type annotations are allowed and stripped before execution") + // The not-supported list is derived from (and verified against) the interpreter. + expect(instructions).toContain("Not supported") + for (const missing of ["classes", "generators", "for await...of", ".then/.catch/.finally", "`x instanceof Error`", "splice"]) { + expect(instructions).toContain(missing) + } + // The data-boundary note survives. + expect(instructions).toContain("Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.") + }) + test("zero tools keep minimal sections and the no-tools notice", () => { const runtime = CodeMode.make({}) const instructions = runtime.instructions() @@ -473,7 +489,7 @@ describe("CodeMode public contract", () => { }) const runtime = CodeMode.make({ tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } }, - discovery: { maxInlineCatalogBytes: 0 }, + discovery: { maxInlineCatalogTokens: 0 }, }) expect(runtime.instructions()).toContain("Available tools (PARTIAL — 0 of 3 shown; find the rest with tools.$codemode.search)") expect(runtime.instructions()).toContain("- thread (2 tools, none shown)") @@ -494,12 +510,12 @@ describe("CodeMode public contract", () => { { path: "tools.thread.uploadFile", description: "Upload one readable local file to the current Discord thread", - signature: "tools.thread.uploadFile(input: { path: string }): Promise<{ sent: boolean }>", + signature: "tools.thread.uploadFile(input: {\n path: string\n}): Promise<{\n sent: boolean\n}>", }, { path: "tools.thread.generateImage", description: "Generate an image and upload it to the current Discord thread", - signature: "tools.thread.generateImage(input: { prompt: string }): Promise<{ sent: boolean }>", + signature: "tools.thread.generateImage(input: {\n prompt: string\n}): Promise<{\n sent: boolean\n}>", }, ], total: 2, @@ -553,7 +569,7 @@ describe("CodeMode public contract", () => { items: [{ path: "tools.many.tool13", description: "Numbered tool 13", - signature: "tools.many.tool13(input: { id: string }): Promise", + signature: "tools.many.tool13(input: {\n id: string\n}): Promise", }], total: 1, }) @@ -647,6 +663,46 @@ describe("CodeMode public contract", () => { } }) + test("a plural query term matches singular-only tool text", async () => { + const simple = (description: string) => + Tool.make({ + description, + input: Schema.Struct({ id: Schema.String }), + output: Schema.String, + run: () => Effect.succeed("ok"), + }) + const runtime = CodeMode.make({ + tools: { + // Neither path nor description contains "issues" — only the singular "issue". + tracker: { fetch_all: simple("Fetch every open issue in the project") }, + github: { list_issues: simple("List issues") }, + misc: { rename: simple("Rename the workspace") }, + }, + }) + + // "issues" still finds the singular-only tool (term OR singular(term) per field)... + const plural = await Effect.runPromise(runtime.execute( + `return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`, + )) + expect(plural.ok).toBe(true) + if (plural.ok) { + const value = plural.value as { items: Array<{ path: string }>; total: number } + expect(value.total).toBe(1) + expect(value.items[0]?.path).toBe("tools.tracker.fetch_all") + } + + // ...while a true "issues" path match still outranks the singular-only description match. + const ranked = await Effect.runPromise(runtime.execute( + `return await tools.$codemode.search({ query: "issues" })`, + )) + expect(ranked.ok).toBe(true) + if (ranked.ok) { + const value = ranked.value as { items: Array<{ path: string }>; total: number } + expect(value.total).toBe(2) + expect(value.items.map((item) => item.path)).toStrictEqual(["tools.github.list_issues", "tools.tracker.fetch_all"]) + } + }) + test("empty query lists everything alphabetically by path", async () => { const simple = (description: string) => Tool.make({ @@ -674,7 +730,7 @@ describe("CodeMode public contract", () => { } }) - test("inlines cheapest signatures first within the byte budget and labels every namespace", () => { + test("inlines round-robin across namespaces so one expensive namespace cannot starve the rest", () => { const cheap = Tool.make({ description: "Cheap", input: Schema.Struct({ q: Schema.String }), @@ -687,20 +743,22 @@ describe("CodeMode public contract", () => { output: Schema.String, run: () => Effect.succeed("ok"), }) - // Budget fits alpha.cheap (70 bytes incl. newline) and would numerically fit - // beta.cheap, but alpha.expensive exhausts the budget first — inlining stops for - // every remaining namespace, exactly like the ported preview algorithm. + // Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2 + // alpha.expensive does not fit, which marks only alpha done — it must NOT prevent + // other namespaces from inlining (beta already got its line in the same round). const runtime = CodeMode.make({ tools: { alpha: { cheap, expensive }, beta: { cheap } }, - discovery: { maxInlineCatalogBytes: 145 }, + discovery: { maxInlineCatalogTokens: 40 }, }) const instructions = runtime.instructions() - expect(instructions).toContain("Available tools (PARTIAL — 1 of 3 shown; find the rest with tools.$codemode.search)") + expect(instructions).toContain("Available tools (PARTIAL — 2 of 3 shown; find the rest with tools.$codemode.search)") expect(instructions).toContain("- alpha (2 tools, 1 shown)") expect(instructions).toContain(" - tools.alpha.cheap(input: { q: string }): Promise // Cheap") expect(instructions).not.toContain("tools.alpha.expensive(") - expect(instructions).toContain("- beta (1 tool, none shown)") + // Fully shown namespaces read cleanly (no "shown" annotation). + expect(instructions).toContain("- beta (1 tool)") + expect(instructions).toContain(" - tools.beta.cheap(input: { q: string }): Promise // Cheap") expect(instructions).toMatch(/\$codemode\.search/) }) @@ -741,27 +799,19 @@ describe("CodeMode public contract", () => { toolCalls: [], }) expect(Schema.decodeUnknownSync(ExecuteResultSchema)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) - - const dataLimits: InternalExecutionLimits = { maxDataBytes: 5 } - const oversized = await Effect.runPromise(CodeMode.execute({ - code: `return { value: undefined }`, - limits: dataLimits, - })) - expect(oversized.ok).toBe(false) - if (!oversized.ok) expect(oversized.error.kind).toBe("InvalidDataValue") }) test("rejects invalid configuration and discovery limits", async () => { - const invalidConcurrency: InternalExecutionLimits = { maxConcurrency: 0 } - expect(() => CodeMode.make({ limits: invalidConcurrency })).toThrow(RangeError) + expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError) expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow(RangeError) + expect(() => CodeMode.execute({ code: "return 1", limits: { maxToolCalls: -1 } })).toThrow(RangeError) expect(() => CodeMode.execute({ code: "return 1", limits: { maxOutputBytes: -1 } })).toThrow(RangeError) - expect(() => CodeMode.make({ tools, discovery: { maxInlineCatalogBytes: -1 } })).toThrow(RangeError) + expect(() => CodeMode.make({ tools, discovery: { maxInlineCatalogTokens: -1 } })).toThrow(RangeError) const result = await Effect.runPromise(CodeMode.make({ tools, - discovery: { maxInlineCatalogBytes: 0 }, + discovery: { maxInlineCatalogTokens: 0 }, }).execute( `return await tools.$codemode.search({ query: "order", limit: 0.5 })`, )) @@ -770,20 +820,47 @@ describe("CodeMode public contract", () => { expect(result.error.kind).toBe("InvalidToolInput") }) - test("enforces source, operation, and tool-call limits as diagnostics", async () => { - const sourceLimits: InternalExecutionLimits = { maxSourceBytes: 1 } - const operationLimits: InternalExecutionLimits = { maxOperations: 10 } - const cases = [ - [CodeMode.execute({ code: "return 1", limits: sourceLimits }), "InvalidDataValue"], - [CodeMode.execute({ code: "while (true) {}", limits: operationLimits }), "OperationLimitExceeded"], - [CodeMode.execute({ tools, code: source, limits: { maxToolCalls: 0 } }), "ToolCallLimitExceeded"], - ] as const + test("enforces the tool-call limit as a diagnostic", async () => { + const result = await Effect.runPromise(CodeMode.execute({ tools, code: source, limits: { maxToolCalls: 0 } })) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded") + }) + + test("timeoutMs and maxToolCalls have no defaults: absent means unlimited", async () => { + // 150 tool calls would have exceeded the old default cap of 100; with no limits + // provided, there is no cap and no timeout — budgets are host policy. + const counter = Tool.make({ + description: "Count invocations", + input: Schema.Struct({}), + output: Schema.Number, + run: () => Effect.succeed(1), + }) + const result = await Effect.runPromise(CodeMode.execute({ + tools: { host: { count: counter } }, + code: ` + let total = 0 + for (let i = 0; i < 150; i += 1) total += await tools.host.count({}) + return total + `, + })) + expect(result).toMatchObject({ ok: true, value: 150 }) + if (result.ok) expect(result.toolCalls.length).toBe(150) + }) - for (const [effect, kind] of cases) { - const result = await Effect.runPromise(effect) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.error.kind).toBe(kind) + test("the timeout interrupts a busy loop without any operation budget", async () => { + // Regression: timeout interruption must not depend on interpreter-side work accounting. + // The Effect fiber runtime auto-yields between interpreter steps, so a pure `while + // (true) {}` loop is interrupted by `timeoutMs` alone. + const startedAt = Date.now() + const result = await Effect.runPromise(CodeMode.execute({ code: "while (true) {}", limits: { timeoutMs: 200 } })) + const elapsedMs = Date.now() - startedAt + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.kind).toBe("TimeoutExceeded") + expect(result.error.message).toContain("timed out after 200ms") } + expect(elapsedMs).toBeLessThan(3_000) }) test("reserves the discovery namespace", () => { diff --git a/packages/codemode/test/enumeration.test.ts b/packages/codemode/test/enumeration.test.ts index 655230e02e6a..981316fd4dc0 100644 --- a/packages/codemode/test/enumeration.test.ts +++ b/packages/codemode/test/enumeration.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema } from "effect" import { CodeMode, Tool } from "../src/index.js" -import type { InternalExecutionLimits } from "../src/codemode.js" // Key enumeration: Object.keys and for...in share one surface over plain objects, arrays // (index strings), and tool references (namespace/tool names from the host tool tree), so a @@ -23,15 +22,14 @@ const tools = { playwright: { navigate: echo("Navigate somewhere") }, } -const run = (code: string, limits?: InternalExecutionLimits) => - Effect.runPromise(CodeMode.execute({ tools, code, ...(limits ? { limits } : {}) })) -const value = async (code: string, limits?: InternalExecutionLimits) => { - const result = await run(code, limits) +const run = (code: string) => Effect.runPromise(CodeMode.execute({ tools, code })) +const value = async (code: string) => { + const result = await run(code) if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) return result.value } -const error = async (code: string, limits?: InternalExecutionLimits) => { - const result = await run(code, limits) +const error = async (code: string) => { + const result = await run(code) if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) return result.error } @@ -146,14 +144,4 @@ describe("for...in", () => { expect(failure.message).toContain("Use for...of for arrays/strings/Maps/Sets, or Object.keys(value)") } }) - - test("charges the operation budget per iteration", async () => { - const limits: InternalExecutionLimits = { maxOperations: 60 } - const build = `const obj = { ${Array.from({ length: 20 }, (_, index) => `k${index}: ${index}`).join(", ")} }` - - // The object itself fits comfortably; the for...in walk is what exhausts the budget. - expect(await value(`${build}; return 1`, limits)).toBe(1) - const failure = await error(`${build}; let n = 0; for (const key in obj) n += 1; return n`, limits) - expect(failure.kind).toBe("OperationLimitExceeded") - }) }) diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 034a3cd5c841..3d2886ccc0d9 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema } from "effect" -import { CodeMode, Tool, toolError, type ExecuteResult } from "../src/index.js" -import type { InternalExecutionLimits } from "../src/codemode.js" +import { CodeMode, Tool, toolError, type ExecuteResult, type ExecutionLimits } from "../src/index.js" // Wave 5 acceptance suite: first-class promise values. Un-awaited tool calls start eagerly on // supervised fibers, `await` settles them, and Promise.all/allSettled/race/resolve/reject are @@ -45,7 +44,7 @@ const failingTool = Tool.make({ run: () => Effect.fail(toolError("Lookup refused")), }) -const run = (code: string, options: { trace?: Trace; limits?: InternalExecutionLimits } = {}): Promise => { +const run = (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}): Promise => { const trace = options.trace ?? makeTrace() return Effect.runPromise(CodeMode.execute({ tools: { host: { sleepy: sleepyTool(trace), fail: failingTool } }, @@ -54,13 +53,13 @@ const run = (code: string, options: { trace?: Trace; limits?: InternalExecutionL })) } -const value = async (code: string, options: { trace?: Trace; limits?: InternalExecutionLimits } = {}) => { +const value = async (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}) => { const result = await run(code, options) if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) return result.value } -const error = async (code: string, options: { trace?: Trace; limits?: InternalExecutionLimits } = {}) => { +const error = async (code: string, options: { trace?: Trace; limits?: ExecutionLimits } = {}) => { const result = await run(code, options) if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) return result.error @@ -205,7 +204,6 @@ describe("Promise.all over arbitrary arrays", () => { test("runs items.map tool calls in parallel", async () => { const trace = makeTrace() - const startedAt = Date.now() const result = await value( ` const ids = [1, 2, 3, 4] @@ -214,12 +212,12 @@ describe("Promise.all over arbitrary arrays", () => { { trace }, ) expect(result).toEqual([1, 2, 3, 4]) + // maxActive counts truly-overlapping live executions, so > 1 proves real + // parallelism deterministically — no wall-clock assertion needed. expect(trace.maxActive).toBeGreaterThan(1) - // Four 40ms sleeps in parallel finish well under the 160ms sequential floor. - expect(Date.now() - startedAt).toBeLessThan(150) }) - test("caps live tool-call concurrency at the internal limit", async () => { + test("caps live tool-call concurrency at the fixed internal constant (8)", async () => { const trace = makeTrace() const result = await value( ` diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts new file mode 100644 index 000000000000..ccbeae8ee2f4 --- /dev/null +++ b/packages/codemode/test/signature.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { CodeMode } from "../src/index.js" +import { Tool, inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool.js" + +// A raw JSON Schema tool in the shape an MCP adapter produces: render-only input schema +// whose property descriptions and constraints must surface as JSDoc in pretty signatures. +const listIssues = Tool.make({ + description: "List issues in a repository", + input: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + after: { type: "string", description: "Cursor from the previous response's pageInfo" }, + perPage: { type: "number", description: "Results per page", default: 30 }, + labels: { type: "array", items: { type: "string" }, description: "Filter by labels", minItems: 1, maxItems: 10 }, + state: { type: "string", enum: ["open", "closed"] }, + }, + required: ["owner"], + }, + run: () => Effect.succeed("[]"), +}) + +// An Effect Schema tool whose field annotations must flow through the emitted JSON Schema. +const lookupOrder = Tool.make({ + description: "Look up an order", + input: Schema.Struct({ + id: Schema.String.annotate({ description: "Order identifier" }), + verbose: Schema.optionalKey(Schema.Boolean), + }), + output: Schema.Struct({ + status: Schema.String.annotate({ description: "Current order status" }), + }), + run: () => Effect.succeed({ status: "open" }), +}) + +describe("pretty signature rendering", () => { + test("described fields get JSDoc comments; undescribed and untagged fields get none", () => { + expect(inputTypeScript(listIssues, true)).toBe( + [ + "{", + " /** Repository owner */", + " owner: string", + " /** Cursor from the previous response's pageInfo */", + " after?: string", + " /**", + " * Results per page", + " * @default 30", + " */", + " perPage?: number", + " /**", + " * Filter by labels", + " * @minItems 1", + " * @maxItems 10", + " */", + " labels?: Array", + ' state?: "open" | "closed"', + "}", + ].join("\n"), + ) + }) + + test("compact mode output is unchanged by the pretty machinery", () => { + expect(inputTypeScript(listIssues)).toBe( + '{ owner: string; after?: string; perPage?: number; labels?: Array; state?: "open" | "closed" }', + ) + expect(inputTypeScript(lookupOrder)).toBe("{ id: string; verbose?: boolean }") + expect(outputTypeScript(lookupOrder)).toBe("{ status: string }") + }) + + test("nested objects recurse with increasing indent and their own JSDoc", () => { + const pretty = jsonSchemaToTypeScript( + { + type: "object", + properties: { + filter: { + type: "object", + description: "Search filter", + properties: { state: { type: "string", description: "Issue state" } }, + }, + }, + }, + true, + ) + expect(pretty).toBe( + [ + "{", + " /** Search filter */", + " filter?: {", + " /** Issue state */", + " state?: string", + " }", + "}", + ].join("\n"), + ) + }) + + test("Effect Schema annotations become JSDoc on input and output fields", () => { + expect(inputTypeScript(lookupOrder, true)).toBe( + ["{", " /** Order identifier */", " id: string", " verbose?: boolean", "}"].join("\n"), + ) + expect(outputTypeScript(lookupOrder, true)).toBe( + ["{", " /** Current order status */", " status: string", "}"].join("\n"), + ) + }) + + test("constraints TypeScript cannot express surface as JSDoc tags", () => { + const pretty = jsonSchemaToTypeScript( + { + type: "object", + properties: { + legacy: { type: "string", deprecated: true }, + homepage: { type: "string", format: "uri" }, + tags: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 5, default: ["a", "b"] }, + }, + }, + true, + ) + expect(pretty).toContain(" /** @deprecated */\n legacy?: string") + expect(pretty).toContain(" /** @format uri */\n homepage?: string") + expect(pretty).toContain( + [" /**", ' * @default ["a","b"]', " * @minItems 2", " * @maxItems 5", " */", " tags?: Array"].join("\n"), + ) + }) + + test("skips an unserializable default rather than emitting a broken tag", () => { + const pretty = jsonSchemaToTypeScript( + { type: "object", properties: { size: { type: "number", default: 1n } } }, + true, + ) + expect(pretty).toBe(["{", " size?: number", "}"].join("\n")) + }) + + test("neutralizes */ inside descriptions so nothing closes the comment early", () => { + const pretty = jsonSchemaToTypeScript( + { type: "object", properties: { note: { type: "string", description: "Ends */ early" } } }, + true, + ) + expect(pretty).toContain(" /** Ends * / early */") + expect(pretty).not.toContain("Ends */") + }) + + test("multiline descriptions become *-prefixed blocks with blank edges trimmed", () => { + const pretty = jsonSchemaToTypeScript( + { + type: "object", + properties: { query: { type: "string", description: "\nFirst line\n\nSecond line\n" } }, + }, + true, + ) + expect(pretty).toBe( + ["{", " /**", " * First line", " *", " * Second line", " */", " query?: string", "}"].join("\n"), + ) + }) + + test("stays total on cyclic $refs and pathological nesting in both modes", () => { + const cyclic = { + $ref: "#/$defs/Node", + $defs: { Node: { type: "object", properties: { child: { $ref: "#/$defs/Node" }, name: { type: "string" } } } }, + } as const + expect(jsonSchemaToTypeScript(cyclic)).toBe("{ child?: Node; name?: string }") + expect(jsonSchemaToTypeScript(cyclic, true)).toContain("child?: Node") + + let deep: Record = { type: "string" } + for (let level = 0; level < 12; level += 1) deep = { type: "object", properties: { next: deep } } + for (const pretty of [false, true]) { + const rendered = jsonSchemaToTypeScript(deep, pretty) + expect(rendered).toContain("unknown") + expect(rendered).toContain("next?:") + } + }) +}) + +describe("pretty signatures in search results", () => { + const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } }) + + const search = async (query: string) => { + const result = await Effect.runPromise(runtime.execute( + `return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`, + )) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error("search failed") + return result.value as { items: Array<{ path: string; signature: string }>; total: number } + } + + test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and tags", async () => { + const { items } = await search("list issues repository") + const item = items.find(({ path }) => path === "tools.github.list_issues")! + expect(item.signature).toBe( + [ + "tools.github.list_issues(input: {", + " /** Repository owner */", + " owner: string", + " /** Cursor from the previous response's pageInfo */", + " after?: string", + " /**", + " * Results per page", + " * @default 30", + " */", + " perPage?: number", + " /**", + " * Filter by labels", + " * @minItems 1", + " * @maxItems 10", + " */", + " labels?: Array", + ' state?: "open" | "closed"', + "}): Promise", + ].join("\n"), + ) + }) + + test("an annotated Effect Schema tool's result signature carries field JSDoc (exact-path lookup too)", async () => { + for (const query of ["look up order", "tools.orders.lookup"]) { + const { items } = await search(query) + const item = items.find(({ path }) => path === "tools.orders.lookup")! + expect(item.signature).toBe( + [ + "tools.orders.lookup(input: {", + " /** Order identifier */", + " id: string", + " verbose?: boolean", + "}): Promise<{", + " /** Current order status */", + " status: string", + "}>", + ].join("\n"), + ) + } + }) + + test("the inline catalog line for the same tool stays single-line compact", () => { + const instructions = runtime.instructions() + expect(instructions).toContain( + ' - tools.github.list_issues(input: { owner: string; after?: string; perPage?: number; labels?: Array; state?: "open" | "closed" }): Promise // List issues in a repository', + ) + expect(instructions).toContain( + " - tools.orders.lookup(input: { id: string; verbose?: boolean }): Promise<{ status: string }> // Look up an order", + ) + expect(instructions).not.toContain("/**") + }) +}) diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index ccf8ad233921..cdc29d26a6d2 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -1,19 +1,18 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { CodeMode } from "../src/index.js" -import type { InternalExecutionLimits as ExecutionLimits } from "../src/codemode.js" // Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS; // at every data boundary (final result, tool arguments, JSON.stringify) they serialize exactly // as JSON.stringify would: Date -> ISO string (invalid -> null), RegExp/Map/Set -> {}. -const run = (code: string, limits?: ExecutionLimits) => Effect.runPromise(CodeMode.execute({ code, tools: {}, ...(limits ? { limits } : {}) })) -const value = async (code: string, limits?: ExecutionLimits) => { - const result = await run(code, limits) +const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} })) +const value = async (code: string) => { + const result = await run(code) if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`) return result.value } -const error = async (code: string, limits?: ExecutionLimits) => { - const result = await run(code, limits) +const error = async (code: string) => { + const result = await run(code) if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`) return result.error } @@ -251,15 +250,6 @@ describe("Map", () => { expect(await value(`return JSON.stringify(new Map([["a", 1]]))`)).toBe("{}") }) - test("collection-length limit rejects unbounded growth", async () => { - const failure = await error( - `const m = new Map(); for (let i = 0; i < 10; i += 1) m.set(i, i); return m.size`, - { maxCollectionLength: 3 }, - ) - expect(failure.kind).toBe("InvalidDataValue") - expect(failure.message).toMatch(/maximum collection length/) - }) - test("console.log renders map contents for debugging", async () => { const result = await run(`console.log(new Map([["a", 1]])); return null`) expect(result.ok).toBe(true) @@ -301,14 +291,6 @@ describe("Set", () => { test("sets serialize to {} at the boundary, like JSON", async () => { expect(await value(`return { s: new Set([1]) }`)).toEqual({ s: {} }) }) - - test("collection-length limit rejects unbounded growth", async () => { - const failure = await error( - `const s = new Set(); for (let i = 0; i < 10; i += 1) s.add(i); return s.size`, - { maxCollectionLength: 3 }, - ) - expect(failure.kind).toBe("InvalidDataValue") - }) }) describe("stdlib integration", () => { From cafcec4da7300bfe97a208274a57502c80c8e9fb Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 16:45:11 -0500 Subject: [PATCH 29/41] feat(opencode): run code mode without execution limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the adapter's 30s timeout — OpenCode passes no limits at all. Cancelling the tool call interrupts the execution fiber and structured concurrency takes in-flight child calls down with it; every child call is permission-gated, and CodeMode's 32KB output truncation remains the only active bound. Adapter tests track the round-robin catalog and JSDoc search signatures. --- packages/opencode/src/session/code-mode.ts | 13 ++----- .../opencode/test/session/code-mode.test.ts | 39 ++++++++++++------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index b90b69ce45a6..2edf72c157d5 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -6,20 +6,16 @@ import { CodeMode, Tool as CodeModeTool, toolError, - type ExecutionLimits, type JsonSchema, type ToolDefinition, } from "@opencode-ai/codemode" export const CODE_MODE_TOOL = "execute" -/** - * Execution limits for CodeMode programs. The timeout matches the default MCP - * request timeout; everything else uses the CodeMode defaults. - */ -const CODE_LIMITS: ExecutionLimits = { - timeoutMs: 30_000, -} +// OpenCode sets NO execution limits: no timeout and no tool-call cap. Cancelling the tool +// call interrupts the execution fiber, and structured concurrency takes the program and its +// in-flight child calls down with it; every child call is permission-gated anyway. The only +// active bound is CodeMode's default 32KB output truncation. export const Parameters = Schema.Struct({ code: Schema.String.annotate({ @@ -298,7 +294,6 @@ export function define( const runtime = CodeMode.make({ tools: toolTree(catalog, callTool), - limits: CODE_LIMITS, onToolCallStart: ({ index, name, input }) => Effect.suspend(() => { const shown = displayInput(input) diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index b6b771862319..cce66c28ff7a 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -116,8 +116,8 @@ describe("code mode execute", () => { // never cherry-picks a catalog tool or fabricates result fields. expect(tool.description).toContain("## Workflow") expect(tool.description).toContain("1. Pick a tool from the list under `## Available tools`") - expect(tool.description).toContain("if a result is a string, JSON.parse it before reading fields") - expect(tool.description).toContain("Return small: extract only the fields you need") + expect(tool.description).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string') + expect(tool.description).toContain("Return only the fields you need") expect(tool.description).not.toContain("total_count") }) @@ -153,22 +153,32 @@ describe("code mode execute", () => { client as any, ) } - tools["zeta_only_tool"] = mcpTool("only_tool", () => "") + tools["zeta_only_tool"] = mcpTool("only_tool", () => "", { + type: "object", + properties: { topic: { type: "string", description: "Subject to look up" } }, + required: ["topic"], + }) const tool = await build(tools, {}, ["alpha", "zeta"]) - // Every namespace is listed with counts; signatures inline cheapest-first until the - // budget runs out, and the description states exactly how comprehensive the list is. + // Every namespace is listed with counts; signatures inline round-robin across + // namespaces (cheapest-first within each) until the budget runs out, and the + // description states exactly how comprehensive the list is. Round-robin fairness: + // the small zeta namespace is fully shown even though alpha alone could exhaust + // the whole budget. expect(tool.description).toContain("Available tools (PARTIAL — ") expect(tool.description).toMatch(/- alpha \(150 tools, \d+ shown\)/) - expect(tool.description).toContain("- zeta (1 tool, none shown)") + expect(tool.description).toContain("- zeta (1 tool)\n") + expect(tool.description).toContain("tools.zeta.only_tool(input: { topic: string }): Promise") expect(tool.description).toContain("tools.$codemode.search(") // PARTIAL catalogs put search first in the workflow and advertise namespace browsing. expect(tool.description).toContain("1. Find a tool (skip when it is already listed below)") expect(tool.description).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.') expect(tool.description).not.toContain("total_count") - // Cheapest signatures (single-digit ops) made the cut; the most expensive did not. + // All op lines cost the same estimated tokens (chars/4 rounds away the 1- vs 3-digit + // name difference), so the path tiebreak decides: the lexicographically-first ops made + // the cut and the lexicographic tail (op_99 is maximal) did not. expect(tool.description).toContain("tools.alpha.op_0(") - expect(tool.description).not.toContain("tools.alpha.op_149(") + expect(tool.description).not.toContain("tools.alpha.op_99(") // The runtime search tool works in-program and returns complete signatures. const out = await Effect.runPromise( @@ -178,6 +188,12 @@ describe("code mode execute", () => { // Search-result paths carry the `tools.` prefix so each is directly usable as a call site. expect(result.items.map((i: any) => i.path)).toContain("tools.zeta.only_tool") expect(result.items[0].signature).toContain("tools.") + // Search results render the pretty multiline signature: MCP input-property + // descriptions ride along as JSDoc field comments. The inline catalog stays compact. + const signature = result.items.find((i: any) => i.path === "tools.zeta.only_tool").signature + expect(signature).toContain("tools.zeta.only_tool(input: {\n") + expect(signature).toContain(" /** Subject to look up */\n topic: string") + expect(tool.description).not.toContain("/**") expect(out.metadata.toolCalls).toEqual([ { tool: "$codemode.search", status: "completed", input: { query: "only tool", limit: 3 } }, ]) @@ -401,13 +417,6 @@ describe("code mode execute", () => { expect(out.attachments).toHaveLength(1) }) - test("terminates a runaway loop via the operation limit instead of hanging", async () => { - const tool = await build({}) - const output = await Effect.runPromise(tool.execute({ code: "while (true) {}" }, ctx)) - expect(output.metadata.error).toBe(true) - expect(output.output.toLowerCase()).toContain("operation") - }) - test("isolates the sandbox from host globals", async () => { const tool = await build({}) const output = await Effect.runPromise(tool.execute({ code: "return process.env" }, ctx)) From 560fc7fcbfe4e6398f3d370783c1bead0d73f481 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 19:07:34 -0500 Subject: [PATCH 30/41] feat(codemode): expand JS parity and make output truncation opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interpreter-surface parity pass: - Real error values: the seven standard error constructors are callable globals; instanceof works for Error types (a specific type matches itself and Error, never a sibling) plus Date/RegExp/Map/Set/Array/ Object/Promise. Caught failures carry the name the equivalent real-JS failure would have — JSON.parse and invalid regex patterns are SyntaxError, unknown identifiers and TDZ access ReferenceError, assignment to a constant TypeError, a bad normalize form RangeError; anything without a specific analogue stays plain Error and internal class names never leak. JSON.parse failures now include the engine's position detail. Error values remain plain { name, message } data; the brand rides on a non-enumerable symbol, so serialization is unchanged and spread loses it like JS loses the prototype. - Array splice/fill/copyWithin and keys/values/entries; string localeCompare/normalize/trimLeft/trimRight; regex failures now name the offending pattern or flags, the engine reason, and how to fix it. - Sandbox values survive intra-sandbox checkpoints: Object.* helpers, coercions, and spread pass Date/RegExp/Map/Set through by reference (copyIn gained a preserving mode), so Object.values({d}).getTime() works. The host boundary — results, tool arguments, JSON.stringify — still serializes JSON forms exactly as documented. - Console formatting is total and deep: NaN/Infinity print literally, nested sandbox values render their friendly forms, opaque references become in-place markers, cycles render [Circular]; console can no longer fail a program. - maxOutputBytes lost its 32,000 default, completing uniform no-default limits: absent now means no truncation, for hosts that bound tool output themselves. Explicit values validate and truncate as before. --- packages/codemode/README.md | 16 +- packages/codemode/codemode.md | 182 ++++++++++-- packages/codemode/src/codemode.ts | 367 ++++++++++++++++++------ packages/codemode/src/tool-runtime.ts | 55 +++- packages/codemode/test/codemode.test.ts | 104 ++++++- packages/codemode/test/parity.test.ts | 154 +++++++++- packages/codemode/test/promise.test.ts | 2 +- packages/codemode/test/stdlib.test.ts | 101 ++++++- 8 files changed, 846 insertions(+), 135 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 4176d6e0aa4f..e92ae00aad24 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -193,7 +193,7 @@ tools.github.list_issues(input: { Result paths carry the `tools.` prefix (`tools.orders.lookup`), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (with or without the `tools.` prefix) is treated as a lookup and returns that tool alone. -The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; `JSON.parse` string results; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (filter and aggregate collections in code; treat `Promise` results as shapeless until verified; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace via search when it is advertised), a short `## Syntax` section that assumes standard JavaScript and names only what is unusual (TypeScript annotations stripped; the data-boundary serialization of Date/Map/Set/RegExp) or missing (classes, generators, `for await...of`, `.then`/`.catch`/`.finally`, `x instanceof Error`, `splice`), and the budgeted `## Available tools` catalog. Every call form uses explicit `.`/`` placeholders — never a real or fabricated tool name. +The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; `JSON.parse` string results; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (filter and aggregate collections in code; treat `Promise` results as shapeless until verified; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace via search when it is advertised), a short `## Syntax` section that assumes standard JavaScript and names only what is unusual (TypeScript annotations stripped; the data-boundary serialization of Date/Map/Set/RegExp) or missing (classes, generators, `for await...of`, `.then`/`.catch`/`.finally`), and the budgeted `## Available tools` catalog. Every call form uses explicit `.`/`` placeholders — never a real or fabricated tool name. A host cannot define its own `$codemode` top-level namespace. @@ -205,14 +205,14 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports: - `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references — anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`. - Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring. - Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`. -- Common array, string, number, `Object`, `Math`, and `JSON` operations. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces and `Object.keys(tools.ns)` the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. +- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces and `Object.keys(tools.ns)` the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. - `Date` — `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones). -- Regular expressions — `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. Function replacers are not supported. +- Regular expressions — `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. Function replacers are not supported. - `Map` and `Set` — construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). - First-class promises — an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic. -- `throw value` and `throw new Error(message)` for explicit program failure. +- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure — thrown errors, interpreter runtime errors, and tool failures — is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have — `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error. -At every data boundary (final result, tool arguments, `JSON.stringify`, and the internal data checkpoints of `Object.*`/coercion helpers) the four value types serialize exactly as `JSON.stringify` would: a Date becomes its ISO string (`null` when invalid) and RegExp/Map/Set become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`. +Inside a program, Date/RegExp/Map/Set values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do the four value types serialize exactly as `JSON.stringify` would: a Date becomes its ISO string (`null` when invalid) and RegExp/Map/Set become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`. It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` — `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available. @@ -226,9 +226,9 @@ The limits are exactly three knobs: | --- | ---: | --- | | `timeoutMs` | none — no timeout | Wall-clock execution time. | | `maxToolCalls` | none — unlimited | Tool calls admitted during the execution. | -| `maxOutputBytes` | 32,000 | Model-facing output: the serialized result value plus captured logs. | +| `maxOutputBytes` | none — no truncation | Model-facing output: the serialized result value plus captured logs. | -`timeoutMs` and `maxToolCalls` have no defaults on purpose: execution budgets are host policy, not library policy — a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set none. `maxOutputBytes` keeps a default because truncation never breaks correctness, while its absence would silently flood model context. +No limit has a default, on purpose: execution budgets are host policy, not library policy — a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context. Pass only the overrides you need: @@ -244,7 +244,7 @@ const runtime = CodeMode.make({ Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` value is the same as leaving the limit unset. -Exceeding `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept from the start until the remaining budget is exhausted (with a final marker line noting the cut), and the result carries `truncated: true`. +Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept from the start until the remaining budget is exhausted (with a final marker line noting the cut), and the result carries `truncated: true`. When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) — no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded. diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 1735aecadd6a..d00890601891 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -99,13 +99,14 @@ From issue #34787 and design discussion. Do not relitigate these casually. ### Runtime behavior - Limits are EXACTLY the three public knobs: `{ timeoutMs, maxToolCalls, maxOutputBytes }` — - matching the original locked spec exactly. `timeoutMs` and `maxToolCalls` have NO defaults - (absent = no timeout / unlimited calls — budgets are host policy; user direction, Fix 6); - `maxOutputBytes` defaults to 32,000 because truncation never breaks correctness and its - absence silently floods model context. OpenCode's adapter policy (user direction): NO - limits at all — no timeout, unlimited tool calls (each child call is permission-gated; - user cancel interrupts the execution fiber and its children), default output truncation - only. + matching the original locked spec exactly. NO limit has a default (user direction, Fix 6 + for the first two; extended to `maxOutputBytes` in the truncation-layering fix below): + absent = no timeout / unlimited calls / no output truncation — budgets are host policy. + A host without its own output bounding should set `maxOutputBytes` explicitly, or + oversized results silently flood model context. OpenCode's adapter policy (user + direction): NO limits at all — no timeout, unlimited tool calls (each child call is + permission-gated; user cancel interrupts the execution fiber and its children), and no + CodeMode truncation (output bounding is OpenCode's native tool-output truncation). The internal limit system that Wave 2 kept behind an `@internal` `InternalExecutionLimits` type (maxOperations, maxDataBytes, maxValueDepth, maxCollectionLength, maxSourceBytes, maxAuditBytes, maxConcurrency) was deleted outright in @@ -113,9 +114,12 @@ From issue #34787 and design discussion. Do not relitigate these casually. `TOOL_CALL_CONCURRENCY = 8` (the fork semaphore) and `MAX_VALUE_DEPTH = 32` (the `copyIn` boundary depth check, kept only because it beats a native stack-overflow RangeError as an error message; still reports `InvalidDataValue`). -- CodeMode owns truncation of its own returned output (`maxOutputBytes`). OpenCode's native - tool-output truncation (50KB / 2000 lines in `tool.ts` + `truncate.ts`) stays on as the - outer safety net for now; we may remove that layering later. +- Truncation layering RESOLVED (user direction): CodeMode truncation is off in OpenCode. + `execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation + (50KB / 2000 lines in `tool.ts` + `truncate.ts`, full output dumped to a file) applies to + it with no special-casing — verified by tracing `wrap()` in `tool.ts:130-144` (the + `metadata.truncated` exemption never fires for `execute`). One truncation layer, the + host's. `maxOutputBytes` remains available for hosts without their own bounding. - Pure-JS built-ins only. **No ambient authority**: no fs, child processes, network/fetch, process/env, or timers in v1. The agent has the bash tool for that. - Forgiving JS semantics are locked (see §3, Wave 1a/1b-i) — missing props read `undefined`, @@ -131,9 +135,10 @@ From issue #34787 and design discussion. Do not relitigate these casually. ## 3. Current status (what is already done on `codemode-v2`) -Waves 0–5 and the post-wave fixes below are committed on `codemode-v2` (two commits: the -generic package, then the OpenCode integration); Fixes 4–8 are uncommitted working-tree -changes. Verification: from `packages/codemode`, `bun test` (169 pass / 0 fail across +Waves 0–5 and the post-wave fixes below are committed on `codemode-v2` (four commits: the +generic package, the OpenCode integration, then one follow-up pair for Fixes 4–9); the +DSL-expansion pass is uncommitted working-tree changes. Verification: from +`packages/codemode`, `bun test` (207 pass / 0 fail across `codemode/parity/stdlib/promise/enumeration/signature`) and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and `bun test test/session/` (all green — the adapter suites are `code-mode.test.ts`, 34 tests, and `code-mode-integration.test.ts`, @@ -174,7 +179,9 @@ spec. The seeded interpreter was deliberately strict; these behaviors replaced t carve-out died with that machinery in Fix 5). - **JSON semantics at every boundary and checkpoint**: Date → ISO string (invalid → null), RegExp/Map/Set → `{}`. `copyIn` also converts host `Date`/`RegExp`/`Map`/`Set` instances the - same way (a host tool may legitimately return them). + same way (a host tool may legitimately return them). (Narrowed by the DSL-expansion pass: + intra-sandbox checkpoints now preserve the instances; JSON forms apply at the host + boundary only.) - Date: `Date.now/parse/UTC`, `new Date(epoch|string|components)`, getters + UTC variants, `end - start`, `a < b`, `+date`; `toString` is ISO for cross-host determinism. - RegExp: literals + `new RegExp`, `test`/`exec` (stateful `lastIndex` for `g`), string @@ -263,8 +270,9 @@ per-MCP registration; MCP resource tools unaffected) is unchanged. error — logs are plain pre-formatted lines now), attachments: accumulated }` through the existing `Tool.ExecuteResult.attachments` → `message-v2.ts` vision plumbing; attachments ride on both success and error results. Diagnostic `suggestions` not already contained in - the message are appended to error output. Native outer truncation stays on (adapter never - sets `metadata.truncated`); CodeMode's own `maxOutputBytes` (32 KB default) cuts first. + the message are appended to error output. Native outer truncation stays on (adapter never + sets `metadata.truncated`); CodeMode's own `maxOutputBytes` (32 KB default at the time) + cut first — since the truncation-layering fix, native truncation is the only layer. Limits: `{ timeoutMs: 30_000 }` at the time (matched the default MCP request timeout); killed in Fix 6 — the adapter now passes no limits at all. - **Progress**: `onToolCallStart`/`onToolCallEnd` → `ctx.metadata({ toolCalls })` with @@ -710,26 +718,135 @@ instructions and directed further cuts): boundary ("could get weird" — type flips, program-sees vs tool-sent divergence). Logged as a next-iteration follow-up below. +**DSL-expansion pass — interpreter-surface batch from §4** (the deferred medium-tier JS +parity items, done as one focused pass; no public API or limit changes): + - **`instanceof` + real Error values**: the `errorConstructors` names (`Error`, + `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`) are + bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` → + `"function"`). Error values stay the same plain `{ name, message }` null-prototype + objects as before — the constructor name additionally rides on a NON-ENUMERABLE symbol + key (`ErrorBrand`), which every `Object.entries`-based walk (copyIn/copyOut, spread, + JSON.stringify) is blind to, so serialization is byte-identical to the old shape and the + brand is lost on spread/boundary copies exactly like JS loses the prototype. + `caughtErrorValue` produces `{ name, message }` wrappers via `createErrorValue`, so + caught interpreter AND tool failures are `instanceof Error` and carry the `name` the + equivalent real-JS failure would have (follow-up fix, user-directed — "closest to real + JS"): `InterpreterRuntimeError` gained an `errorName` field ("Error" default) set + fluently at throw sites via `.as(name)` — `JSON.parse` failures are `"SyntaxError"` (and + now include the engine's position detail in the message; safe — derived from the + program-supplied string), invalid regex patterns/flags `"SyntaxError"`, unknown + identifiers and TDZ access `"ReferenceError"`, assignment to a constant `"TypeError"`, + a bad `normalize` form `"RangeError"`; a host Error reaching the catch path directly + keeps its own name when it is one of the standard seven. Tool failures and everything + without a specific analogue stay `"Error"` — internal class names never leak. Specific + names satisfy the specific `instanceof` (`e instanceof SyntaxError`), matching JS. + The operator is handled in `evaluateBinaryExpression` + BEFORE the data-only operand check (like `typeof`, it observes any lhs — promises and + functions included); recognized rhs: the error constructors (a specific type matches its + own brand or `Error`, never a sibling), `Date`/`RegExp`/`Map`/`Set` (sandbox classes), + `Array`, `Object` (any object/function-ish value), `Promise` (`SandboxPromise`), and + `Number`/`String`/`Boolean` (always false — no boxed values exist); anything else is a + catchable error naming the recognized constructors. + - **Array methods**: `splice` (mutating, returns the removed elements; insertions run + `rejectCircularInsertion` like push/unshift; one-arg form removes to the end, undefined + delete count removes nothing), `fill` (circular-checked value) and `copyWithin` + (host-delegated), and `keys`/`values`/`entries` returning **arrays** (the Map/Set + convention — for...of and spread work either way). The `retryableArrayMethods` + "rewrite using map/filter" hint set emptied out and was deleted with its branch; unknown + array properties still read `undefined`. + - **String methods**: `localeCompare(that)` (locale/options arguments ignored — host + default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form + → catchable error naming the four valid forms), `trimLeft`/`trimRight` as + trimStart/trimEnd aliases. + - **Actionable regex failures**: `toHostRegex` and `constructRegExp` now show the + offending pattern (or flags) plus the engine reason (deduped "Invalid regular + expression:" prefix via `regexFailureReason`) and a shared escaping hint + (`escapeRegexHint`); flags failures list the valid flag letters; the + replaceAll/matchAll missing-`g` errors spell out the exact `/pattern/g` to write and + the single-match alternative. + - **copyIn split (the important one)**: `copyIn(value, label, preserveSandboxValues = + false)` — recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox + checkpoint: `Object.*` helpers, coercion/Array.from/join inputs, template + interpolation, expression-result checkpoints) is now `copyIn(value, label, true)`, + which passes `SandboxDate`/`SandboxRegExp`/`SandboxMap`/`SandboxSet` through **by + reference as leaves** (contents not walked — Map/Set members are validated at their + mutation sites) while keeping the depth (`MAX_VALUE_DEPTH`), circularity, + plain-objects-only, blocked-property, and data-only checks; un-awaited promises keep + the await-hinting rejection in BOTH modes (deliberate — JS-parity pass-through was + considered and skipped to preserve the nudge). The HOST boundary (final result, + tool-call arguments, `JSON.stringify`, tool-result intake) uses the default mode and + still serializes JSON forms (Date → ISO, RegExp/Map/Set → `{}`); host instances met on + the preserving path are defensively wrapped into sandbox equivalents. Ripple: the + `Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` → `[]`, + assign sources contribute nothing, hasOwn → false — JS has no own enumerable props + there), so interpreter internals (`.map`/`.time`/`.regex`) can never leak; the + template-literal sandbox carve-out collapsed into `boundedData`. Object/array spread + already preserved instances (reference copies, no checkpoint) — now tested. + - **Console formatting**: `formatConsoleArgument` is total and deep + (`formatConsoleValue`): numbers render via `String` (`NaN`/`Infinity`/`-Infinity` + literally — never the JSON `null`; finite numbers match their JSON form), nested + strings are JSON-quoted, sandbox values keep their friendly forms at ANY depth (ISO + date, `/regex/flags`, `Map(n) [...]`, `Set(n) [...]`), opaque references become + in-place `[CodeMode reference]` markers instead of collapsing the whole argument, + cycles render `[Circular]` (reachable via Map/Set members, which mutation never + checkpoints), and depth beyond `MAX_CONSOLE_DEPTH = 32` (fixed constant, not a knob) + degrades to `…` — console can no longer fail a program. `console.table` guards with + `containsOpaqueReference` (sandbox cells render, e.g. ISO dates) and its row/cell + walkers treat sandbox values as scalar cells. + - **Prose**: the instructions Syntax not-supported line dropped its `instanceof + Error`/splice mentions (nothing else reworded); README updated (checkpoint + preservation vs boundary serialization, error values/`instanceof`, new array/string + methods, regex-failure behavior); `supportedSyntaxMessage` left untouched (it lists + supported syntax, was already non-exhaustive, and stays accurate). + - **Tests**: package suite 169 → 209 (parity: Error/instanceof + real-JS error-name + coverage, splice/fill/copyWithin/keys/values/entries, localeCompare/normalize/trim-alias + describes; stdlib: checkpoint survival incl. tool-arg boundary pinning, stdlib + `instanceof`, regex-message assertions; codemode: NaN/Infinity + nested/cyclic console + rendering, table cells, caught-tool-failure `instanceof`); adapter suites unchanged + (34 + 16, green); both packages `tsgo --noEmit` clean. + +**Truncation layering — CodeMode truncation off in OpenCode** (user direction; resolves the +§4 outer-truncation item the OPPOSITE way from "kill the outer one"): + - `maxOutputBytes` lost its 32,000 default and now behaves exactly like the other two + limits: absent = no truncation. All three limits are uniformly no-default — budgets are + host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`; + `boundOutput` only runs when the host set the limit. Explicit values validate as before + (safe integer ≥ 0). + - OpenCode continues to pass NO limits, which now also means no CodeMode truncation. + `execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation + applies with no special-casing — verified by tracing `wrap()` (`tool.ts:130-144`, + 50KB/2000-line thresholds in `truncate.ts`, full output dumped to a file under + `tool-output/`): the `metadata.truncated` self-truncation exemption never fires for + `execute` (its metadata never sets that key). One truncation layer, the host's — and it + is the richer one (file dump + explore/grep hint vs an inline marker). + - Hosts without their own output bounding set `maxOutputBytes` explicitly; README table + and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit → 100KB + value + 50KB log line pass through unbounded, `truncated` undefined); the adapter test + that relied on the old default now asserts the oversized result reaches the shared + wrapper un-truncated. Suites: 210 + 50, tsgo clean both. + --- ## 4. Remaining work (detailed TODO) -### Next DSL-expansion pass +### Next DSL-expansion pass (done — see the DSL-expansion pass entry in §3) Batch these together — per user direction: important, but deliberately deferred to one focused interpreter-surface pass rather than picked off piecemeal. -- [ ] Medium-tier JS parity items deferred from the original audit: caught errors are plain +- [x] Medium-tier JS parity items deferred from the original audit: caught errors are plain `{ name, message }` objects, not `instanceof Error` (and `Error` isn't a value — `x instanceof Error` is unsupported syntax); `splice` (still a "rewrite using map/filter" hint) and array `entries()/keys()/values()`; `localeCompare`/`normalize`/`trimLeft`/`trimRight`; friendlier regex-y error messages. -- [ ] `Date`/`Map`/`Set`/`RegExp` values passing through `Object.*` helpers and coercion + (`fill`/`copyWithin` — which the hint set also covered — were implemented too since + they are trivial host delegations, so the hint set is gone entirely.) +- [x] `Date`/`Map`/`Set`/`RegExp` values passing through `Object.*` helpers and coercion checkpoints take their JSON forms (e.g. `Object.values({ d: date })` yields the ISO string, not the Date — calling `.getTime()` on it then fails). Currently deliberate (documented in README) but flagged as important: fix in this pass by letting sandbox values survive `Object.*`/spread checkpoints instead of JSON-serializing them. -- [ ] `console.log(NaN)` prints `"null"` (goes through the boundary chokepoint) — could +- [x] `console.log(NaN)` prints `"null"` (goes through the boundary chokepoint) — could special-case number formatting in `formatConsoleArgument`. -- [ ] Sandbox values nested inside logged containers print `[CodeMode reference]` +- [x] Sandbox values nested inside logged containers print `[CodeMode reference]` (`console.log({ m: map })`) — could deep-format instead. ### Next iteration: text-result handling (deliberate follow-up, user-directed) @@ -748,9 +865,11 @@ focused interpreter-surface pass rather than picked off piecemeal. blocks carry no filename (mime + data only) so the generic `[N images attached to the result]` stays, but `resource`/`resource_link` blocks have URIs/names we could surface, e.g. `[2 files attached: chart.png, data.csv]`. Minor. -- [ ] Decide whether OpenCode's outer native truncation gets disabled for `execute` once - `maxOutputBytes` exists (issue says CodeMode reimplements it; "maybe we kill \[the outer - one\] later"). +- [x] Truncation layering decided (user direction): the OPPOSITE of killing the outer layer — + CodeMode truncation off in OpenCode (`maxOutputBytes` lost its default; absent = no + truncation, uniform with the other two limits), native tool-output truncation is the + single active layer (verified: `execute` flows through `tool.ts` `wrap()` like any + normal tool, no exemption). See the §3 entry. - [x] Flaky wall-clock assertion removed from `test/promise.test.ts`: the parallelism test now relies solely on the deterministic `trace.maxActive > 1` counter (which proves true temporal overlap). The timeout tests were never flaky — 100ms timeout vs 60s @@ -777,11 +896,16 @@ focused interpreter-surface pass rather than picked off piecemeal. Wave 4 prompting stops the payload dumping. Both are needed. - Realistically **all MCP tools render `Promise`** (no outputSchema), so the instructions prose is the only lever for result-shape behavior in the dominant case. -- **`copyIn` has two roles**: host↔sandbox boundary AND intra-sandbox data checkpoint - (`boundedData` is now just a `copyIn` alias). Sandbox value types are converted to JSON - forms wherever it runs — that's the documented model. If you add a new value type, follow - the Wave 1b-i pattern: class in `values.ts`, opaque-by-default via `isRuntimeReference`, - explicit carve-outs, JSON form in `copyIn`, console formatting, tests. +- **`copyIn` has two roles, split by a mode flag** (DSL-expansion pass): host↔sandbox + boundary (default mode — final result, tool arguments, `JSON.stringify`, tool-result + intake; sandbox value types serialize to JSON forms) AND intra-sandbox data checkpoint + (`boundedData` = `copyIn(value, label, true)` — sandbox value instances pass through by + reference as leaves, everything else keeps the same plain-data validation). If you add a + new value type, follow the Wave 1b-i pattern: class in `values.ts`, opaque-by-default via + `isRuntimeReference`, explicit carve-outs, JSON form in `copyIn`'s boundary mode plus + pass-through in its preserving mode, console formatting (`formatConsoleValue`), tests — + and make sure the `Object.*` helpers treat it as an empty object so class fields never + leak. - The interpreter throws synchronously inside `Effect.gen`/`Effect.sync` freely; everything is normalized by `catchCause` → `normalizeError` into `Diagnostic` data. Program failures are **data, never Effect failures**; only interruption propagates. diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 59e4f1f7dfc6..b8b891fcd1cc 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -30,8 +30,8 @@ export type ExecutionLimits = { readonly maxToolCalls?: number /** * Maximum UTF-8 bytes of model-facing output: the serialized result value plus captured - * logs (default 32,000). Excess output is truncated with an explanatory marker instead of - * failing. + * logs. Excess output is truncated with an explanatory marker instead of failing. No + * default: absent means no truncation (for hosts with their own output bounding). */ readonly maxOutputBytes?: number } @@ -57,7 +57,8 @@ type ResolvedExecutionLimits = { readonly timeoutMs: number | undefined /** Undefined means unlimited tool calls. */ readonly maxToolCalls: number | undefined - readonly maxOutputBytes: number + /** Undefined means no output truncation. */ + readonly maxOutputBytes: number | undefined } /** Options for one CodeMode execution. */ @@ -258,6 +259,32 @@ class ProgramThrow { constructor(readonly value: unknown) {} } +// A bound error constructor global (`Error`, `TypeError`, ...): callable with or without +// `new`, and the recognized right-hand side of `x instanceof Error`. +class ErrorConstructorReference { + constructor(readonly name: string) {} +} + +// Error values stay plain `{ name, message }` data objects — they stringify/serialize exactly +// as before — but carry their constructor name on a non-enumerable symbol key so `instanceof +// Error` can recognize them. Object.entries/JSON walks (copyIn/copyOut, spread, stringify) +// never see the brand, and losing it on spread/boundary copies matches JS, where a spread +// error loses its prototype too. +const ErrorBrand: unique symbol = Symbol("codemode.error") + +const brandError = (errorValue: SafeObject, name: string): SafeObject => { + Object.defineProperty(errorValue, ErrorBrand, { value: name }) + return errorValue +} + +const createErrorValue = (name: string, message: string): SafeObject => + brandError(Object.assign(Object.create(null) as SafeObject, { name, message }), name) + +const errorBrandName = (value: unknown): string | undefined => + value !== null && typeof value === "object" + ? ((value as Record)[ErrorBrand] as string | undefined) + : undefined + /** Stable categories produced by program, schema, tool, and limit failures. */ export type DiagnosticKind = | "ParseError" @@ -275,18 +302,18 @@ const arrayMethods = new Set([ "map", "filter", "find", "findIndex", "findLast", "findLastIndex", "some", "every", "includes", "join", "reduce", "reduceRight", "flatMap", "forEach", "sort", "toSorted", "slice", "concat", "indexOf", "lastIndexOf", "at", "flat", "reverse", "toReversed", "with", "push", "pop", "shift", "unshift", + "splice", "fill", "copyWithin", "keys", "values", "entries", ]) -const retryableArrayMethods = new Set(["splice", "fill", "copyWithin", "keys", "values", "entries"]) const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"]) const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"]) const stringMethods = new Set([ - "toLowerCase", "toUpperCase", "trim", "trimStart", "trimEnd", "split", "slice", "substring", "substr", - "includes", "startsWith", "endsWith", "indexOf", "lastIndexOf", "replace", "replaceAll", + "toLowerCase", "toUpperCase", "trim", "trimStart", "trimEnd", "trimLeft", "trimRight", "split", "slice", + "substring", "substr", "includes", "startsWith", "endsWith", "indexOf", "lastIndexOf", "replace", "replaceAll", "repeat", "padStart", "padEnd", "charAt", "charCodeAt", "codePointAt", "at", "concat", "toString", - "match", "matchAll", "search", + "match", "matchAll", "search", "localeCompare", "normalize", ]) const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"]) @@ -329,6 +356,9 @@ const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError /** How many eagerly forked tool calls may run at once. Fixed; not a configurable knob. */ const TOOL_CALL_CONCURRENCY = 8 +/** Console formatting recursion ceiling; deeper values render as "…". Fixed; not a knob. */ +const MAX_CONSOLE_DEPTH = 32 + const validateLimit = (name: keyof ExecutionLimits, value: Value, minimum: number): Value => { if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum)) { throw new RangeError(`${name} must be a safe integer greater than or equal to ${minimum}.`) @@ -336,17 +366,25 @@ const validateLimit = (name: keyof ExecutionLi return value } -// timeoutMs and maxToolCalls have NO defaults: absent means no timeout / unlimited calls — -// budgets are host policy, not library policy. maxOutputBytes keeps a default because -// truncation never breaks correctness and its absence silently floods model context. +// No limit has a default: absent means no timeout / unlimited calls / no output truncation — +// budgets are host policy, not library policy. A host without its own output bounding should +// pass maxOutputBytes explicitly, or oversized results flood model context. const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimits => ({ timeoutMs: validateLimit("timeoutMs", limits?.timeoutMs, 1), maxToolCalls: validateLimit("maxToolCalls", limits?.maxToolCalls, 0), - maxOutputBytes: validateLimit("maxOutputBytes", limits?.maxOutputBytes, 0) ?? 32_000, + maxOutputBytes: validateLimit("maxOutputBytes", limits?.maxOutputBytes, 0), }) class InterpreterRuntimeError extends Error { readonly node?: AstNode + /** + * The constructor name a program observes when it catches this failure (`caught.name`, and + * the brand behind `caught instanceof SyntaxError` etc.). "Error" unless the failing + * operation names a standard type in real JS — e.g. JSON.parse and invalid regex patterns + * throw SyntaxError, an unknown identifier is a ReferenceError, a bad normalize form is a + * RangeError. + */ + errorName: string = "Error" constructor(message: string, node?: AstNode, readonly kind: DiagnosticKind = "ExecutionFailure", readonly suggestions?: ReadonlyArray) { super(message) @@ -356,6 +394,11 @@ class InterpreterRuntimeError extends Error { this.node = node } } + + as(errorName: string): this { + this.errorName = errorName + return this + } } const isRecord = (value: unknown): value is Record => @@ -530,32 +573,43 @@ const normalizeError = (error: unknown): Diagnostic => { // The plain value a program observes for a settled failure — shared by `catch` bindings, // `Promise.allSettled` rejection reasons, and race-loser diagnostics. A thrown program value // passes through as-is (so `throw new Error(m)` yields its `{ name, message }` object); every -// other failure becomes a plain `{ message }` object. Interpreter errors use their raw message -// so the program never sees the transpiled-source "(line N, col N)" coordinates that -// normalizeError appends — without disturbing a host/tool message that legitimately ends that -// way. Other error kinds carry no appended location, so normalizeError is used as-is. -const caughtErrorValue = (thrown: unknown): unknown => - thrown instanceof ProgramThrow - ? thrown.value - : Object.assign(Object.create(null) as SafeObject, { - message: thrown instanceof InterpreterRuntimeError ? thrown.message : normalizeError(thrown).message, - }) +// other failure becomes a plain `{ name, message }` object, error-branded so `caught +// instanceof Error` is true for interpreter and tool failures too. When a host failure is a +// real Error whose constructor name is one of the standard seven (e.g. JSON.parse throwing a +// SyntaxError), that name is carried through — both as `caught.name` and as the brand, so +// `caught instanceof SyntaxError` matches real JS. Interpreter diagnostics carry the name the +// equivalent real-JS failure would have (`errorName`, "Error" unless the throw site says +// otherwise); tool failures and internal error classes are plain "Error" — internal class +// names never leak. +// Interpreter errors use their raw message so the program never sees the transpiled-source +// "(line N, col N)" coordinates that normalizeError appends — without disturbing a host/tool +// message that legitimately ends that way. Other error kinds carry no appended location, so +// normalizeError is used as-is. +const caughtErrorValue = (thrown: unknown): unknown => { + if (thrown instanceof ProgramThrow) return thrown.value + if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message) + const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error" + return createErrorValue(name, normalizeError(thrown).message) +} // ── Built-in method/global implementations ─────────────────────────────────── // These mirror the corresponding JavaScript operations over Data Values. They are // pure (string/Object/Math/JSON/coercion) and so live as free functions; array // Methods that run CodeMode callbacks live on the interpreter (they need invokeFunction). -// The intra-sandbox data checkpoint: copies a value through `copyIn`, which validates the -// plain-data contract (depth, circularity, plain objects only, blocked properties) and -// serializes sandbox value types to their JSON forms. -const boundedData = (value: unknown, label: string): unknown => copyIn(value, label) +// The intra-sandbox data checkpoint: copies a value through `copyIn` in preserving mode, +// which validates the plain-data contract (depth, circularity, plain objects only, blocked +// properties) while keeping sandbox value instances (Date/RegExp/Map/Set) alive — so values +// flowing through `Object.*` helpers, coercion inputs, and other in-sandbox checkpoints stay +// fully usable. Only the HOST boundary (final result, tool-call arguments, JSON.stringify) +// serializes them to JSON forms via the default `copyIn` mode. +const boundedData = (value: unknown, label: string): unknown => copyIn(value, label, true) const isRuntimeReference = (value: unknown): boolean => value instanceof CodeModeFunction || value instanceof ToolReference || value instanceof IntrinsicReference || value instanceof GlobalNamespace || value instanceof GlobalMethodReference || value instanceof PromiseNamespace || value instanceof PromiseMethodReference || value instanceof SandboxPromise || value instanceof CoercionFunction || - isSandboxValue(value) + value instanceof ErrorConstructorReference || isSandboxValue(value) const containsRuntimeReference = (value: unknown, seen = new Set()): boolean => { if (isRuntimeReference(value)) return true @@ -591,7 +645,8 @@ const containsOpaqueReference = (value: unknown, seen = new Set()): bool const typeofValue = (value: unknown): string => { if ( value instanceof CodeModeFunction || value instanceof CoercionFunction || value instanceof IntrinsicReference || - value instanceof GlobalMethodReference || value instanceof PromiseMethodReference || value instanceof PromiseNamespace + value instanceof GlobalMethodReference || value instanceof PromiseMethodReference || value instanceof PromiseNamespace || + value instanceof ErrorConstructorReference ) return "function" if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object" if (value instanceof GlobalNamespace) { @@ -600,20 +655,66 @@ const typeofValue = (value: unknown): string => { return typeof value } +// `x instanceof C` against the constructors CodeMode knows. Like `typeof`, it observes any +// left-hand value (opaque references included) without coercing it. Error checks use the +// error brand: `instanceof Error` accepts every branded error; a specific error type matches +// its own brand only (as in JS, where TypeError instances are also Error instances). +const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => { + if (rhs instanceof ErrorConstructorReference) { + const brand = errorBrandName(lhs) + return brand !== undefined && (rhs.name === "Error" || brand === rhs.name) + } + if (rhs instanceof GlobalNamespace) { + switch (rhs.name) { + case "Date": return lhs instanceof SandboxDate + case "RegExp": return lhs instanceof SandboxRegExp + case "Map": return lhs instanceof SandboxMap + case "Set": return lhs instanceof SandboxSet + case "Array": return Array.isArray(lhs) + case "Object": return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function") + } + } + if (rhs instanceof PromiseNamespace) return lhs instanceof SandboxPromise + // Number/String/Boolean wrap primitives in JS; no boxed values exist in CodeMode, so + // `x instanceof Number` is always false — exactly what it is for primitives in JS. + if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) { + return false + } + throw new InterpreterRuntimeError( + "The right-hand side of 'instanceof' must be a constructor CodeMode knows: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, Array, Object, or Promise.", + node, + ) +} + +// A regex engine failure message without the engine's own "Invalid regular expression:" +// prefix, so composed diagnostics read as one sentence instead of stuttering the phrase. +const regexFailureReason = (error: unknown): string => + (error instanceof Error ? error.message : String(error)).replace(/^Invalid regular expression:\s*/i, "") + +const escapeRegexHint = + 'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.' + // A string method's pattern argument as a host regex: a sandbox regex passes its own host // instance through (so `g` lastIndex semantics follow the spec across calls); a string becomes // a pattern, exactly as String.prototype.match/matchAll/search do (`extraFlags` adds matchAll's -// implicit `g`). Invalid patterns fail as catchable program errors. +// implicit `g`). Invalid patterns fail as catchable program errors that say what was wrong +// with the pattern and how to fix it. const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => { if (arg instanceof SandboxRegExp) return arg.regex if (typeof arg === "string") { try { return new RegExp(arg, extraFlags) } catch (error) { - throw new InterpreterRuntimeError(`Invalid regular expression in String.${method}: ${error instanceof Error ? error.message : String(error)}`, node) + throw new InterpreterRuntimeError( + `String.${method} received the string ${JSON.stringify(arg)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`, + node, + ).as("SyntaxError") } } - throw new InterpreterRuntimeError(`String.${method} expects a regular expression or string pattern.`, node) + throw new InterpreterRuntimeError( + `String.${method} expects a regular expression (a /pattern/flags literal or new RegExp(...)) or a string pattern, not ${arg === null ? "null" : typeof arg}.`, + node, + ) } // A host match result as a sandbox value: a plain array of the full match and captures, with @@ -652,8 +753,24 @@ const invokeStringMethod = (value: string, name: string, args: Array, n case "toLowerCase": result = value.toLowerCase(); break case "toUpperCase": result = value.toUpperCase(); break case "trim": result = value.trim(); break - case "trimStart": result = value.trimStart(); break - case "trimEnd": result = value.trimEnd(); break + // trimLeft/trimRight are the legacy aliases of trimStart/trimEnd, kept because models write them. + case "trimStart": case "trimLeft": result = value.trimStart(); break + case "trimEnd": case "trimRight": result = value.trimEnd(); break + // Locale/options arguments are ignored: comparison runs with the host default locale, and + // the common use is a sort comparator where any consistent order works. + case "localeCompare": result = value.localeCompare(str(0)); break + case "normalize": { + const form = optStr(0) + try { + result = value.normalize(form) + } catch { + throw new InterpreterRuntimeError( + `String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`, + node, + ).as("RangeError") + } + break + } case "split": { if (args.length === 0) { result = [value] @@ -682,7 +799,10 @@ const invokeStringMethod = (value: string, name: string, args: Array, n const pattern = (args[0] as SandboxRegExp).regex const replacement = str(1) if (name === "replaceAll" && !pattern.global) { - throw new InterpreterRuntimeError("String.replaceAll requires a regular expression with the global (g) flag.", node) + throw new InterpreterRuntimeError( + `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`, + node, + ) } result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement) break @@ -706,7 +826,10 @@ const invokeStringMethod = (value: string, name: string, args: Array, n case "matchAll": { const pattern = toHostRegex(args[0], name, node, "g") if (!pattern.global) { - throw new InterpreterRuntimeError("String.matchAll requires a regular expression with the global (g) flag.", node) + throw new InterpreterRuntimeError( + `String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`, + node, + ) } // Materialized as an array (not an iterator); each entry is a match array with // index/groups own properties. Match count is bounded by the subject length. @@ -821,6 +944,9 @@ const invokeCoercion = (ref: CoercionFunction, args: Array, node: AstNo const invokeObjectMethod = (name: string, args: Array, node: AstNode): unknown => { const requireObject = (): Record => { const value = boundedData(args[0], `Object.${name} input`) + // Sandbox values (Date/RegExp/Map/Set) have no own enumerable properties in JS, so the + // Object.* helpers see them as empty objects — never their interpreter internals. + if (isSandboxValue(value)) return {} if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node) } @@ -836,6 +962,7 @@ const invokeObjectMethod = (name: string, args: Array, node: AstNode): // yield their own enumerable keys. (Tool references never reach here — the interpreter // resolves them against the host tool tree first.) const value = boundedData(args[0], "Object.keys input") + if (isSandboxValue(value)) return [] if (Array.isArray(value)) return Object.keys(value) if (value === null || typeof value !== "object") { throw new InterpreterRuntimeError("Object.keys expects a data object or array.", node) @@ -850,6 +977,8 @@ const invokeObjectMethod = (name: string, args: Array, node: AstNode): for (const source of args) { if (source === null || source === undefined) continue const value = boundedData(source, "Object.assign input") + // A sandbox value source contributes nothing (no own enumerable properties in JS). + if (isSandboxValue(value)) continue if (value === null || typeof value !== "object" || Array.isArray(value)) throw new InterpreterRuntimeError("Object.assign expects data objects.", node) for (const [key, item] of Object.entries(value)) guardedSet(out, key, item) } @@ -921,8 +1050,13 @@ const invokeJsonMethod = (name: string, args: Array, node: AstNode): un let parsed: unknown try { parsed = JSON.parse(text) - } catch { - throw new InterpreterRuntimeError("JSON.parse received invalid JSON.", node) + } catch (error) { + // The engine reason is derived from the program-supplied string (token/position), so + // it is safe to surface — and the position is exactly what a model needs to fix it. + throw new InterpreterRuntimeError( + `JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + node, + ).as("SyntaxError") } return copyIn(parsed, "JSON.parse result") } @@ -1147,6 +1281,11 @@ class Interpreter { globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") }) globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") }) globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") }) + // Error constructors are real values, so `x instanceof Error` works and `Error("msg")` + // (with or without `new`) constructs a branded { name, message } error object. + for (const name of errorConstructors) { + globalScope.set(name, { mutable: false, value: new ErrorConstructorReference(name) }) + } // NaN/Infinity flow as ordinary in-sandbox values (normalized to null only at the data // boundary — see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`. globalScope.set("NaN", { mutable: false, value: NaN }) @@ -1919,11 +2058,7 @@ class Interpreter { if (errorConstructors.has(name)) { return Effect.gen(function*() { const arg = argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined - const message = arg === undefined ? "" : coerceToString(arg) - const errorValue: SafeObject = Object.create(null) as SafeObject - errorValue.name = name - errorValue.message = message - return errorValue + return createErrorValue(name, arg === undefined ? "" : coerceToString(arg)) }) } if (valueConstructors.has(name)) { @@ -1959,13 +2094,25 @@ class Interpreter { const pattern = first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first) const flagsArg = args[1] if (flagsArg !== undefined && typeof flagsArg !== "string") { - throw new InterpreterRuntimeError("RegExp flags must be a string.", node) + throw new InterpreterRuntimeError( + `RegExp flags must be a string of flag characters (e.g. "g", "gi"), not ${flagsArg === null ? "null" : typeof flagsArg}.`, + node, + ) } const flags = flagsArg ?? (first instanceof SandboxRegExp ? first.regex.flags : "") try { return new SandboxRegExp(pattern, flags) } catch (error) { - throw new InterpreterRuntimeError(`Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`, node) + // Say which part was rejected and how to fix it, instead of passing the engine + // message through bare. A flags failure names the flags; a pattern failure gets the + // escaping hint (the usual cause is an unescaped metacharacter in a built-up string). + const reason = regexFailureReason(error) + throw new InterpreterRuntimeError( + /flag/i.test(reason) + ? `new RegExp(...) received invalid flags ${JSON.stringify(flags)} (${reason}). Valid flags are d, g, i, m, s, u, v, and y.` + : `new RegExp(...) received ${JSON.stringify(pattern)}, which is not a valid regular expression pattern (${reason}). ${escapeRegexHint}`, + node, + ).as("SyntaxError") } } @@ -2014,6 +2161,10 @@ class Interpreter { return Effect.gen(function*() { const lhs = (yield* self.evaluateExpression(getNode(node, "left"))) as any const rhs = (yield* self.evaluateExpression(getNode(node, "right"))) as any + // Like `typeof`, `instanceof` observes any value without coercing it (a promise or + // function operand is a legitimate question, not an error), so it is handled before + // the data-only operand check. + if (operator === "instanceof") return instanceofValue(lhs, rhs, node) if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) { throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue") } @@ -2228,6 +2379,10 @@ class Interpreter { } if (callable instanceof CoercionFunction) { return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`) + } + // `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS. + if (callable instanceof ErrorConstructorReference) { + return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0])) } throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee) }) @@ -2255,39 +2410,72 @@ class Interpreter { } private formatConsoleMessage(name: string, args: Array, node: AstNode): string { - if (name === "dir") return args.length === 0 ? "undefined" : this.formatConsoleArgument(args[0], node) + if (name === "dir") return args.length === 0 ? "undefined" : this.formatConsoleArgument(args[0]) if (name === "table") return this.formatConsoleTable(args[0], args[1], node) const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : "" - return `${prefix}${args.map((arg) => this.formatConsoleArgument(arg, node)).join(" ")}` + return `${prefix}${args.map((arg) => this.formatConsoleArgument(arg)).join(" ")}` } - private formatConsoleArgument(value: unknown, node: AstNode): string { + // Console arguments format deeply and totally: values render as a debugger would show them + // rather than as boundary JSON — numbers keep NaN/Infinity (JSON would say null), sandbox + // values keep their friendly forms at ANY depth (ISO date, /regex/flags, Map(n) [...], + // Set(n) [...]), opaque runtime references become "[CodeMode reference]" markers in place, + // and plain objects/arrays render JSON-style. Formatting never fails the program: cycles + // render "[Circular]" and extreme depth degrades to "…". + private formatConsoleArgument(value: unknown): string { if (value === undefined) return "undefined" - // Sandbox values print debugger-friendly forms (their boundary JSON would lose the content). + // A top-level string prints bare; nested strings are JSON-quoted (see formatConsoleValue). + if (typeof value === "string") return value + return this.formatConsoleValue(value, new Set(), 0) + } + + private formatConsoleValue(value: unknown, seen: Set, depth: number): string { + // Nested undefined renders as null, matching what JSON boundary output would show. + if (value === null || value === undefined) return "null" + if (typeof value === "string") return JSON.stringify(value) + // String(value) keeps NaN/Infinity/-Infinity readable; finite numbers match their JSON form. + if (typeof value === "number" || typeof value === "boolean") return String(value) + if (typeof value !== "object") return String(value) if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]" if (value instanceof SandboxDate) return coerceToString(value) if (value instanceof SandboxRegExp) return coerceToString(value) + if (depth > MAX_CONSOLE_DEPTH) return "…" + if (seen.has(value)) return "[Circular]" if (value instanceof SandboxMap) { - return `Map(${value.map.size}) ${this.formatConsoleArgument(Array.from(value.map.entries(), ([key, item]): Array => [key, item]), node)}` + seen.add(value) + try { + const entries = Array.from(value.map.entries(), ([key, item]): Array => [key, item]) + return `Map(${value.map.size}) ${this.formatConsoleValue(entries, seen, depth + 1)}` + } finally { + seen.delete(value) + } } if (value instanceof SandboxSet) { - return `Set(${value.set.size}) ${this.formatConsoleArgument(Array.from(value.set.values()), node)}` + seen.add(value) + try { + return `Set(${value.set.size}) ${this.formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}` + } finally { + seen.delete(value) + } } - if (containsRuntimeReference(value)) return "[CodeMode reference]" - const copied = copyOut(copyIn(value, "console argument"), true) - if (typeof copied === "string") return copied - if (copied === null || typeof copied === "number" || typeof copied === "boolean") return String(copied) + if (isRuntimeReference(value)) return "[CodeMode reference]" + seen.add(value) try { - return JSON.stringify(copied) ?? String(copied) - } catch { - throw new InterpreterRuntimeError("console argument must contain data only.", node, "InvalidDataValue") + if (Array.isArray(value)) { + return `[${value.map((item) => this.formatConsoleValue(item, seen, depth + 1)).join(",")}]` + } + return `{${Object.entries(value).map(([key, item]) => `${JSON.stringify(key)}:${this.formatConsoleValue(item, seen, depth + 1)}`).join(",")}}` + } finally { + seen.delete(value) } } private formatConsoleTable(value: unknown, columnsArgument: unknown, node: AstNode): string { if (value === undefined) return "undefined" - if (containsRuntimeReference(value)) return "[CodeMode reference]" - const data = copyOut(copyIn(value, "console.table argument"), true) + // Sandbox values are legitimate table data (cells render their friendly forms); only + // truly opaque references (functions, tools, promises) collapse to the marker. + if (containsOpaqueReference(value)) return "[CodeMode reference]" + const data = boundedData(value, "console.table argument") const columns = this.consoleTableColumns(columnsArgument, node) const rows = this.consoleTableRows(data, columns) const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values)))) @@ -2306,14 +2494,14 @@ class Interpreter { if (Array.isArray(data)) { return data.map((item, index) => ({ index: String(index), values: this.consoleTableValues(item, columns) })) } - if (data !== null && typeof data === "object") { + if (data !== null && typeof data === "object" && !isSandboxValue(data)) { return Object.entries(data).map(([index, item]) => ({ index, values: this.consoleTableValues(item, columns) })) } return [{ index: "0", values: { Value: data } }] } private consoleTableValues(value: unknown, columns: ReadonlyArray | undefined): Record { - if (value !== null && typeof value === "object" && !Array.isArray(value)) { + if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) { const source = value as Record if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]])) return Object.fromEntries(Object.entries(source)) @@ -2324,8 +2512,7 @@ class Interpreter { private formatConsoleTableCell(value: unknown): string { if (value === undefined) return "" if (typeof value === "string") return value - if (value === null || typeof value === "number" || typeof value === "boolean") return String(value) - return JSON.stringify(value) ?? String(value) + return this.formatConsoleValue(value, new Set(), 0) } private evaluateCallArguments(argNodes: Array): Effect.Effect, unknown, R> { @@ -2621,6 +2808,31 @@ class Interpreter { return Effect.succeed(target.pop()) case "shift": return Effect.succeed(target.shift()) + case "splice": { + // Mutates in place and returns the removed elements, exactly like JS: one argument + // removes to the end, an undefined delete count removes nothing. + if (args.length === 0) return Effect.succeed(target.splice(0, 0)) + const start = optNumber(args[0], "start") ?? 0 + if (args.length === 1) return Effect.succeed(target.splice(start)) + const deleteCount = optNumber(args[1], "delete count") ?? 0 + const inserted = args.slice(2) + for (const item of inserted) this.rejectCircularInsertion(target, item, "Array.splice result", node) + return Effect.succeed(target.splice(start, deleteCount, ...inserted)) + } + case "fill": { + this.rejectCircularInsertion(target, args[0], "Array.fill result", node) + return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end"))) + } + case "copyWithin": + return Effect.succeed(target.copyWithin(optNumber(args[0], "target index") ?? 0, optNumber(args[1], "start") ?? 0, optNumber(args[2], "end"))) + // keys/values/entries return arrays (not iterators), matching the Map/Set convention; + // they work with for...of and spread either way. + case "keys": + return Effect.succeed(Array.from(target.keys())) + case "values": + return Effect.succeed([...target]) + case "entries": + return Effect.succeed(Array.from(target.entries(), ([index, item]): Array => [index, item])) } const callback = args[0] @@ -2872,10 +3084,9 @@ class Interpreter { if (index < expressions.length) { const raw = yield* self.evaluateExpression(asNode(expressions[index], "expressions")) - // Sandbox values stringify directly (ISO date, /regex/ form) — the data checkpoint - // would JSON-serialize them first (a regex would render "[object Object]" via {}). - const value = isSandboxValue(raw) ? raw : boundedData(raw, "Template interpolation") - output += coerceToString(value) + // The preserving checkpoint keeps sandbox values intact, so coerceToString renders + // them directly (ISO date, /regex/ literal form) instead of a JSON-serialized husk. + output += coerceToString(boundedData(raw, "Template interpolation")) } } @@ -3060,17 +3271,8 @@ class Interpreter { if (typeof key === "string" && Object.hasOwn(objectValue, key)) { return new ComputedValue((objectValue as Record & Array)[key]) } - if (typeof key === "string" && retryableArrayMethods.has(key)) { - throw new InterpreterRuntimeError( - `Array.${key}(...) is not supported in CodeMode. Rewrite using map/filter/find/some/every/includes/join or a for...of loop.`, - propertyNode, - "UnsupportedSyntax", - [supportedSyntaxMessage], - ) - } // Unknown property on an array reads as `undefined`, matching JS (`[1,2].foo === undefined`), - // instead of throwing — so defensive access under optional chaining behaves as expected. The - // retryable-method hint above still fires for real methods we don't support (e.g. splice). + // instead of throwing — so defensive access under optional chaining behaves as expected. return new ComputedValue(undefined) } return { target: objectValue, key } @@ -3193,12 +3395,12 @@ class Interpreter { const binding = this.resolveBinding(name) if (!binding) { - throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node) + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") } // A parameter default that forward-references a later (not-yet-bound) parameter — JS TDZ. if (binding.initialized === false) { - throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node) + throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError") } return binding.value @@ -3208,11 +3410,11 @@ class Interpreter { const binding = this.resolveBinding(name) if (!binding) { - throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node) + throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") } if (!binding.mutable) { - throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node) + throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError") } binding.value = value @@ -3324,7 +3526,7 @@ const executeWithLimits = >( toolCalls: tools.calls, } satisfies ExecuteResult), ), - Effect.map((result) => boundOutput(result, limits.maxOutputBytes)), + Effect.map((result) => limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes)), ) } @@ -3343,7 +3545,8 @@ const utf8Truncate = (value: string, maxBytes: number): string => { * Bounds the model-facing output (serialized result value plus logs) to `maxOutputBytes`. * Oversized values are replaced by their truncated serialized text with an explanatory marker, * and logs are kept from the start until the remaining budget is exhausted. Truncation never - * fails the execution; `truncated: true` marks affected results. + * fails the execution; `truncated: true` marks affected results. Only runs when the host set + * `maxOutputBytes` — with the limit absent, output passes through unbounded. */ const boundOutput = (result: ExecuteResult, maxOutputBytes: number): ExecuteResult => { let truncated = false diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index ad76ed8b768f..b13577554b13 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -107,7 +107,25 @@ const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"]) export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name) -export const copyIn = (value: unknown, label: string, depth = 0, seen = new Set()): unknown => { +/** + * Validates and copies a value against the plain-data contract (depth, circularity, plain + * objects only, blocked properties, data-only leaves). + * + * Two modes share the walk: + * - **Boundary** (`preserveSandboxValues` false, the default): the host↔sandbox boundary — + * final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize + * exactly as JSON.stringify would: Date → ISO string (invalid → null), RegExp/Map/Set → {}. + * - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in + * codemode.ts): Date/RegExp/Map/Set instances pass through untouched (treated as leaves, + * contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and + * other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...). + * + * Both modes reject un-awaited promises with an await-hinting diagnostic. + */ +export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown => + copyBounded(value, label, 0, new Set(), preserveSandboxValues) + +const copyBounded = (value: unknown, label: string, depth: number, seen: Set, preserveSandboxValues: boolean): unknown => { if (depth > MAX_VALUE_DEPTH) { throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`) } @@ -129,7 +147,7 @@ export const copyIn = (value: unknown, label: string, depth = 0, seen = new Set< throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`) } - // An un-awaited promise never crosses a data boundary as `{}`; the diagnostic tells the + // An un-awaited promise never crosses a data checkpoint as `{}`; the diagnostic tells the // model exactly how to fix the program instead. if (value instanceof SandboxPromise) { throw new ToolRuntimeError( @@ -138,8 +156,33 @@ export const copyIn = (value: unknown, label: string, depth = 0, seen = new Set< ) } + if (preserveSandboxValues) { + // Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents + // are never walked here (Map/Set members are validated where mutation happens, and the + // real boundary still serializes them below). + if (value instanceof SandboxDate || value instanceof SandboxRegExp || value instanceof SandboxMap || value instanceof SandboxSet) { + return value + } + // Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross + // the boundary first), but wrap them defensively rather than degrading to JSON forms. + if (value instanceof Date) return new SandboxDate(value.getTime()) + if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags) + if (value instanceof Map) { + const wrapped = new SandboxMap() + for (const [key, item] of value.entries()) { + wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true)) + } + return wrapped + } + if (value instanceof Set) { + const wrapped = new SandboxSet() + for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true)) + return wrapped + } + } + // Sandbox value types (and their host counterparts, which a host tool may legitimately - // return) serialize exactly as JSON.stringify would at every data checkpoint: a Date is its + // return) serialize exactly as JSON.stringify would at the data boundary: a Date is its // toJSON() ISO string (invalid -> null), and RegExp/Map/Set have no JSON form beyond {}. if (value instanceof SandboxDate) { return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null @@ -161,7 +204,7 @@ export const copyIn = (value: unknown, label: string, depth = 0, seen = new Set< seen.add(value) if (Array.isArray(value)) { - const copied = value.map((item) => copyIn(item, label, depth + 1, seen)) + const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues)) seen.delete(value) return copied } @@ -176,7 +219,7 @@ export const copyIn = (value: unknown, label: string, depth = 0, seen = new Set< if (isBlockedMember(key)) { throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`) } - copied[key] = copyIn(item, label, depth + 1, seen) + copied[key] = copyBounded(item, label, depth + 1, seen, preserveSandboxValues) } seen.delete(value) return copied @@ -427,7 +470,7 @@ export const discoveryPlan = ( "", "Standard modern JavaScript works: functions/closures, destructuring, template literals, loops, try/catch, spread, optional chaining, the usual Array/String/Object/Math/JSON methods, plus Date, RegExp, Map, Set, and Promise.all/allSettled/race/resolve/reject.", "TypeScript type annotations are allowed and stripped before execution (decorators are not supported).", - "Not supported (each fails with a message naming the alternative): classes, generators, for await...of, .then/.catch/.finally (use await with try/catch), `x instanceof Error` (caught errors are plain `{ name, message }` objects), splice.", + "Not supported (each fails with a message naming the alternative): classes, generators, for await...of, .then/.catch/.finally (use await with try/catch).", "Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.", ] diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index da5a40146bf2..6ded82f9bb74 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -91,6 +91,33 @@ describe("CodeMode host failure boundary", () => { expect(JSON.stringify(result)).not.toMatch(/host-output-secret/) }) + test("caught tool failures are Error values in-program", async () => { + const result = await Effect.runPromise( + CodeMode.make({ + tools: { + host: { + call: Tool.make({ + description: "Refuse", + input: Schema.Struct({}), + output: Schema.String, + run: () => Effect.fail(toolError("Refused")), + }), + }, + }, + }).execute(` + try { + await tools.host.call({}) + return "no" + } catch (e) { + return { isError: e instanceof Error, message: e.message } + } + `), + ) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toStrictEqual({ isError: true, message: "Refused" }) + }) + test("propagates host interruption instead of returning a diagnostic", async () => { const exit = await Effect.runPromiseExit( CodeMode.make({ @@ -212,6 +239,66 @@ describe("CodeMode console capture", () => { expect(result.ok ? undefined : result.error.message).toBe("Uncaught: boom") }) + test("prints NaN and Infinity literally instead of the JSON null", async () => { + const result = await Effect.runPromise(CodeMode.execute({ + code: ` + console.log(NaN) + console.log(Infinity, -Infinity) + console.log({ ratio: NaN, bounds: [Infinity] }) + return null + `, + })) + + expect(result.ok).toBe(true) + expect(result.logs).toStrictEqual(["NaN", "Infinity -Infinity", '{"ratio":NaN,"bounds":[Infinity]}']) + }) + + test("renders sandbox values nested inside logged containers", async () => { + const result = await Effect.runPromise(CodeMode.execute({ + code: ` + console.log({ m: new Map([["a", 1]]), when: new Date(0), r: /ab/g, s: new Set([1, 2]) }) + console.log([new Date(0)]) + return null + `, + })) + + expect(result.ok).toBe(true) + expect(result.logs).toStrictEqual([ + '{"m":Map(1) [["a",1]],"when":1970-01-01T00:00:00.000Z,"r":/ab/g,"s":Set(2) [1,2]}', + "[1970-01-01T00:00:00.000Z]", + ]) + }) + + test("console formatting is total: cycles and opaque references render as markers", async () => { + const result = await Effect.runPromise(CodeMode.execute({ + code: ` + const m = new Map() + m.set("self", m) + console.log({ box: m }) + console.log({ fn: (x) => x, ok: 1 }) + return null + `, + })) + + expect(result.ok).toBe(true) + expect(result.logs).toStrictEqual([ + '{"box":Map(1) [["self",[Circular]]]}', + '{"fn":[CodeMode reference],"ok":1}', + ]) + }) + + test("console.table renders sandbox value cells", async () => { + const result = await Effect.runPromise(CodeMode.execute({ + code: ` + console.table([{ when: new Date(0), n: NaN }]) + return null + `, + })) + + expect(result.ok).toBe(true) + expect(result.logs).toStrictEqual(["(index)\twhen\tn\n0\t1970-01-01T00:00:00.000Z\tNaN"]) + }) + test("captures console.dir and console.table output", async () => { const result = await Effect.runPromise(CodeMode.execute({ code: ` @@ -237,6 +324,18 @@ describe("CodeMode console capture", () => { }) describe("CodeMode output budget", () => { + test("absent maxOutputBytes means no truncation at all", async () => { + const result = await Effect.runPromise(CodeMode.execute({ + code: `console.log("z".repeat(50_000)); return "x".repeat(100_000)`, + })) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.truncated).toBeUndefined() + expect(result.value).toBe("x".repeat(100_000)) + expect(result.logs).toStrictEqual(["z".repeat(50_000)]) + }) + test("truncates an oversized result value with a marker instead of failing", async () => { const limits: ExecutionLimits = { maxOutputBytes: 40 } const result = await Effect.runPromise(CodeMode.execute({ @@ -456,9 +555,12 @@ describe("CodeMode public contract", () => { expect(instructions).toContain("TypeScript type annotations are allowed and stripped before execution") // The not-supported list is derived from (and verified against) the interpreter. expect(instructions).toContain("Not supported") - for (const missing of ["classes", "generators", "for await...of", ".then/.catch/.finally", "`x instanceof Error`", "splice"]) { + for (const missing of ["classes", "generators", "for await...of", ".then/.catch/.finally"]) { expect(instructions).toContain(missing) } + // Implemented by the DSL-expansion pass, so no longer listed as missing. + expect(instructions).not.toContain("instanceof Error") + expect(instructions).not.toContain("splice") // The data-boundary note survives. expect(instructions).toContain("Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.") }) diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 25bdd0630e96..7ee1579e2794 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -59,10 +59,8 @@ describe("H3: array property access reads as undefined (not a throw)", () => { expect(await value(`return [1,2,3]?.foo ?? "fb"`)).toBe("fb") }) - test("real-but-unsupported array methods still give the rewrite hint", async () => { - const err = await error(`return [1,2,3].splice(0,1)`) - expect(err.kind).toBe("UnsupportedSyntax") - expect(err.message).toContain("splice") + test("unknown property reads stay undefined for methods CodeMode does not implement", async () => { + expect(await value(`return [1,2,3].toSpliced === undefined`)).toBe(true) }) test("supported array methods and indexing still work", async () => { @@ -143,6 +141,154 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo }) }) +describe("Error values and instanceof", () => { + test("new Error carries name/message and is instanceof Error", async () => { + expect(await value(`const e = new Error("boom"); return [e instanceof Error, e.name, e.message]`)).toEqual([true, "Error", "boom"]) + }) + + test("Error without new behaves like new Error", async () => { + expect(await value(`const e = Error("plain"); return [e instanceof Error, e.name, e.message]`)).toEqual([true, "Error", "plain"]) + expect(await value(`const e = new Error(); return [e.name, e.message, e instanceof Error]`)).toEqual(["Error", "", true]) + }) + + test("specific error types are instanceof themselves and Error, not each other", async () => { + expect(await value(`const e = new TypeError("t"); return [e instanceof TypeError, e instanceof Error, e instanceof RangeError]`)).toEqual([true, true, false]) + expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false) + }) + + test("thrown errors keep instanceof through try/catch", async () => { + expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([true, "x"]) + }) + + test("interpreter runtime failures are caught as Error values", async () => { + expect(await value(`try { JSON.parse("nope") } catch (e) { return e instanceof Error }`)).toBe(true) + expect(await value(`try { undeclared() } catch (e) { return e instanceof Error }`)).toBe(true) + }) + + test("caught failures carry the constructor name the real-JS failure would have", async () => { + // JSON.parse throws SyntaxError: name and specific-instanceof both carry through, and the + // message keeps the engine's position detail. + expect(await value(` + try { JSON.parse("{oops") } catch (e) { + return [e.name, e instanceof SyntaxError, e instanceof Error, e instanceof TypeError, e.message.includes("JSON")] + } + `)).toEqual(["SyntaxError", true, true, false, true]) + expect(await value(`try { undeclared() } catch (e) { return [e.name, e instanceof ReferenceError] }`)) + .toEqual(["ReferenceError", true]) + expect(await value(`try { const c = 1; c = 2 } catch (e) { return [e.name, e instanceof TypeError] }`)) + .toEqual(["TypeError", true]) + expect(await value(`try { "a".normalize("NOPE") } catch (e) { return [e.name, e instanceof RangeError] }`)) + .toEqual(["RangeError", true]) + expect(await value(`try { "a".match("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)) + .toEqual(["SyntaxError", true]) + expect(await value(`try { new RegExp("(") } catch (e) { return [e.name, e instanceof SyntaxError] }`)) + .toEqual(["SyntaxError", true]) + }) + + test("diagnostics without a specific real-JS analogue are named plain Error", async () => { + expect(await value(`try { JSON.parse(5) } catch (e) { return [e.name, e instanceof Error] }`)) + .toEqual(["Error", true]) + }) + + test("Promise.allSettled rejection reasons are Error values", async () => { + expect(await value(` + const settled = await Promise.allSettled([Promise.reject(new Error("b"))]) + return [settled[0].reason instanceof Error, settled[0].reason.message] + `)).toEqual([true, "b"]) + }) + + test("non-error thrown values are not instanceof Error", async () => { + expect(await value(`try { throw "raw" } catch (e) { return e instanceof Error }`)).toBe(false) + expect(await value(`try { throw { message: "shaped" } } catch (e) { return e instanceof Error }`)).toBe(false) + }) + + test("plain data is never instanceof Error", async () => { + expect(await value(`return [({}) instanceof Error, "s" instanceof Error, null instanceof Error]`)).toEqual([false, false, false]) + }) + + test("error values still serialize as plain { name, message } data", async () => { + expect(await value(`return new Error("m")`)).toEqual({ name: "Error", message: "m" }) + expect(await value(`return JSON.stringify(new Error("m"))`)).toBe('{"name":"Error","message":"m"}') + expect(await value(`try { throw new Error("m") } catch (e) { return Object.keys(e) }`)).toEqual(["name", "message"]) + }) + + test("spreading an error loses the brand, like losing the prototype in JS", async () => { + expect(await value(`const e = new Error("m"); return ({ ...e }) instanceof Error`)).toBe(false) + expect(await value(`const e = new Error("m"); return { ...e }`)).toEqual({ name: "Error", message: "m" }) + }) + + test("typeof Error is function; an unknown instanceof right-hand side is a catchable error", async () => { + expect(await value(`return typeof Error`)).toBe("function") + expect(await value(`try { return 1 instanceof 5 } catch (e) { return "caught" }`)).toBe("caught") + const err = await error(`return 1 instanceof 5`) + expect(err.message).toContain("right-hand side of 'instanceof'") + }) +}) + +describe("array methods: splice, fill, copyWithin, keys/values/entries", () => { + test("splice removes in place and returns the removed elements", async () => { + expect(await value(`const a = [1,2,3,4]; const removed = a.splice(1, 2); return { removed, a }`)).toEqual({ removed: [2, 3], a: [1, 4] }) + }) + + test("splice inserts new elements at the cut", async () => { + expect(await value(`const a = ["a","d"]; a.splice(1, 0, "b", "c"); return a`)).toEqual(["a", "b", "c", "d"]) + expect(await value(`const a = [1,2,3]; const removed = a.splice(1, 1, "x"); return { removed, a }`)).toEqual({ removed: [2], a: [1, "x", 3] }) + }) + + test("splice with one argument removes to the end; negative start counts back", async () => { + expect(await value(`const a = [1,2,3]; const removed = a.splice(1); return { removed, a }`)).toEqual({ removed: [2, 3], a: [1] }) + expect(await value(`const a = [1,2,3]; const removed = a.splice(-1); return { removed, a }`)).toEqual({ removed: [3], a: [1, 2] }) + }) + + test("splice rejects inserting a container into itself", async () => { + const err = await error(`const a = [1]; a.splice(0, 0, [a]); return a`) + expect(err.kind).toBe("InvalidDataValue") + expect(err.message).toContain("circular") + }) + + test("fill overwrites a range and returns the mutated array", async () => { + expect(await value(`const a = [1,2,3,4]; return a.fill(0, 1, 3)`)).toEqual([1, 0, 0, 4]) + expect(await value(`return [1,2,3].fill("z")`)).toEqual(["z", "z", "z"]) + }) + + test("copyWithin copies a range in place", async () => { + expect(await value(`return [1,2,3,4,5].copyWithin(0, 3)`)).toEqual([4, 5, 3, 4, 5]) + }) + + test("keys/values/entries return arrays usable with for...of and spread", async () => { + expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2]) + expect(await value(`return ["x","y"].values()`)).toEqual(["x", "y"]) + expect(await value(` + const out = [] + for (const [index, item] of ["a","b"].entries()) out.push(index + ":" + item) + return out + `)).toEqual(["0:a", "1:b"]) + expect(await value(`return [...[7].entries()]`)).toEqual([[0, 7]]) + }) +}) + +describe("string methods: localeCompare, normalize, trim aliases", () => { + test("localeCompare orders strings for sorting", async () => { + expect(await value(`return ["b","a","c"].sort((x, y) => x.localeCompare(y))`)).toEqual(["a", "b", "c"]) + expect(await value(`return "a".localeCompare("a")`)).toBe(0) + }) + + test("normalize applies unicode normalization forms", async () => { + expect(await value(`return "\\u0065\\u0301".normalize("NFC").length`)).toBe(1) + expect(await value(`return "\\u00e9".normalize("NFD").length`)).toBe(2) + expect(await value(`return "x".normalize() === "x"`)).toBe(true) + }) + + test("an invalid normalize form is a clear catchable error", async () => { + expect(await value(`try { "x".normalize("nope"); return "no" } catch (e) { return e.message }`)).toContain('"NFC"') + }) + + test("trimLeft/trimRight alias trimStart/trimEnd", async () => { + expect(await value(`return " x ".trimLeft()`)).toBe("x ") + expect(await value(`return " x ".trimRight()`)).toBe(" x") + }) +}) + describe("H5: builtin coercion functions work as array callbacks", () => { test("filter(Boolean) drops falsy values", async () => { expect(await value(`return [0, 1, "", 2, null, 3].filter(Boolean)`)).toEqual([1, 2, 3]) diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 3d2886ccc0d9..ed6b3bb8c807 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -273,7 +273,7 @@ describe("Promise.allSettled", () => { ]) `)).toEqual([ { status: "fulfilled", value: 5 }, - { status: "rejected", reason: { message: "Lookup refused" } }, + { status: "rejected", reason: { name: "Error", message: "Lookup refused" } }, { status: "fulfilled", value: "plain" }, { status: "rejected", reason: { name: "Error", message: "boom" } }, ]) diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index cdc29d26a6d2..1dedd1a1e9fc 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" -import { CodeMode } from "../src/index.js" +import { CodeMode, Tool } from "../src/index.js" // Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS; -// at every data boundary (final result, tool arguments, JSON.stringify) they serialize exactly -// as JSON.stringify would: Date -> ISO string (invalid -> null), RegExp/Map/Set -> {}. +// intra-sandbox checkpoints (Object.* helpers, spread, coercion inputs) preserve the live +// values, while at the host boundary (final result, tool arguments, JSON.stringify) they +// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null), +// RegExp/Map/Set -> {}. const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} })) const value = async (code: string) => { const result = await run(code) @@ -144,7 +146,32 @@ describe("RegExp", () => { test("new RegExp constructs from strings; invalid patterns are catchable", async () => { expect(await value(`return new RegExp("a+", "i").test("AAA")`)).toBe(true) expect(await value(`try { new RegExp("("); return "no" } catch { return "caught" }`)).toBe("caught") - expect(await value(`try { /a/ instanceof RegExp } catch { }; return /a/.source`)).toBe("a") + expect(await value(`return [/a/ instanceof RegExp, /a/.source]`)).toEqual([true, "a"]) + }) + + test("invalid patterns fail with actionable messages", async () => { + const fromString = await error(`return "abc".match("(")`) + expect(fromString.message).toContain('String.match received the string "("') + expect(fromString.message).toContain("escape them with a backslash") + + const fromConstructor = await error(`return new RegExp("(")`) + expect(fromConstructor.message).toContain('new RegExp(...) received "("') + expect(fromConstructor.message).toContain("escape them with a backslash") + + const fromFlags = await error(`return new RegExp("a", "xz")`) + expect(fromFlags.message).toContain('invalid flags "xz"') + expect(fromFlags.message).toContain("Valid flags are") + }) + + test("missing g-flag errors say how to fix the call", async () => { + expect((await error(`return "aa".replaceAll(/a/, "b")`)).message).toContain("write /a/g, or use String.replace") + expect((await error(`return "aa".matchAll(/a/)`)).message).toContain("write /a/g, or use String.match") + }) + + test("a non-pattern argument names the expected shapes", async () => { + const err = await error(`return "abc".match(42)`) + expect(err.message).toContain("expects a regular expression") + expect(err.message).toContain("not number") }) test("source and flags properties read through", async () => { @@ -317,6 +344,13 @@ describe("stdlib integration", () => { `)).toBe(1000) }) + test("instanceof recognizes the stdlib value types", async () => { + expect(await value(`return [new Date(0) instanceof Date, /a/ instanceof RegExp, new Map() instanceof Map, new Set() instanceof Set]`)).toEqual([true, true, true, true]) + expect(await value(`return [[1] instanceof Array, [1] instanceof Object, ({}) instanceof Object, 5 instanceof Object]`)).toEqual([true, true, true, false]) + expect(await value(`return [new Map() instanceof Set, "s" instanceof Date]`)).toEqual([false, false]) + expect(await value(`const p = Promise.resolve(1); const isPromise = p instanceof Promise; await p; return isPromise`)).toBe(true) + }) + test("realistic pipeline: parse, extract with regex, dedupe, count by day", async () => { expect(await value(` const raw = '[{"at":"2024-01-01T05:00:00Z","tag":"a b"},{"at":"2024-01-01T09:00:00Z","tag":"b c"},{"at":"2024-01-02T01:00:00Z","tag":"a"}]' @@ -332,3 +366,62 @@ describe("stdlib integration", () => { `)).toEqual({ tags: ["a", "b", "c"], byDay: { "2024-01-01": 2, "2024-01-02": 1 } }) }) }) + +describe("sandbox values at intra-sandbox checkpoints", () => { + test("Object.values/entries keep Dates usable", async () => { + expect(await value(`return Object.values({ d: new Date(0) })[0].getTime()`)).toBe(0) + expect(await value(`const [key, d] = Object.entries({ d: new Date(0) })[0]; return key + ":" + d.getTime()`)).toBe("d:0") + }) + + test("Object.assign keeps Maps usable", async () => { + expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe(1) + }) + + test("object and array spread keep sandbox values usable", async () => { + expect(await value(` + const src = { m: new Map([["a", 1]]) } + const copy = { ...src } + copy.m.set("b", 2) + return [copy.m.get("a"), src.m.get("b")] + `)).toEqual([1, 2]) + expect(await value(`const list = [new Date(1000)]; const copy = [...list]; return copy[0].getTime()`)).toBe(1000) + }) + + test("Array.from over arrays keeps nested sandbox values usable", async () => { + expect(await value(`return Array.from([new Date(5)])[0].getTime()`)).toBe(5) + }) + + test("regexes stay callable through Object.values", async () => { + expect(await value(`return Object.values({ r: /ab+/ })[0].test("abb")`)).toBe(true) + }) + + test("Object.* helpers see sandbox values as empty objects, never internals", async () => { + expect(await value(`return Object.keys(new Map([["a", 1]]))`)).toEqual([]) + expect(await value(`return Object.values(new Date(0))`)).toEqual([]) + expect(await value(`return Object.entries(new Set([1]))`)).toEqual([]) + expect(await value(`return Object.assign({}, new Map([["a", 1]]))`)).toEqual({}) + expect(await value(`return Object.hasOwn(new Date(0), "time")`)).toBe(false) + }) + + test("the host boundary still serializes JSON forms: results, JSON.stringify, and tool arguments", async () => { + expect(await value(`return { d: new Date(0), m: new Map([["a", 1]]) }`)).toEqual({ d: "1970-01-01T00:00:00.000Z", m: {} }) + expect(await value(`return JSON.stringify({ d: new Date(0) })`)).toBe('{"d":"1970-01-01T00:00:00.000Z"}') + + const observed: Array = [] + const capture = Tool.make({ + description: "Capture the exact input the host receives", + input: { type: "object" }, + run: (input) => + Effect.sync(() => { + observed.push(input) + return "ok" + }), + }) + const result = await Effect.runPromise(CodeMode.execute({ + tools: { host: { capture } }, + code: `return await tools.host.capture({ when: new Date(0), tags: new Map([["a", 1]]) })`, + })) + expect(result.ok).toBe(true) + expect(observed).toStrictEqual([{ when: "1970-01-01T00:00:00.000Z", tags: {} }]) + }) +}) From 27fbf7c6ba3282d36c1e6a08e25525e6b9c20b3b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 19:07:42 -0500 Subject: [PATCH 31/41] feat(opencode): rely on native tool-output truncation for code mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeMode no longer truncates execute output — with no limits passed, maxOutputBytes is unset and results reach the shared Tool.define wrapper intact, where OpenCode's native truncation applies to execute like any other tool (and is the richer layer: full output saved to a file with a follow-up hint, instead of an inline marker). --- packages/opencode/src/session/code-mode.ts | 10 ++++++---- packages/opencode/test/session/code-mode.test.ts | 8 +++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 2edf72c157d5..bb015bce3166 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -12,10 +12,12 @@ import { export const CODE_MODE_TOOL = "execute" -// OpenCode sets NO execution limits: no timeout and no tool-call cap. Cancelling the tool -// call interrupts the execution fiber, and structured concurrency takes the program and its -// in-flight child calls down with it; every child call is permission-gated anyway. The only -// active bound is CodeMode's default 32KB output truncation. +// OpenCode sets NO execution limits: no timeout, no tool-call cap, and no CodeMode output +// truncation. Cancelling the tool call interrupts the execution fiber, and structured +// concurrency takes the program and its in-flight child calls down with it; every child call +// is permission-gated anyway. Output bounding is OpenCode's native tool-output truncation +// (Tool.define's shared wrapper), which applies to `execute` like any other tool and dumps +// the full output to a file when it triggers. export const Parameters = Schema.Struct({ code: Schema.String.annotate({ diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index cce66c28ff7a..e64201f1c8c1 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -423,12 +423,14 @@ describe("code mode execute", () => { expect(output.metadata.error).toBe(true) }) - test("truncates an oversized result via the CodeMode output limit", async () => { + test("leaves oversized results to OpenCode's native tool-output truncation", async () => { + // No CodeMode output limit is set, so the full result reaches the shared Tool.define + // wrapper intact (the harness Truncate fake passes it through un-truncated). const tool = await build({}) const output = await Effect.runPromise(tool.execute({ code: "return 'x'.repeat(40000)" }, ctx)) expect(output.metadata.error).toBeUndefined() - expect(output.output).toContain("[result truncated:") - expect(output.output.length).toBeLessThan(40_000) + expect(output.output).not.toContain("[result truncated:") + expect(output.output.length).toBeGreaterThanOrEqual(40_000) }) test("appends logs after the result on success and after the message on error", async () => { From 231144f1df8a8cd948a2ec52caae108ed7687000 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 19:38:53 -0500 Subject: [PATCH 32/41] docs(codemode): fix stale claims, log wiring-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DiscoveryOptions JSDoc claimed the pre-trim 4,000 default and the old alphabetical cheapest-first inlining; now 2,000 and round-robin. - README claimed effect is a peer dependency; it is a regular dependency — hosts depend on effect themselves because the API surface is Effect-typed. - codemode.md brought current: committed/pushed status, six-commit history, triaged findings from the integration review (pre-PR cut vs post-MVP), and the cancellation fix record. --- packages/codemode/README.md | 2 +- packages/codemode/codemode.md | 97 ++++++++++++++++++++++++++----- packages/codemode/src/codemode.ts | 9 ++- 3 files changed, 87 insertions(+), 21 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index e92ae00aad24..489a9c12f3bd 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -30,7 +30,7 @@ Within this workspace: } ``` -CodeMode requires `effect` as a peer dependency. +Hosts interact with CodeMode through `effect` (tool `run` implementations, `Effect`-typed results), so they should depend on `effect` themselves. ## Quick Start diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index d00890601891..3f43b7c5afed 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -135,14 +135,13 @@ From issue #34787 and design discussion. Do not relitigate these casually. ## 3. Current status (what is already done on `codemode-v2`) -Waves 0–5 and the post-wave fixes below are committed on `codemode-v2` (four commits: the -generic package, the OpenCode integration, then one follow-up pair for Fixes 4–9); the -DSL-expansion pass is uncommitted working-tree changes. Verification: from -`packages/codemode`, `bun test` (207 pass / 0 fail across -`codemode/parity/stdlib/promise/enumeration/signature`) and `bun run typecheck`; from -`packages/opencode`, `bun run typecheck` and `bun test test/session/` (all green — the -adapter suites are `code-mode.test.ts`, 34 tests, and `code-mode-integration.test.ts`, -16 tests). +Everything below is committed and pushed on `codemode-v2` (six commits, in pairs of +generic-package + OpenCode-integration: waves 0–5, Fixes 4–9, then the DSL-expansion pass / +real-JS error names / truncation layering). Verification: from `packages/codemode`, +`bun test` (210 pass / 0 fail across `codemode/parity/stdlib/promise/enumeration/signature`) +and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and +`bun test test/session/` (all green — the adapter suites are `code-mode.test.ts`, 34 tests, +and `code-mode-integration.test.ts`, 16 tests). ### Wave 0 — scaffold (done) - `packages/codemode` created from the experiments implementation: `src/{index,codemode,tool, @@ -588,9 +587,9 @@ configurable knobs; the internal limit system dies): **Fix 6 — no default timeout / tool-call cap** (user direction): `timeoutMs` and `maxToolCalls` lost their defaults (were 10_000 / 100) — absent now means no timeout / -unlimited calls. Budgets are host policy, not library policy; `maxOutputBytes` keeps its -32,000 default because truncation never breaks correctness and its absence would silently -flood model context. `ResolvedExecutionLimits` carries `number | undefined` for both, the +unlimited calls. Budgets are host policy, not library policy; `maxOutputBytes` kept its +32,000 default at the time (removed later — see the truncation-layering entry: absent now +means no truncation). `ResolvedExecutionLimits` carries `number | undefined` for both, the timeout wrapper is only applied when configured, and `ToolRuntime.make` treats undefined `maxToolCalls` as uncapped. Validation is unchanged when values ARE provided (safe integers, timeoutMs >= 1, others >= 0). The OpenCode adapter is unaffected in behavior it sets @@ -825,6 +824,11 @@ parity items, done as one focused pass; no public API or limit changes): that relied on the old default now asserts the oversized result reaches the shared wrapper un-truncated. Suites: 210 + 50, tsgo clean both. +**Docs polish** (post-API-review): stale `DiscoveryOptions` JSDoc fixed (claimed default +4,000 and alphabetical cheapest-first — now 2,000 and round-robin, matching Fix 8/9 reality) +and the README's incorrect "`effect` as a peer dependency" line corrected (`effect` is a +regular dependency; hosts depend on it themselves because the API surface is Effect-typed). + --- ## 4. Remaining work (detailed TODO) @@ -860,6 +864,65 @@ focused interpreter-surface pass rather than picked off piecemeal. signatures). Result quality is dominated by whether servers declare output schemas; revisit once real usage shows which failure modes matter. +### Wiring-review findings (subagent code review of the OpenCode integration, triaged) +Pre-PR fixes (user-approved cut): +- [x] **Cancellation does not interrupt the interpreter** — the no-limits rationale claimed + "user cancel interrupts the execution fiber," but `tools.ts` runs tools via + `run.promise` → `Effect.runPromise` (`effect/bridge.ts:64-66`) with NO abort wiring; + on cancel the ai-sdk abandons the promise, child MCP calls abort (they hold + `ctx.abort`) but the interpreter fiber spun on — `while(true){}` or a try/catch + loop was uncancellable with no timeout backstop. Verified by hand, not just the + reviewer. FIXED in the adapter: `Effect.raceFirst(runtime.execute(code), cancelled)` + where `cancelled` is an `Effect.callback` abort-signal watcher (listener removed on + interruption) resuming with an `ok: false` "Execution cancelled." result — the abort + winning the race interrupts the execution fiber (interpreter auto-yield makes busy + loops preemptible, same mechanism as timeoutMs) and returning a value keeps the + runner's post-abort `completeToolCall` bookkeeping on its normal path. A pre-aborted + signal short-circuits at entry before the program starts (racing alone still lets + the loser run its first steps). Tests: +2 adapter (child call triggers abort + deterministically then the program enters `while(true){}` — would hang if + interruption broke; pre-aborted signal runs nothing). Adapter suite 34 → 36. + (Wiring abort→interrupt into the shared `tools.ts` runner for ALL tools remains a + worthwhile separate change.) +- [ ] **Permission-denied/disabled MCP tools are still advertised in the catalog** — the + non-code-mode path filters them from the model's view (`llm/request.ts:208-213`); + code mode builds the catalog from all of `mcp.tools()`, so the model is invited to + call tools that can only fail at permission time, and per-message `tools[key]=false` + disabling has no child-call equivalent. Fix: filter the catalog with the same + ruleset. +- [ ] Style: `code-mode.ts` is the only `src/session` sibling without the + `export * as ... from "./..."` self-reexport footer, forcing a star import at + `tools.ts:26` (AGENTS.md violation). Add footer + import the projection. +- [ ] Trivial: latent `groupByServer` fallback bug — `key.slice(0, key.indexOf("_"))` is + `slice(0, -1)` when no underscore (unreachable today; guard or drop); dead + `CODE_MODE_TOOL` export (integration points hardcode `"execute"` — use it or inline + it). + +Post-MVP (logged, not blocking an experimental flag): +- [ ] **Plugin `tool.execute.before/after` hooks skip child calls** — legacy MCP + registration fires them per tool (`tools.ts:419-441`); under code mode only the + outer `execute` fires them, so auditing/intercepting plugins silently lose MCP + coverage when the flag flips. +- [ ] Description/preview rebuilt every assistant turn — `resolve()` re-runs + `groupByServer` + a throwaway `CodeMode.make(...).instructions()` per turn + (`code-mode.ts:252-259`); memoizable on a defs snapshot key. A second + `CodeMode.make` per execution is inherent (description precedes execution). +- [ ] Child permission rejection round-trips through the defect channel — `ctx.ask` + defect (`tools.ts:90` orDie) recovered via `catchCause` + `Cause.squash` + (`code-mode.ts:238-245`). Works, interrupts preserved, but fragile coupling; + exposing the typed rejection on `Tool.Context.ask` would be cleaner. +- [ ] No collision guard on the `execute` tool id (a plugin/custom tool named `execute` + is silently shadowed; a log line would do). +- [ ] Style nits: triple-nested `yield*` in `tools.ts:101-107` argument position (bind + first, like neighbors); single-use micro-helpers (`toJsonSchema` is a bare cast); + comment density far above session-neighbor norm; adapter tests use raw + `Effect.runPromise` + hand-built layers with `as any` instead of the + `testEffect`/`LayerNode.compile` fixture pattern (`test/tool/grep.test.ts:25-31`) + and star-import `Truncate`. +- [ ] Reviewer observation worth keeping: MCP server instructions (`sys.mcp`, + `session/system.ts:110-126`) still inject prose referencing server-native tool + names that are no longer directly callable under code mode. + ### Backlog / loose ends (non-blocking, any order) - [ ] Media-only marker could name what it attached when MCP provides names: `image`/`audio` blocks carry no filename (mime + data only) so the generic @@ -879,10 +942,14 @@ focused interpreter-surface pass rather than picked off piecemeal. normalizes → `FilePart`s visible to the model). Code-reviewed as sound; confirm with one interactive session (an image-returning MCP tool) when convenient. Same session can eyeball TUI child-call rendering via `metadata.toolCalls`. -- [x] Commit hygiene: Waves 0–5 + post-wave fixes committed on `codemode-v2` as two units - (the generic package; the OpenCode integration). Future work: commit only when - explicitly asked; push with `--no-verify` per repo convention. The scratch - `.opencode/opencode.jsonc` stays uncommitted. +- [x] Commit hygiene: all work committed and pushed on `codemode-v2` as six commits, in + generic-package + OpenCode-integration pairs (waves 0–5; Fixes 4–9; DSL pass + + error names + truncation layering). Future work: commit only when explicitly asked; + push with `--no-verify` per repo convention. The scratch `.opencode/opencode.jsonc` + stays uncommitted. +- [ ] MVP scope decided (user direction): the interactive e2e eyeball is NOT required — + remaining pre-PR work is essentially just opening the PR. Attachment-propagation + verification (below) stays parked as post-MVP. --- diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index b8b891fcd1cc..b5b60cbe03f9 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -39,11 +39,10 @@ export type ExecutionLimits = { /** Controls how much of the tool catalog is inlined in agent instructions. */ export type DiscoveryOptions = { /** - * Estimated-token budget (chars/4, default 4000) for inlined full tool signatures in agent - * instructions. Every namespace is always listed with its tool count regardless of budget; - * as many full signatures as fit this budget are inlined (cheapest-first within a - * namespace, namespaces alphabetical), and the instructions state whether the list is - * COMPLETE or PARTIAL. `tools.$codemode.search` is always registered. + * Estimated-token budget (chars/4, default 2000) for inlined full tool signatures in agent + * instructions. Signatures that fit are inlined round-robin across namespaces; every + * namespace is always listed with its tool count regardless of budget, and + * `tools.$codemode.search` is always registered. */ readonly maxInlineCatalogTokens?: number } From 9a6fdc4aeeafd07601ab749ae8419084f3793190 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 19:38:53 -0500 Subject: [PATCH 33/41] fix(opencode): interrupt code mode execution on cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared tool runner does not wire ctx.abort to fiber interruption (run.promise -> Effect.runPromise), so cancelling execute abandoned the promise while the interpreter fiber kept running — an infinite loop was uncancellable with no timeout backstop, contradicting the documented no-limits rationale. The adapter now races execution against an abort-signal watcher: cancel interrupts the execution fiber (structured concurrency takes in-flight child calls down with it; interpreter auto-yield makes busy loops preemptible) and resolves to an Execution cancelled result so the runner's post-abort bookkeeping stays on its normal path. A pre-aborted signal short-circuits before the program starts, since racing alone still lets the loser run its first steps. --- packages/opencode/src/session/code-mode.ts | 42 ++++++++++++++++--- .../opencode/test/session/code-mode.test.ts | 34 +++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index bb015bce3166..bd7020f6559e 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -6,6 +6,7 @@ import { CodeMode, Tool as CodeModeTool, toolError, + type ExecuteResult, type JsonSchema, type ToolDefinition, } from "@opencode-ai/codemode" @@ -13,11 +14,12 @@ import { export const CODE_MODE_TOOL = "execute" // OpenCode sets NO execution limits: no timeout, no tool-call cap, and no CodeMode output -// truncation. Cancelling the tool call interrupts the execution fiber, and structured -// concurrency takes the program and its in-flight child calls down with it; every child call -// is permission-gated anyway. Output bounding is OpenCode's native tool-output truncation -// (Tool.define's shared wrapper), which applies to `execute` like any other tool and dumps -// the full output to a file when it triggers. +// truncation. Cancelling the tool call aborts `ctx.abort`, which wins the race below and +// interrupts the execution fiber — structured concurrency takes the program and its +// in-flight child calls down with it; every child call is permission-gated anyway. Output +// bounding is OpenCode's native tool-output truncation (Tool.define's shared wrapper), +// which applies to `execute` like any other tool and dumps the full output to a file when +// it triggers. export const Parameters = Schema.Struct({ code: Schema.String.annotate({ @@ -264,6 +266,15 @@ export function define( description, parameters: Parameters, execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { + // Already cancelled: don't start the program at all. (The mid-flight case is the + // race below; racing alone would still let the program run its first steps.) + if (ctx.abort.aborted) { + return { + title: "execute", + metadata: { toolCalls: [], error: true }, + output: "Execution cancelled.", + } satisfies Tool.ExecuteResult + } const calls: CallEntry[] = [] // Media stripped from child tool results accumulates here for the life of the // call; the bytes never enter the sandbox (see toSandboxResult). @@ -310,7 +321,26 @@ export function define( }), }) - const result = yield* runtime.execute(params.code) + // The shared tool runner does not wire ctx.abort to fiber interruption (it runs + // tools via Effect.runPromise with no abort handling), so without this race the + // program would keep running after the user cancels. The abort signal winning the + // race interrupts the execution fiber; the cancelled result keeps the runner's + // post-abort bookkeeping (completeToolCall) on its normal path. + const cancelled = Effect.callback((resume) => { + const onAbort = () => + resume( + Effect.succeed({ + ok: false, + error: { kind: "ExecutionFailure", message: "Execution cancelled." }, + toolCalls: calls.map((call) => ({ name: call.tool })), + }), + ) + if (ctx.abort.aborted) return onAbort() + ctx.abort.addEventListener("abort", onAbort, { once: true }) + return Effect.sync(() => ctx.abort.removeEventListener("abort", onAbort)) + }) + + const result = yield* Effect.raceFirst(runtime.execute(params.code), cancelled) const logs = result.logs ?? [] const attached = attachments.length > 0 ? { attachments } : {} diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index e64201f1c8c1..5e73468022f4 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -423,6 +423,40 @@ describe("code mode execute", () => { expect(output.metadata.error).toBe(true) }) + test("cancelling via ctx.abort interrupts the running program", async () => { + // The child call triggers the abort itself, deterministically, while the program + // swallows the call's failure and heads into an infinite loop. If abort did not + // interrupt the execution fiber, this test would hang on the busy loop. + const controller = new AbortController() + const tool = await build({ + host_trigger: mcpTool("trigger", () => { + controller.abort() + return new Promise(() => {}) + }), + }) + const output = await Effect.runPromise( + tool.execute( + { code: "try { await tools.host.trigger({}) } catch {} while (true) {}" }, + { ...ctx, abort: controller.signal }, + ), + ) + expect(output.output).toBe("Execution cancelled.") + expect(output.metadata.error).toBe(true) + expect(output.metadata.toolCalls).toEqual([{ tool: "host.trigger", status: "running" }]) + }) + + test("a pre-aborted signal cancels before the program runs", async () => { + const controller = new AbortController() + controller.abort() + const ran: string[] = [] + const tool = await build({ host_touch: mcpTool("touch", () => (ran.push("called"), "ok")) }) + const output = await Effect.runPromise( + tool.execute({ code: "return await tools.host.touch({})" }, { ...ctx, abort: controller.signal }), + ) + expect(output.output).toBe("Execution cancelled.") + expect(ran).toEqual([]) + }) + test("leaves oversized results to OpenCode's native tool-output truncation", async () => { // No CodeMode output limit is set, so the full result reaches the shared Tool.define // wrapper intact (the harness Truncate fake passes it through un-truncated). From 9be249aafe633b94d7fd2efa09c4ed0b8f8b31c3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 19:51:49 -0500 Subject: [PATCH 34/41] refactor(opencode): align code mode module with session conventions - Add the SessionCodeMode self-reexport footer and replace the star import in tools.ts with the named projection, matching the other session modules. - Guard the groupByServer fallback: a key without an underscore now uses the whole key as the server name instead of silently dropping its last character (unreachable today; pinned by a test). - Use CODE_MODE_TOOL at the result/metadata title sites instead of hardcoding the string. --- packages/codemode/codemode.md | 8 ++++++-- packages/opencode/src/session/code-mode.ts | 12 +++++++----- packages/opencode/src/session/tools.ts | 4 ++-- packages/opencode/test/session/code-mode.test.ts | 11 +++++++++++ 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 3f43b7c5afed..366a037b4e5e 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -890,13 +890,17 @@ Pre-PR fixes (user-approved cut): call tools that can only fail at permission time, and per-message `tools[key]=false` disabling has no child-call equivalent. Fix: filter the catalog with the same ruleset. -- [ ] Style: `code-mode.ts` is the only `src/session` sibling without the +- [x] Style: `code-mode.ts` is the only `src/session` sibling without the `export * as ... from "./..."` self-reexport footer, forcing a star import at `tools.ts:26` (AGENTS.md violation). Add footer + import the projection. -- [ ] Trivial: latent `groupByServer` fallback bug — `key.slice(0, key.indexOf("_"))` is + DONE: added `export * as SessionCodeMode from "./code-mode"` footer; `tools.ts` now + imports the named `SessionCodeMode` projection. +- [x] Trivial: latent `groupByServer` fallback bug — `key.slice(0, key.indexOf("_"))` is `slice(0, -1)` when no underscore (unreachable today; guard or drop); dead `CODE_MODE_TOOL` export (integration points hardcode `"execute"` — use it or inline it). + DONE: no-underscore key now falls back to the whole key (test pins it); the four + `title: "execute"` sites in `code-mode.ts` now reference `CODE_MODE_TOOL`. Post-MVP (logged, not blocking an experimental flag): - [ ] **Plugin `tool.execute.before/after` hooks skip child calls** — legacy MCP diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index bd7020f6559e..60c1f16bbdee 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -85,7 +85,7 @@ export function groupByServer( const byLongest = [...servers].sort((a, b) => b.length - a.length) const groups = new Map() 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 server = byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key) const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key const def = mcpDefs[key] const entry: CatalogEntry = { @@ -270,7 +270,7 @@ export function define( // race below; racing alone would still let the program run its first steps.) if (ctx.abort.aborted) { return { - title: "execute", + title: CODE_MODE_TOOL, metadata: { toolCalls: [], error: true }, output: "Execution cancelled.", } satisfies Tool.ExecuteResult @@ -282,7 +282,7 @@ export function define( const collect = (attachment: Attachment) => void attachments.push(attachment) // Stream the current call list to the UI. Sent on every status change so the // tool part shows each child call appearing and resolving while the program runs. - const publish = () => ctx.metadata({ title: "execute", metadata: { toolCalls: calls.map((c) => ({ ...c })) } }) + const publish = () => ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } }) // One CodeMode tool per MCP tool: gate on permission, dispatch through the // ai-sdk wrapper (which owns callTool timeouts/progress and turns an MCP @@ -346,7 +346,7 @@ export function define( if (result.ok) { return { - title: "execute", + title: CODE_MODE_TOOL, metadata: { toolCalls: calls }, output: withLogs(formatValue(result.value), logs), ...attached, @@ -356,7 +356,7 @@ export function define( // discovery); append the ones the message doesn't already contain. const hints = (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint)) return { - title: "execute", + title: CODE_MODE_TOOL, metadata: { toolCalls: calls, error: true }, output: withLogs([result.error.message, ...hints].join("\n"), logs), ...attached, @@ -365,3 +365,5 @@ export function define( }), ) } + +export * as SessionCodeMode from "./code-mode" diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 9e10a9ce9795..c54ea43360f7 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -23,7 +23,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { isRecord } from "@/util/record" import { RuntimeFlags } from "@/effect/runtime-flags" import { McpCatalog } from "@/mcp/catalog" -import * as CodeModeTool from "./code-mode" +import { SessionCodeMode } from "./code-mode" const MCP_RESOURCE_TOOLS = { list: "list_mcp_resources", @@ -99,7 +99,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { const codeModeTool = flags.experimentalCodeMode && Object.keys(mcpTools).length > 0 ? yield* Tool.init( - yield* CodeModeTool.define( + yield* SessionCodeMode.define( mcpTools, yield* mcp.defs(), Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize), diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 5e73468022f4..3ddeb49a50a0 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -74,6 +74,17 @@ describe("code mode execute", () => { }) }) + test("groupByServer uses the whole key as the server name when it has no underscore", () => { + const groups = groupByServer({ standalone: mcpTool("standalone", () => "") }, []) + expect([...groups.keys()]).toEqual(["standalone"]) + expect(groups.get("standalone")![0]).toMatchObject({ + path: "standalone.standalone", + server: "standalone", + local: "standalone", + key: "standalone", + }) + }) + test("groupByServer carries the raw MCP schemas for rendering", () => { const defs: Record = { weather_current: { From 8f330deee26989721cdeb17ff29a56d7b71f6efd Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 20:31:06 -0500 Subject: [PATCH 35/41] feat(codemode): render bracket notation for non-identifier tool names Catalog and search signatures are now valid JavaScript expressions rooted at `tools` (e.g. tools.context7["resolve-library-id"]), so every rendered path is directly usable as a call site. Exact-lookup search accepts the rendered expression as well as the canonical path. Instructions now state that only listed/searchable tools exist inside `tools` and note that bracket notation may appear for names that are not identifiers. --- packages/codemode/README.md | 4 +-- packages/codemode/src/tool-runtime.ts | 48 ++++++++++++++++--------- packages/codemode/test/codemode.test.ts | 42 ++++++++++++++++++++++ 3 files changed, 75 insertions(+), 19 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 489a9c12f3bd..5ae401c5f0f0 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -191,9 +191,9 @@ tools.github.list_issues(input: { }): Promise ``` -Result paths carry the `tools.` prefix (`tools.orders.lookup`), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (with or without the `tools.` prefix) is treated as a lookup and returns that tool alone. +Result paths are rendered as JavaScript expressions rooted at `tools` (`tools.orders.lookup`, or `tools.context7["resolve-library-id"]` for non-identifier segments), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (canonical path, `tools.`-prefixed path, or rendered JavaScript expression) is treated as a lookup and returns that tool alone. -The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; `JSON.parse` string results; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (filter and aggregate collections in code; treat `Promise` results as shapeless until verified; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace via search when it is advertised), a short `## Syntax` section that assumes standard JavaScript and names only what is unusual (TypeScript annotations stripped; the data-boundary serialization of Date/Map/Set/RegExp) or missing (classes, generators, `for await...of`, `.then`/`.catch`/`.finally`), and the budgeted `## Available tools` catalog. Every call form uses explicit `.`/`` placeholders — never a real or fabricated tool name. +The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; `JSON.parse` string results; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result tools exist inside `tools`; filter and aggregate collections in code; treat `Promise` results as shapeless until verified; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace via search when it is advertised), a short `## Syntax` section that assumes standard JavaScript and names only what is unusual (TypeScript annotations stripped; the data-boundary serialization of Date/Map/Set/RegExp) or missing (classes, generators, `for await...of`, `.then`/`.catch`/`.finally`), and the budgeted `## Available tools` catalog. Example call forms use explicit `.`/`` placeholders — never a real or fabricated tool name. A host cannot define its own `$codemode` top-level namespace. diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index b13577554b13..64ba85f8ecff 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -68,6 +68,13 @@ const reservedNamespace = "$codemode" const defaultMaxInlineCatalogTokens = 2_000 const defaultSearchLimit = 10 const searchSignature = "tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>" +const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/ + +const toolExpression = (path: string) => + "tools" + path + .split(".") + .map((segment) => identifierSegment.test(segment) ? `.${segment}` : `[${JSON.stringify(segment)}]`) + .join("") export class ToolReference { constructor(readonly path: ReadonlyArray) {} @@ -255,10 +262,10 @@ const definitions = (tools: HostTools, path: ReadonlyArray = []): } const describeDefinition = (path: string, definition: Definition): ToolDescription => ({ - path, - description: definition.description, - signature: `tools.${path}(input: ${inputTypeScript(definition)}): Promise<${outputTypeScript(definition)}>`, - }) + path, + description: definition.description, + signature: `${toolExpression(path)}(input: ${inputTypeScript(definition)}): Promise<${outputTypeScript(definition)}>`, +}) const visibleDefinitions = (tools: HostTools) => definitions(tools).flatMap(({ path, definition }) => { @@ -330,7 +337,7 @@ const catalogLine = (tool: ToolDescription) => { const toSearchEntry = (path: string, definition: Definition, description: ToolDescription): SearchEntry => ({ description, - signature: `tools.${path}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`, + signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`, namespace: path.split(".", 1)[0]!, searchText: [ path, @@ -418,13 +425,15 @@ export const discoveryPlan = ( // Section order is deliberate: workflow first (the top is the least likely part of a long // description to be truncated or skimmed away), then rules, then syntax, with the budgeted - // catalog at the bottom. Every call form uses explicit `.` placeholders — + // catalog at the bottom. Example call forms use explicit `.` placeholders — // never a real or fabricated tool name. const intro = [ "Write a CodeMode program to answer the request. Return code only.", empty ? "Execute JavaScript in a confined runtime." - : "Execute JavaScript in a confined runtime with access to the tools listed below under `tools.*`.", + : complete + ? "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed below; surrounding agent tools are not available unless listed here." + : "Execute JavaScript in a confined runtime. Inside this program, `tools` contains only the host-provided tools listed or searchable below; surrounding agent tools are not available unless listed here.", ] // The search step exists only when search is advertised (PARTIAL catalog); a COMPLETE @@ -438,14 +447,14 @@ export const discoveryPlan = ( ...(complete ? [ "1. Pick a tool from the list under `## Available tools` — each line is the exact call signature; use it as-is rather than guessing segments.", - "2. Call it: `const res = await tools..(input)`", + "2. Call it using the exact signature shown: `const res = await tools..(input)` — bracket notation may appear for names that are not JavaScript identifiers.", '3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string.', "4. Return only the fields you need: `return { : data. }` — raw payloads get truncated and waste context.", ] : [ '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "" })` — short phrases like "list issues" work best.', "2. Read the matches: each item is `{ path, description, signature }` — read the description before using an unfamiliar tool.", - "3. Call it with the result's `path` as-is (never guess segments): `const res = await tools..(input)`", + "3. Call it with the result's `path` as-is (never guess segments): `const res = await tools..(input)` — bracket notation may appear for names that are not JavaScript identifiers.", '4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string.', "5. Return only the fields you need: `return { : data. }` — raw payloads get truncated and waste context.", ]), @@ -457,6 +466,9 @@ export const discoveryPlan = ( "", "## Rules", "", + complete + ? "- Only tools listed here are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed." + : "- Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed.", "- Filter, aggregate, and transform collections in code — never return them raw or call a tool per item across messages.", "- A result typed `Promise` has no guaranteed shape — verify what actually came back before relying on its fields.", "- Run independent calls in parallel: `await Promise.all(items.map((item) => tools..(item)))`.", @@ -646,11 +658,12 @@ export const make = ( try: () => { const limit = typeof request.limit === "number" ? request.limit : defaultSearchLimit const scoped = namespace === undefined ? searchIndex : searchIndex.filter((entry) => entry.namespace === namespace) - // A query that names one tool path exactly (optionally `tools.`-prefixed) is a - // lookup, not a search: return that tool alone. + // A query that names one tool path exactly (canonical path or rendered + // JavaScript expression) is a lookup, not a search: return that tool alone. 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) + const exact = pathQuery === "" ? undefined : scoped.find((entry) => + entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed) const terms = tokenize(query).map(termForms) // Additive field-weighted scoring, summed across terms: exact path or path // segment (20) > path substring (8) > description substring (4) > any @@ -678,13 +691,14 @@ export const make = ( .sort((left, right) => right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path)) .map(({ entry }) => entry) - // Result paths carry the `tools.` prefix so each `path` is directly usable - // as the call site (`await tools.github.list({ ... })`); the signature is - // the pretty, JSDoc-annotated form (schema descriptions and constraints - // ride along as field comments). + // Result paths are rendered as JavaScript expressions so each `path` is + // directly usable as the call site (`await tools.github.list({ ... })` or + // `await tools.ns["dashed-name"]({ ... })`). The signature is the pretty, + // JSDoc-annotated form (schema descriptions and constraints ride along as + // field comments). const items = ranked.slice(0, limit).map(({ description, signature }) => ({ ...description, - path: `tools.${description.path}`, + path: toolExpression(description.path), signature, })) return copyIn({ items, total: ranked.length }, "Result from tool '$codemode.search'") diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 6ded82f9bb74..ac04e9208aca 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -512,6 +512,44 @@ describe("CodeMode public contract", () => { } }) + test("renders bracket notation for tool names that are not JavaScript identifiers", async () => { + const resolveLibrary = Tool.make({ + description: "Resolve a library ID", + input: Schema.Struct({ libraryName: Schema.String }), + output: Schema.String, + run: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`), + }) + const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } }) + + expect(runtime.catalog()).toStrictEqual([{ + path: "context7.resolve-library-id", + description: "Resolve a library ID", + signature: 'tools.context7["resolve-library-id"](input: { libraryName: string }): Promise', + }]) + expect(runtime.instructions()).toContain('tools.context7["resolve-library-id"](input: { libraryName: string }): Promise') + + const search = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`)) + expect(search.ok).toBe(true) + if (search.ok) { + expect(search.value).toStrictEqual({ + items: [{ + path: 'tools.context7["resolve-library-id"]', + description: "Resolve a library ID", + signature: 'tools.context7["resolve-library-id"](input: {\n libraryName: string\n}): Promise', + }], + total: 1, + }) + } + + const call = await Effect.runPromise(runtime.execute(`return await tools.context7["resolve-library-id"]({ libraryName: "TypeScript" })`)) + expect(call.ok).toBe(true) + if (call.ok) expect(call.value).toBe("/resolved/TypeScript") + + const exact = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`)) + expect(exact.ok).toBe(true) + if (exact.ok) expect((exact.value as { total: number }).total).toBe(1) + }) + test("instructions use markdown sections with placeholder-only call forms", () => { const runtime = CodeMode.make({ tools }) const instructions = runtime.instructions() @@ -527,6 +565,9 @@ describe("CodeMode public contract", () => { expect(instructions).toContain("Return only the fields you need") expect(instructions).toContain("raw payloads get truncated and waste context") expect(instructions).toContain("`const res = await tools..(input)`") + expect(instructions).toContain("surrounding agent tools are not available unless listed here") + expect(instructions).toContain("Only tools listed here are available inside `tools`") + expect(instructions).toContain("bracket notation may appear for names that are not JavaScript identifiers") // Placeholders use the ./ style ONLY — no fabricated tool // names, and no real catalog tools cherry-picked into example lines. expect(instructions).toContain("`return { : data. }`") @@ -543,6 +584,7 @@ describe("CodeMode public contract", () => { expect(partial).toContain( '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "" })` — short phrases like "list issues" work best.', ) + expect(partial).toContain("Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`") expect(partial).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.') expect(partial).not.toContain("total_count") expect(partial).not.toContain("tools.orders.lookup({") From 9431ba3da0a81e93572871b7792d383d62e8beae Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 20:31:21 -0500 Subject: [PATCH 36/41] refactor(opencode): promote code mode to a registry tool service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code mode was constructed ad-hoc in SessionTools.resolve and bolted onto the registry's output, which meant it bypassed the tool.definition plugin hook and — the real bug — baked permission-denied MCP tools into its catalog before request-prep filtering could run. It is now a proper registry citizen following the TaskTool pattern: - CodeModeTool (src/tool/code-mode.ts, moved from src/session/) is a Tool.define service depending on MCP/Agent/Session; the registry yields it, gates it on the experimental flag plus connected MCP tools, and appends the grouped catalog per agent via describeCodeMode — the same composition point as describeTask, so plugins see the base description. - Permission.visibleTools is the shared visibility predicate (delegating to Permission.disabled): only a hard deny hides a tool; ask-level rules stay visible and prompt at call time. Used by both request-prep resolveTools and the code-mode catalog so the two cannot drift. - Execution rebuilds the runtime per call from a fresh MCP snapshot filtered with the merged agent+session ruleset, so a denied tool is not dispatchable even if the model guesses its name (it gets the normal unknown-tool diagnostic). - SessionTools.resolve keeps only the raw-MCP-registration suppression; the special-case construction is gone. Tests move to test/tool/ with new coverage: deny hides from catalog and search, ask stays visible and callable, denied tools not dispatchable, and registry-level enablement. --- packages/codemode/codemode.md | 105 ++++++-- packages/opencode/src/permission/index.ts | 12 + packages/opencode/src/session/llm/request.ts | 6 +- packages/opencode/src/session/tools.ts | 26 +- .../src/{session => tool}/code-mode.ts | 101 ++++++-- packages/opencode/src/tool/registry.ts | 29 ++- .../code-mode-integration.test.ts | 61 +++-- .../test/{session => tool}/code-mode.test.ts | 237 ++++++++++++++---- packages/opencode/test/tool/registry.test.ts | 100 ++++++++ 9 files changed, 541 insertions(+), 136 deletions(-) rename packages/opencode/src/{session => tool}/code-mode.ts (81%) rename packages/opencode/test/{session => tool}/code-mode-integration.test.ts (83%) rename packages/opencode/test/{session => tool}/code-mode.test.ts (74%) diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 366a037b4e5e..9c4d991783d8 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -70,10 +70,12 @@ From issue #34787 and design discussion. Do not relitigate these casually. `@maxItems`) ride along as field comments. The original spec's separate `input`/`output` raw-schema fields are deliberately NOT added: shapes are already fully expressed in the TypeScript signature and schema annotations now arrive as JSDoc — intent satisfied, letter - deviated. Result `path`s carry the `tools.` prefix (post-wave fix) so each is directly + deviated. Result `path`s render a JavaScript expression rooted at `tools` (for example + `tools.github.list_issues` or `tools.context7["resolve-library-id"]`) so each is directly usable as the call site; the internal `ToolDescription.path` stays unprefixed. - Default limit: **10** (done). Exact-path lookup goes through search too: a query equal to a - tool path (with or without the `tools.` prefix) returns that tool alone (done). + canonical tool path, `tools.`-prefixed path, or rendered JavaScript expression returns that + tool alone (done). - Signatures render **native payloads**: `Promise`, NOT `Promise>`. There is no result envelope; attachments never appear in return types (they are collected host-side, see below). @@ -138,10 +140,12 @@ From issue #34787 and design discussion. Do not relitigate these casually. Everything below is committed and pushed on `codemode-v2` (six commits, in pairs of generic-package + OpenCode-integration: waves 0–5, Fixes 4–9, then the DSL-expansion pass / real-JS error names / truncation layering). Verification: from `packages/codemode`, -`bun test` (210 pass / 0 fail across `codemode/parity/stdlib/promise/enumeration/signature`) +`bun test` (211 pass / 0 fail across `codemode/parity/stdlib/promise/enumeration/signature`) and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and -`bun test test/session/` (all green — the adapter suites are `code-mode.test.ts`, 34 tests, -and `code-mode-integration.test.ts`, 16 tests). +`bun test test/tool/` (all green — the adapter suites are `test/tool/code-mode.test.ts`, +43 tests, and `test/tool/code-mode-integration.test.ts`, 16 tests, moved from +`test/session/` by the registry promotion; registry coverage in +`test/tool/registry.test.ts`). ### Wave 0 — scaffold (done) - `packages/codemode` created from the experiments implementation: `src/{index,codemode,tool, @@ -467,9 +471,11 @@ adapter needed **no changes**. `namespace` (validated as a string when provided) filters `SearchEntry`s to one top-level namespace before ranking; `{ query: "", namespace: "github" }` lists that namespace alphabetically. `searchSignature` updated. - - **Prefixed result paths**: search-result `path`s are `tools.github.list_issues` style, - directly usable as the call site. Internal `ToolDescription.path` stays unprefixed; only - the search RESULT items are prefixed. Exact-path queries accept both forms, as before. + - **Callable result paths**: search-result `path`s are rendered as JavaScript expressions + rooted at `tools` (`tools.github.list_issues`, or bracket notation for non-identifier + segments), directly usable as the call site. Internal `ToolDescription.path` stays + unprefixed; only the search RESULT items are rendered this way. Exact-path queries accept + canonical paths and rendered expressions. - **Instructions** (`discoveryPlan`): an explicit calling-convention line and a browse hint on the search advertisement (both since absorbed into the `## Rules` section by the instructions restructure below). @@ -829,6 +835,54 @@ parity items, done as one focused pass; no public API or limit changes): and the README's incorrect "`effect` as a peer dependency" line corrected (`effect` is a regular dependency; hosts depend on it themselves because the API surface is Effect-typed). +**Registry promotion + permission-aware catalog** (the "promote to a proper tool service" +restructure; fixes the §4 permission-advertising bug): + - **The adapter moved** `src/session/code-mode.ts` → `src/tool/code-mode.ts` and is now a + registry-resident tool service on the TaskTool precedent: `CodeModeTool = + Tool.define(CODE_MODE_TOOL, ...)` whose init depends on `MCP.Service`, `Agent.Service`, + and `Session.Service`. It is yielded in `ToolRegistry.layer`, gated into `builtin` by + `flags.experimentalCodeMode` (like the lsp/plan experiments), and `MCP.node` joined the + registry's `node.deps` (`MCP.node` has no ToolRegistry dependency, so no cycle). The + session-level special-casing in `session/tools.ts` (ad-hoc `SessionCodeMode.define` + + append) is deleted; the early return that suppresses raw per-MCP registration when the + flag is on stays session-side, keyed on the same flag+tool-count condition. + - **Enablement** lives in `ToolRegistry.tools()` next to the WebSearchTool check: the MCP + tool count is consulted once (an Effect) before the synchronous filter, and code mode + passes the predicate iff `flags.experimentalCodeMode` && count > 0. + - **Description split on the `describeTask` precedent**: the tool's static base + description is a two-line summary; `describeCodeMode(agent)` in `registry.tools()` + appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog, + `catalogInstructions` in the adapter) at the same composition point as task — so + `plugin.trigger("tool.definition")` sees the base description first. + - **Permission-aware catalog + dispatch** (the bug fix): the visibility predicate from + `llm/request.ts` `resolveTools` is hoisted to `Permission.visibleTools(tools, ruleset)` + (a record filter over `Permission.disabled` — only a hard `deny` with pattern `"*"` + hides a tool; ask-level rules stay fully visible and prompt at call time) and + `resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters + with the agent's ruleset before building the catalog/search index; `execute` rebuilds + the runtime per execution from a fresh, filtered `mcp.tools()` snapshot using the merged + agent+session ruleset (`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, the same + merge `SessionTools.context` wires into `ctx.ask`) — a denied tool is not dispatchable + even if the model guesses its name and yields the normal unknown-tool diagnostic. + Documented gap (out of scope by design): per-message `user.tools[key] === false` arrives + at request-prep after descriptions are built and has no child-call equivalent. + - **Preserved behavior**: cancellation race + pre-aborted-signal guard, `toSandboxResult` + unwrap order, attachment accumulation, `CODE_MODE_TOOL` at all title sites, no execution + limits (native truncation only), `displayInput`, per-child `ctx.ask` gating (now wired + through `Tool.Context` exactly like every registry tool). + - **Explicit non-goal**: memoizing the catalog builder keyed on (ToolsChanged generation, + permission ruleset) was considered and deliberately skipped — the per-turn rebuild is + cheap (grouping + string rendering); revisit only if profiling shows it matters. + - **Tests**: the two adapter suites moved to `test/tool/{code-mode,code-mode-integration} + .test.ts` (mocked `MCP.Service`/`Agent.Service`/`Session.Service` replacing the direct + `define(...)` construction; description assertions target `catalogInstructions`, the + registry's composition input) and gained permission coverage: deny excluded from + catalog/search, ask-level stays visible and callable, denied tool undispatchable + (unknown-tool diagnostic), `Permission.visibleTools` semantics. `test/tool/ + registry.test.ts` gained four registry-level tests: registered with flag+MCP tools, + excluded without MCP tools, excluded with flag off, and deny/ask catalog filtering + through `registry.tools()`. Suites: 43 + 16 adapter tests, 16 registry tests, all green. + --- ## 4. Remaining work (detailed TODO) @@ -884,12 +938,19 @@ Pre-PR fixes (user-approved cut): interruption broke; pre-aborted signal runs nothing). Adapter suite 34 → 36. (Wiring abort→interrupt into the shared `tools.ts` runner for ALL tools remains a worthwhile separate change.) -- [ ] **Permission-denied/disabled MCP tools are still advertised in the catalog** — the +- [x] **Permission-denied/disabled MCP tools are still advertised in the catalog** — the non-code-mode path filters them from the model's view (`llm/request.ts:208-213`); code mode builds the catalog from all of `mcp.tools()`, so the model is invited to call tools that can only fail at permission time, and per-message `tools[key]=false` disabling has no child-call equivalent. Fix: filter the catalog with the same ruleset. + DONE (see the "Registry promotion + permission-aware catalog" entry in §3): the + shared `Permission.visibleTools` predicate filters both the appended + catalog/description (`describeCodeMode`, agent ruleset) and the execute-time tool + tree (merged agent+session ruleset) — hard-denied tools are neither advertised nor + dispatchable. Ask-level tools stay visible/callable. Per-message + `tools[key] === false` remains a documented gap by design (it arrives at + request-prep, after descriptions are built). - [x] Style: `code-mode.ts` is the only `src/session` sibling without the `export * as ... from "./..."` self-reexport footer, forcing a star import at `tools.ts:26` (AGENTS.md violation). Add footer + import the projection. @@ -907,10 +968,13 @@ Post-MVP (logged, not blocking an experimental flag): registration fires them per tool (`tools.ts:419-441`); under code mode only the outer `execute` fires them, so auditing/intercepting plugins silently lose MCP coverage when the flag flips. -- [ ] Description/preview rebuilt every assistant turn — `resolve()` re-runs +- [x] Description/preview rebuilt every assistant turn — `registry.tools()` re-runs `groupByServer` + a throwaway `CodeMode.make(...).instructions()` per turn - (`code-mode.ts:252-259`); memoizable on a defs snapshot key. A second - `CodeMode.make` per execution is inherent (description precedes execution). + (`describeCodeMode`). DECIDED as an explicit non-goal: memoizing the catalog + builder keyed on (ToolsChanged generation, permission ruleset) was considered and + deliberately skipped — the per-turn rebuild is cheap (grouping + string + rendering); revisit only if profiling shows it matters. A second `CodeMode.make` + per execution is inherent (description precedes execution). - [ ] Child permission rejection round-trips through the defect channel — `ctx.ask` defect (`tools.ts:90` orDie) recovered via `catchCause` + `Cause.squash` (`code-mode.ts:238-245`). Works, interrupts preserved, but fragile coupling; @@ -994,9 +1058,14 @@ Post-MVP (logged, not blocking an experimental flag): `src/tool-runtime.ts` — tool tree, `copyIn`/`copyOut`, search/discovery, invoke path; `src/tool.ts` — `Tool.make` + JSON-Schema→TS rendering; `src/values.ts` — sandbox value types; `src/tool-error.ts` — `ToolError`; tests in `test/{codemode,parity,stdlib}.test.ts`. -- OpenCode file map (integration points): `src/session/code-mode.ts` (adapter, rewritten in - Wave 3); `src/session/tools.ts:93-115,406` (gating/registration); `src/mcp/index.ts` - (`MCP.tools()`/`MCP.defs()`); `src/mcp/catalog.ts` (`convertTool`, `server_tool` naming); - `src/tool/tool.ts` (`ExecuteResult.attachments`, truncation wrapper); - `src/session/message-v2.ts` (attachments → vision); `packages/tui/src/routes/session/index.tsx` - (`Execute` progress component); `src/effect/runtime-flags.ts` (feature flag). +- OpenCode file map (integration points): `src/tool/code-mode.ts` (the adapter, now a + registry tool service — `CodeModeTool` + `catalogInstructions`; formerly + `src/session/code-mode.ts`); `src/tool/registry.ts` (`describeCodeMode`, enablement in + `tools()`, `MCP.node` dep); `src/session/tools.ts` (raw-MCP-registration suppression + when the flag is on); `src/permission/index.ts` (`Permission.visibleTools`, the shared + visibility predicate, also used by `src/session/llm/request.ts` `resolveTools`); + `src/mcp/index.ts` (`MCP.tools()`/`MCP.defs()`); `src/mcp/catalog.ts` (`convertTool`, + `server_tool` naming); `src/tool/tool.ts` (`ExecuteResult.attachments`, truncation + wrapper); `src/session/message-v2.ts` (attachments → vision); + `packages/tui/src/routes/session/index.tsx` (`Execute` progress component); + `src/effect/runtime-flags.ts` (feature flag). diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index e1eb13ccc80e..5db29dcc7bd8 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -213,6 +213,18 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set(tools: Record, ruleset: PermissionV1.Ruleset): Record { + const hidden = disabled(Object.keys(tools), ruleset) + return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name))) +} + export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 4f93411107df..3508ee981c5c 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -206,11 +206,11 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre }) function resolveTools(input: Pick) { - const disabled = Permission.disabled( - Object.keys(input.tools), + const visible = Permission.visibleTools( + input.tools, Permission.merge(input.agent.permission, input.permission ?? []), ) - return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k)) + return Record.filter(visible, (_, k) => input.user.tools?.[k] !== false) } export function hasToolCalls(messages: ModelMessage[]): boolean { diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index c54ea43360f7..92099a399bbe 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -22,8 +22,6 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { isRecord } from "@/util/record" import { RuntimeFlags } from "@/effect/runtime-flags" -import { McpCatalog } from "@/mcp/catalog" -import { SessionCodeMode } from "./code-mode" const MCP_RESOURCE_TOOLS = { list: "list_mcp_resources", @@ -91,28 +89,18 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }) const mcpTools = yield* mcp.tools() - // When code mode is enabled and MCP tools are present, expose them through the - // single code-mode `execute` tool instead of registering each MCP tool directly - // (see the early return below). Code mode is experimental and off by default. - // Namespaces are sanitized client names; tool keys are `sanitize(server)_sanitize(tool)`, - // so the names match the catalog key prefixes. - const codeModeTool = - flags.experimentalCodeMode && Object.keys(mcpTools).length > 0 - ? yield* Tool.init( - yield* SessionCodeMode.define( - mcpTools, - yield* mcp.defs(), - Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize), - ), - ) - : undefined + // When code mode is enabled and MCP tools are present, the registry exposes them + // through the single code-mode `execute` tool (ToolRegistry.tools), so raw per-MCP + // registration is suppressed via the early return below. Code mode is experimental + // and off by default. + const codeMode = flags.experimentalCodeMode && Object.keys(mcpTools).length > 0 const registryTools = yield* registry.tools({ modelID: ModelV2.ID.make(input.model.api.id), providerID: input.model.providerID, agent: input.agent, }) - for (const item of codeModeTool ? [...registryTools, codeModeTool] : registryTools) { + for (const item of registryTools) { const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item)) tools[item.id] = tool({ description: item.description, @@ -403,7 +391,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }) } - if (codeModeTool) return tools + if (codeMode) return tools for (const [key, item] of Object.entries(mcpTools)) { const execute = item.execute diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/tool/code-mode.ts similarity index 81% rename from packages/opencode/src/session/code-mode.ts rename to packages/opencode/src/tool/code-mode.ts index 60c1f16bbdee..ebb573825930 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/tool/code-mode.ts @@ -1,15 +1,20 @@ -import { Tool } from "@/tool/tool" +import * as Tool from "./tool" import type { Tool as AITool } from "ai" import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import { Cause, Effect, Schema } from "effect" import { CodeMode, - Tool as CodeModeTool, + Tool as SandboxTool, toolError, type ExecuteResult, type JsonSchema, type ToolDefinition, } from "@opencode-ai/codemode" +import { MCP } from "@/mcp" +import { McpCatalog } from "@/mcp/catalog" +import { Agent } from "@/agent/agent" +import { Session } from "@/session/session" +import { Permission } from "@/permission" export const CODE_MODE_TOOL = "execute" @@ -21,10 +26,22 @@ export const CODE_MODE_TOOL = "execute" // which applies to `execute` like any other tool and dumps the full output to a file when // it triggers. +// The static base description. The full usage guide and the grouped, permission-filtered +// tool catalog are appended per agent by the registry (`describeCodeMode`, the same +// composition point `describeTask` uses), so `plugin.trigger("tool.definition")` sees this +// base first, exactly like the task tool. +const DESCRIPTION = [ + "Execute a JavaScript/TypeScript program that orchestrates the connected MCP tools inside a confined runtime.", + "The full usage guide and the catalog of available tools follow below.", +].join("\n") + export const Parameters = Schema.Struct({ code: Schema.String.annotate({ - description: - "JavaScript source to execute. Call the available tools with `await tools..(input)`, compose the results, and `return` the final value.", + description: [ + "JavaScript source to execute.", + "Inside CodeMode, `tools` contains only the MCP/CodeMode tools listed in this execute tool's description; top-level opencode tools like bash, read, or lsp are not available unless listed there.", + "Call available tools using the exact signatures shown in this execute tool's description, compose the results, and `return` the final value.", + ].join(" "), }), }) @@ -103,6 +120,34 @@ export function groupByServer( return groups } +/** The executable catalog for a (already permission-filtered) MCP tool set: grouped + * entries, minus any without an ai-sdk execute function. */ +export function buildCatalog( + mcpTools: Record, + mcpDefs: Record, + servers: readonly string[], +): CatalogEntry[] { + return [...groupByServer(mcpTools, servers, mcpDefs).values()].flat().filter((entry) => entry.tool.execute !== undefined) +} + +/** + * The model-facing usage guide plus grouped catalog for the given MCP tool set: the + * CodeMode instructions for this tool tree (syntax guide + tool signatures, or the + * namespace overview + search for large catalogs). Callers pass an already + * permission-filtered tool set — hard-denied tools never enter the catalog. The preview + * tree's runs are placeholders — rendering never invokes them. + */ +export function catalogInstructions( + mcpTools: Record, + mcpDefs: Record, + servers: readonly string[], +): string { + const catalog = buildCatalog(mcpTools, mcpDefs, servers) + return CodeMode.make({ + tools: toolTree(catalog, () => () => Effect.fail(toolError("Tool preview is not executable."))), + }).instructions() +} + function displayInput(input: unknown): Record | undefined { if (input === null || input === undefined) return if (typeof input === "object" && !Array.isArray(input)) { @@ -224,7 +269,7 @@ function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) = const tree: Record> = {} for (const entry of catalog) { const namespace = (tree[entry.server] ??= {}) - namespace[entry.local] = CodeModeTool.make({ + namespace[entry.local] = SandboxTool.make({ description: entry.description, input: entry.inputSchema, output: entry.outputSchema, @@ -246,24 +291,15 @@ const askPermission = (ctx: Tool.Context, entry: CatalogEntry) => }), ) -export function define( - mcpTools: Record, - mcpDefs: Record, - servers: readonly string[], -) { - const groups = groupByServer(mcpTools, servers, mcpDefs) - const catalog = [...groups.values()].flat().filter((entry) => entry.tool.execute !== undefined) - // The agent-facing description is the CodeMode instructions for this tool tree - // (syntax guide + tool signatures, or the namespace overview + search for large - // catalogs). The preview tree's runs are placeholders — rendering never invokes them. - const description = CodeMode.make({ - tools: toolTree(catalog, () => () => Effect.fail(toolError("Tool preview is not executable."))), - }).instructions() +export const CodeModeTool = Tool.define( + CODE_MODE_TOOL, + Effect.gen(function* () { + const mcp = yield* MCP.Service + const agents = yield* Agent.Service + const sessions = yield* Session.Service - return Tool.define( - CODE_MODE_TOOL, - Effect.succeed>({ - description, + const init: Tool.DefWithoutID = { + description: DESCRIPTION, parameters: Parameters, execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { // Already cancelled: don't start the program at all. (The mid-flight case is the @@ -275,6 +311,18 @@ export function define( output: "Execution cancelled.", } satisfies Tool.ExecuteResult } + // A fresh MCP snapshot per execution, so the runtime tracks live tool-list + // changes, filtered with the same merged agent+session ruleset that gates + // `ctx.ask` (see SessionTools.context). A hard-denied tool never enters the + // tree, so it is not dispatchable even if the model guesses its name — the + // program gets the normal unknown-tool diagnostic, not a permission error. + const agent = yield* agents.get(ctx.agent) + const session = yield* sessions.get(ctx.sessionID).pipe(Effect.orDie) + const ruleset = Permission.merge(agent.permission, session.permission ?? []) + const mcpTools = Permission.visibleTools(yield* mcp.tools(), ruleset) + const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize) + const catalog = buildCatalog(mcpTools, yield* mcp.defs(), servers) + const calls: CallEntry[] = [] // Media stripped from child tool results accumulates here for the life of the // call; the bytes never enter the sandbox (see toSandboxResult). @@ -362,8 +410,7 @@ export function define( ...attached, } satisfies Tool.ExecuteResult }), - }), - ) -} - -export * as SessionCodeMode from "./code-mode" + } + return init + }), +) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 553fa1ebaf5c..e2e1d7fca89d 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -26,6 +26,9 @@ import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { WebSearchTool } from "./websearch" +import { CodeModeTool, catalogInstructions } from "./code-mode" +import { MCP } from "@/mcp" +import { McpCatalog } from "@/mcp/catalog" import { LspTool } from "./lsp" import * as Truncate from "./truncate" import { ApplyPatchTool } from "./apply_patch" @@ -88,6 +91,7 @@ export const layer = Layer.effect( const agents = yield* Agent.Service const truncate = yield* Truncate.Service const flags = yield* RuntimeFlags.Service + const mcp = yield* MCP.Service const invalid = yield* InvalidTool const task = yield* TaskTool @@ -105,6 +109,7 @@ export const layer = Layer.effect( const greptool = yield* GrepTool const patchtool = yield* ApplyPatchTool const skilltool = yield* SkillTool + const codemode = yield* CodeModeTool const agent = yield* Agent.Service const state = yield* InstanceState.make( @@ -212,6 +217,7 @@ export const layer = Layer.effect( question: Tool.init(question), lsp: Tool.init(lsptool), plan: Tool.init(plan), + codemode: Tool.init(codemode), }) return { @@ -233,6 +239,7 @@ export const layer = Layer.effect( tool.patch, ...(flags.experimentalLspTool ? [tool.lsp] : []), ...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []), + ...(flags.experimentalCodeMode ? [tool.codemode] : []), ], task: tool.task, read: tool.read, @@ -264,11 +271,25 @@ export const layer = Layer.effect( return ["Available agent types and the tools they have access to:", description].join("\n") }) + // The grouped MCP-tool catalog appended to the code-mode base description, built + // fresh per turn so it tracks live tool-list changes. Hard-denied tools (the shared + // Permission.visibleTools predicate over the agent's ruleset) never enter the + // catalog, its inlined signatures, or the in-program search index. + const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* (agent: Agent.Info) { + const visible = Permission.visibleTools(yield* mcp.tools(), agent.permission) + const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize) + return catalogInstructions(visible, yield* mcp.defs(), servers) + }) + const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { + // Consulted once before the synchronous filter: code mode registers only when the + // experimental flag is on AND at least one MCP tool is connected. + const mcpToolCount = flags.experimentalCodeMode ? Object.keys(yield* mcp.tools()).length : 0 const filtered = (yield* all()).filter((tool) => { if (tool.id === WebSearchTool.id) { return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel }) } + if (tool.id === CodeModeTool.id) return flags.experimentalCodeMode && mcpToolCount > 0 const usePatch = input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4") @@ -293,7 +314,11 @@ export const layer = Layer.effect( : undefined return { id: tool.id, - description: [output.description, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined] + description: [ + output.description, + tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined, + tool.id === CodeModeTool.id ? yield* describeCodeMode(input.agent) : undefined, + ] .filter(Boolean) .join("\n"), parameters: output.parameters, @@ -330,6 +355,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(FSUtil.defaultLayer), + Layer.provide(MCP.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(Format.defaultLayer), @@ -431,6 +457,7 @@ export const node = LayerNode.make({ LSP.node, Instruction.node, FSUtil.node, + MCP.node, EventV2Bridge.node, httpClient, CrossSpawnSpawner.node, diff --git a/packages/opencode/test/session/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts similarity index 83% rename from packages/opencode/test/session/code-mode-integration.test.ts rename to packages/opencode/test/tool/code-mode-integration.test.ts index ade51f652611..ec95f9e105b0 100644 --- a/packages/opencode/test/session/code-mode-integration.test.ts +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -1,7 +1,9 @@ import { beforeAll, describe, expect, test } from "bun:test" -import { define } from "@/session/code-mode" +import { CodeModeTool, catalogInstructions } from "@/tool/code-mode" import { McpCatalog } from "@/mcp/catalog" import { Agent } from "@/agent/agent" +import { MCP } from "@/mcp" +import { Session } from "@/session/session" import { Tool } from "@/tool/tool" import * as Truncate from "@/tool/truncate" import { MessageID, SessionID } from "@/session/schema" @@ -33,14 +35,6 @@ const ctx: Tool.Context = { ask: () => Effect.void, } -// Truncate echoes its input so assertions read the exact program output. -const layer = Layer.mergeAll( - Layer.mock(Truncate.Service, { - output: (text: string) => Effect.succeed({ content: text, truncated: false as const }), - }), - Layer.succeed(Agent.Service, Agent.Service.of({ get: () => Effect.succeed({ name: "build" } as any) } as any)), -) - // A real MCP server, exposed over an in-memory transport, with a representative mix // of tools: plain text, structured data (with an outputSchema), an image, and a // failing tool. Tools are defined with raw JSON Schema so outputSchema is exact. @@ -85,7 +79,8 @@ function handleCall(name: string, args: Record) { } } -let tool: Awaited> +let tool: Awaited>["tool"] +let description: string async function buildTool() { const server = new Server({ name: SERVER, version: "1.0.0" }, { capabilities: { tools: {} } }) @@ -107,30 +102,52 @@ async function buildTool() { mcpDefs[key] = def mcpTools[key] = McpCatalog.convertTool(def, client) } - return Effect.runPromise(define(mcpTools, mcpDefs, [SERVER]).pipe(Effect.flatMap(Tool.init), Effect.provide(layer))) + + // Truncate echoes its input so assertions read the exact program output; Agent/Session + // supply the (empty) permission rulesets the execute path merges; MCP serves the tools + // this real in-memory server listed — the same snapshot shape the live service returns. + const layer = Layer.mergeAll( + Layer.mock(Truncate.Service, { + output: (text: string) => Effect.succeed({ content: text, truncated: false as const }), + }), + Layer.mock(Agent.Service, { get: () => Effect.succeed({ name: "build", permission: [] } as any) }), + Layer.mock(Session.Service, { get: () => Effect.succeed({ permission: [] } as any) }), + Layer.mock(MCP.Service, { + tools: () => Effect.succeed(mcpTools), + defs: () => Effect.succeed(mcpDefs), + clients: () => Effect.succeed({ [SERVER]: {} as any }), + }), + ) + return { + tool: await Effect.runPromise(CodeModeTool.pipe(Effect.flatMap(Tool.init), Effect.provide(layer))), + // The catalog section the registry appends to the base description (describeCodeMode). + description: catalogInstructions(mcpTools, mcpDefs, [SERVER]), + } } const run = (code: string) => Effect.runPromise(tool.execute({ code }, ctx)) beforeAll(async () => { - tool = await buildTool() + const built = await buildTool() + tool = built.tool + description = built.description }) describe("code mode integration (real MCP server)", () => { - test("the tool description inlines full signatures with real MCP schemas", () => { - expect(tool.description).toContain("Available tools (COMPLETE list") - expect(tool.description).toContain("- fixtures (4 tools)") - expect(tool.description).toContain( + test("the appended catalog inlines full signatures with real MCP schemas", () => { + expect(description).toContain("Available tools (COMPLETE list") + expect(description).toContain("- fixtures (4 tools)") + expect(description).toContain( "tools.fixtures.add(input: { a: number; b: number }): Promise<{ sum: number }>", ) - expect(tool.description).toContain("tools.fixtures.get_text(input: { name: string }): Promise") - expect(tool.description).toContain("// Add two numbers and return the structured sum") + expect(description).toContain("tools.fixtures.get_text(input: { name: string }): Promise") + expect(description).toContain("// Add two numbers and return the structured sum") // Small catalog: everything is inline, so no discovery tool is advertised. - expect(tool.description).not.toContain("$codemode") + expect(description).not.toContain("$codemode") // The workflow section is present with placeholder-only call forms. - expect(tool.description).toContain("## Workflow") - expect(tool.description).toContain("`const res = await tools..(input)`") - expect(tool.description).not.toContain("total_count") + expect(description).toContain("## Workflow") + expect(description).toContain("`const res = await tools..(input)`") + expect(description).not.toContain("total_count") }) test("calls a text tool and receives its text as the native result", async () => { diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts similarity index 74% rename from packages/opencode/test/session/code-mode.test.ts rename to packages/opencode/test/tool/code-mode.test.ts index 3ddeb49a50a0..60aa0d252fe6 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -1,15 +1,21 @@ import { describe, expect, test } from "bun:test" import { + CODE_MODE_TOOL, + CodeModeTool, Parameters, - define, + catalogInstructions, formatValue, groupByServer, toSandboxResult, withLogs, type Attachment, -} from "@/session/code-mode" +} from "@/tool/code-mode" import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" +import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Agent } from "@/agent/agent" +import { MCP } from "@/mcp" +import { Permission } from "@/permission" +import { Session } from "@/session/session" import { Tool } from "@/tool/tool" import * as Truncate from "@/tool/truncate" import { McpCatalog } from "@/mcp/catalog" @@ -42,19 +48,63 @@ function mcpTool( } // Truncate echoes its input so assertions read the exact program output. Agent.get is -// only consulted by the shared wrapper during truncation. -const layer = Layer.mergeAll( - Layer.mock(Truncate.Service, { - output: (text: string) => Effect.succeed({ content: text, truncated: false as const }), - }), - Layer.succeed(Agent.Service, Agent.Service.of({ get: () => Effect.succeed({ name: "build" } as any) } as any)), -) - -// Derive sanitized server namespaces from the catalog keys, mirroring how -// session/tools.ts passes `Object.keys(mcp.clients()).map(sanitize)`. -function build(mcpTools: Record, defs: Record = {}, servers?: string[]) { - const names = servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))] - return Effect.runPromise(define(mcpTools, defs, names).pipe(Effect.flatMap(Tool.init), Effect.provide(layer))) +// consulted by the shared wrapper during truncation AND at execute time for the +// permission ruleset that filters the dispatchable tool tree; Session.get supplies the +// (empty) session-level ruleset half of that merge. +function harness(input: { + mcpTools: Record + defs?: Record + servers: string[] + permission?: PermissionV1.Rule[] +}) { + return Layer.mergeAll( + Layer.mock(Truncate.Service, { + output: (text: string) => Effect.succeed({ content: text, truncated: false as const }), + }), + Layer.mock(Agent.Service, { + get: () => Effect.succeed({ name: "build", permission: input.permission ?? [] } as any), + }), + Layer.mock(Session.Service, { + get: () => Effect.succeed({ permission: [] } as any), + }), + Layer.mock(MCP.Service, { + tools: () => Effect.succeed(input.mcpTools), + defs: () => Effect.succeed(input.defs ?? {}), + clients: () => Effect.succeed(Object.fromEntries(input.servers.map((name) => [name, {} as any]))), + }), + ) +} + +// Derive sanitized server namespaces from the catalog keys, mirroring how the registry +// passes `Object.keys(mcp.clients()).map(sanitize)`. +function serverNames(mcpTools: Record, servers?: string[]) { + return servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))] +} + +function build( + mcpTools: Record, + defs: Record = {}, + servers?: string[], + permission?: PermissionV1.Rule[], +) { + const names = serverNames(mcpTools, servers) + return Effect.runPromise( + CodeModeTool.pipe( + Effect.flatMap(Tool.init), + Effect.provide(harness({ mcpTools, defs, servers: names, permission })), + ), + ) +} + +// The agent-facing description, as the registry composes it (`describeCodeMode`): +// permission-filtered tool set → grouped catalog → CodeMode instructions. +function describeFor( + mcpTools: Record, + defs: Record = {}, + servers?: string[], + permission: PermissionV1.Rule[] = [], +) { + return catalogInstructions(Permission.visibleTools(mcpTools, permission), defs, serverNames(mcpTools, servers)) } describe("code mode execute", () => { @@ -99,8 +149,16 @@ describe("code mode execute", () => { expect(entry.outputSchema).toEqual(defs.weather_current!.outputSchema as any) }) - test("small catalogs inline every full signature in the tool description", async () => { - const tool = await build({ + test("the static base description carries no catalog; the registry appends it", async () => { + const tool = await build({ github_list_issues: mcpTool("list_issues", () => "") }) + expect(tool.id).toBe(CODE_MODE_TOOL) + expect(tool.description).toContain("confined runtime") + expect(tool.description).not.toContain("Available tools") + expect(tool.description).not.toContain("list_issues") + }) + + test("small catalogs inline every full signature in the appended catalog", () => { + const description = describeFor({ github_create_issue: mcpTool("create_issue", () => "", { type: "object", properties: { title: { type: "string" }, body: { type: "string" } }, @@ -110,29 +168,29 @@ describe("code mode execute", () => { linear_search: mcpTool("search", () => ""), }) - expect(tool.description).toContain("Available tools (COMPLETE list") - expect(tool.description).toContain("- github (2 tools)") - expect(tool.description).toContain("- linear (1 tool)") - expect(tool.description).toContain( + expect(description).toContain("Available tools (COMPLETE list") + expect(description).toContain("- github (2 tools)") + expect(description).toContain("- linear (1 tool)") + expect(description).toContain( "tools.github.create_issue(input: { title: string; body?: string }): Promise", ) - expect(tool.description).toContain("tools.github.list_issues(") - expect(tool.description).toContain("tools.linear.search(") + expect(description).toContain("tools.github.list_issues(") + expect(description).toContain("tools.linear.search(") // A schema with no properties renders as an empty object, not `{ }`. - expect(tool.description).toContain("tools.linear.search(input: {}): Promise") + expect(description).toContain("tools.linear.search(input: {}): Promise") // Fully inlined catalog: no discovery round-trip is needed or advertised. - expect(tool.description).not.toContain("$codemode") - expect(tool.description).not.toContain("Browse one namespace") + expect(description).not.toContain("$codemode") + expect(description).not.toContain("Browse one namespace") // The workflow/rules sections use placeholder call forms only — the example machinery // never cherry-picks a catalog tool or fabricates result fields. - expect(tool.description).toContain("## Workflow") - expect(tool.description).toContain("1. Pick a tool from the list under `## Available tools`") - expect(tool.description).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string') - expect(tool.description).toContain("Return only the fields you need") - expect(tool.description).not.toContain("total_count") + expect(description).toContain("## Workflow") + expect(description).toContain("1. Pick a tool from the list under `## Available tools`") + expect(description).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string') + expect(description).toContain("Return only the fields you need") + expect(description).not.toContain("total_count") }) - test("signatures render the declared outputSchema as the return type", async () => { + test("signatures render the declared outputSchema as the return type", () => { const defs: Record = { weather_current: { name: "current", @@ -144,8 +202,8 @@ describe("code mode execute", () => { }, } as any, } - const tool = await build({ weather_current: mcpTool("current", () => "") }, defs) - expect(tool.description).toContain( + const description = describeFor({ weather_current: mcpTool("current", () => "") }, defs) + expect(description).toContain( "tools.weather.current(input: { city: string }): Promise<{ tempC: number; summary?: string }>", ) }) @@ -169,29 +227,30 @@ describe("code mode execute", () => { properties: { topic: { type: "string", description: "Subject to look up" } }, required: ["topic"], }) - const tool = await build(tools, {}, ["alpha", "zeta"]) + const description = describeFor(tools, {}, ["alpha", "zeta"]) // Every namespace is listed with counts; signatures inline round-robin across // namespaces (cheapest-first within each) until the budget runs out, and the // description states exactly how comprehensive the list is. Round-robin fairness: // the small zeta namespace is fully shown even though alpha alone could exhaust // the whole budget. - expect(tool.description).toContain("Available tools (PARTIAL — ") - expect(tool.description).toMatch(/- alpha \(150 tools, \d+ shown\)/) - expect(tool.description).toContain("- zeta (1 tool)\n") - expect(tool.description).toContain("tools.zeta.only_tool(input: { topic: string }): Promise") - expect(tool.description).toContain("tools.$codemode.search(") + expect(description).toContain("Available tools (PARTIAL — ") + expect(description).toMatch(/- alpha \(150 tools, \d+ shown\)/) + expect(description).toContain("- zeta (1 tool)\n") + expect(description).toContain("tools.zeta.only_tool(input: { topic: string }): Promise") + expect(description).toContain("tools.$codemode.search(") // PARTIAL catalogs put search first in the workflow and advertise namespace browsing. - expect(tool.description).toContain("1. Find a tool (skip when it is already listed below)") - expect(tool.description).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.') - expect(tool.description).not.toContain("total_count") + expect(description).toContain("1. Find a tool (skip when it is already listed below)") + expect(description).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.') + expect(description).not.toContain("total_count") // All op lines cost the same estimated tokens (chars/4 rounds away the 1- vs 3-digit // name difference), so the path tiebreak decides: the lexicographically-first ops made // the cut and the lexicographic tail (op_99 is maximal) did not. - expect(tool.description).toContain("tools.alpha.op_0(") - expect(tool.description).not.toContain("tools.alpha.op_99(") + expect(description).toContain("tools.alpha.op_0(") + expect(description).not.toContain("tools.alpha.op_99(") // The runtime search tool works in-program and returns complete signatures. + const tool = await build(tools, {}, ["alpha", "zeta"]) const out = await Effect.runPromise( tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3 })" }, ctx), ) @@ -204,7 +263,7 @@ describe("code mode execute", () => { const signature = result.items.find((i: any) => i.path === "tools.zeta.only_tool").signature expect(signature).toContain("tools.zeta.only_tool(input: {\n") expect(signature).toContain(" /** Subject to look up */\n topic: string") - expect(tool.description).not.toContain("/**") + expect(description).not.toContain("/**") expect(out.metadata.toolCalls).toEqual([ { tool: "$codemode.search", status: "completed", input: { query: "only tool", limit: 3 } }, ]) @@ -495,6 +554,92 @@ describe("code mode execute", () => { }) }) +describe("code mode permission visibility", () => { + const deny = (permission: string): PermissionV1.Rule => ({ permission, pattern: "*", action: "deny" }) + const askRule = (permission: string): PermissionV1.Rule => ({ permission, pattern: "*", action: "ask" }) + const ok = () => ({ content: [{ type: "text", text: "ok" }] }) + + test("a hard-denied tool never enters the catalog or its search index", () => { + const mcpTools = { + github_create_issue: mcpTool("create_issue", ok), + github_list_issues: mcpTool("list_issues", ok), + } + const description = describeFor(mcpTools, {}, ["github"], [deny("github_create_issue")]) + expect(description).toContain("tools.github.list_issues(") + expect(description).not.toContain("create_issue") + expect(description).toContain("- github (1 tool)") + }) + + test("an ask-level tool stays fully visible in the catalog", () => { + const mcpTools = { + github_create_issue: mcpTool("create_issue", ok), + github_list_issues: mcpTool("list_issues", ok), + } + const description = describeFor(mcpTools, {}, ["github"], [askRule("github_create_issue")]) + expect(description).toContain("tools.github.create_issue(") + expect(description).toContain("tools.github.list_issues(") + expect(description).toContain("- github (2 tools)") + }) + + test("a hard-denied tool is not dispatchable: the program gets the unknown-tool diagnostic", async () => { + const called: string[] = [] + const tool = await build( + { + github_create_issue: mcpTool("create_issue", () => { + called.push("create_issue") + return ok() + }), + github_list_issues: mcpTool("list_issues", ok), + }, + {}, + ["github"], + [deny("github_create_issue")], + ) + + const denied = await Effect.runPromise( + tool.execute({ code: "return await tools.github.create_issue({ title: 'x' })" }, ctx), + ) + expect(denied.metadata.error).toBe(true) + expect(denied.output).toContain("Unknown tool 'github.create_issue'") + expect(denied.output).not.toContain("permission") + expect(called).toEqual([]) + + // The rest of the namespace still works. + const allowed = await Effect.runPromise( + tool.execute({ code: "return await tools.github.list_issues({})" }, ctx), + ) + expect(allowed.metadata.error).toBeUndefined() + expect(allowed.output).toBe("ok") + }) + + test("an ask-level tool remains callable and still prompts via ctx.ask", async () => { + const asked: string[] = [] + const askCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req.permission)) } + const tool = await build( + { github_list_issues: mcpTool("list_issues", ok) }, + {}, + ["github"], + [askRule("github_list_issues")], + ) + const out = await Effect.runPromise( + tool.execute({ code: "return await tools.github.list_issues({})" }, askCtx), + ) + expect(out.output).toBe("ok") + expect(asked).toEqual(["github_list_issues"]) + }) + + test("Permission.visibleTools hides only hard denies, matching Permission.disabled", () => { + const tools = { a_tool: 1, b_tool: 2, c_tool: 3 } + const visible = Permission.visibleTools(tools, [ + deny("a_tool"), + askRule("b_tool"), + // A scoped (non-"*") deny does not hide the tool from the catalog. + { permission: "c_tool", pattern: "something", action: "deny" }, + ]) + expect(Object.keys(visible)).toEqual(["b_tool", "c_tool"]) + }) +}) + describe("toSandboxResult", () => { const collector = () => { const attachments: Attachment[] = [] diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 0e4b05f3e216..389e2d4a4e8a 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -17,6 +17,8 @@ import { InstanceState } from "@/effect/instance-state" import { ToolJsonSchema } from "@/tool/json-schema" import { MessageID, SessionID } from "@/session/schema" import { RuntimeFlags } from "@/effect/runtime-flags" +import { MCP } from "@/mcp" +import { McpCatalog } from "@/mcp/catalog" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -57,6 +59,45 @@ const replacements = [ const it = testEffect(LayerNode.compile(root, replacements)) const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]])) +// Fake MCP.Service serving two tools on one server, built through the same +// `convertTool` path the real service uses so the code-mode catalog renders +// genuine signatures. +function fakeMcpLayer(tools: Record) { + const client = { callTool: async () => ({ content: [] }) } + const converted = Object.fromEntries( + Object.entries(tools).map(([key, def]) => [ + key, + McpCatalog.convertTool( + { + name: key, + description: def.description, + inputSchema: { type: "object", properties: {} }, + } as any, + client as any, + ), + ]), + ) + return Layer.mock(MCP.Service, { + tools: () => Effect.succeed(converted), + defs: () => Effect.succeed({}), + clients: () => Effect.succeed(Object.keys(tools).length > 0 ? ({ github: {} } as any) : {}), + }) +} + +const mcpToolsLayer = fakeMcpLayer({ + github_create_issue: { description: "Create an issue" }, + github_list_issues: { description: "List issues" }, +}) + +const codeModeFlags = [RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })] as const +const withCodeMode = testEffect( + LayerNode.compile(root, [[Config.node, configLayer], codeModeFlags, [MCP.node, mcpToolsLayer]]), +) +const withCodeModeNoMcp = testEffect( + LayerNode.compile(root, [[Config.node, configLayer], codeModeFlags, [MCP.node, fakeMcpLayer({})]]), +) +const withFlagOff = testEffect(LayerNode.compile(root, [...replacements, [MCP.node, mcpToolsLayer]])) + afterEach(async () => { await disposeAllInstances() }) @@ -491,3 +532,62 @@ describe("tool.registry", () => { }), ) }) + +describe("tool.registry code mode", () => { + const model = { providerID: ProviderV2.ID.opencode, modelID: ModelV2.ID.make("test") } + + withCodeMode.instance("registers the code-mode execute tool when the flag is on and MCP tools exist", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agents = yield* Agent.Service + const tools = yield* registry.tools({ ...model, agent: yield* agents.defaultInfo() }) + const execute = tools.find((tool) => tool.id === "execute") + if (!execute) throw new Error("code-mode execute tool was not registered") + // The registry appends the grouped catalog to the static base description. + expect(execute.description).toContain("confined runtime") + expect(execute.description).toContain("## Available tools") + expect(execute.description).toContain("tools.github.create_issue(") + expect(execute.description).toContain("tools.github.list_issues(") + }), + ) + + withCodeModeNoMcp.instance("does not register code mode when no MCP tools are connected", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agents = yield* Agent.Service + const tools = yield* registry.tools({ ...model, agent: yield* agents.defaultInfo() }) + expect(tools.map((tool) => tool.id)).not.toContain("execute") + }), + ) + + withFlagOff.instance("does not register code mode when the flag is off, even with MCP tools", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agents = yield* Agent.Service + const tools = yield* registry.tools({ ...model, agent: yield* agents.defaultInfo() }) + expect(tools.map((tool) => tool.id)).not.toContain("execute") + }), + ) + + withCodeMode.instance("hard-denied MCP tools are dropped from the catalog; ask-level ones stay", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agents = yield* Agent.Service + const base = yield* agents.defaultInfo() + const agent = { + ...base, + permission: [ + ...base.permission, + { permission: "github_create_issue", pattern: "*" as const, action: "deny" as const }, + { permission: "github_list_issues", pattern: "*" as const, action: "ask" as const }, + ], + } + const tools = yield* registry.tools({ ...model, agent }) + const execute = tools.find((tool) => tool.id === "execute") + if (!execute) throw new Error("code-mode execute tool was not registered") + expect(execute.description).not.toContain("create_issue") + expect(execute.description).toContain("tools.github.list_issues(") + expect(execute.description).toContain("- github (1 tool)") + }), + ) +}) From fedae57bde4b50fe9cea8ee48879ef781a4112a4 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 22:34:06 -0500 Subject: [PATCH 37/41] fix(codemode): quote non-identifier keys in signatures; unify compound assignment coercion Two externally reported interpreter/rendering bugs, both verified with failing tests before fixing: - renderSchema emitted raw property names, producing invalid TypeScript for schema properties like foo-bar/@type/x.y. Object keys now render bare for identifiers and JSON-quoted otherwise, in both compact and pretty renderings. The identifierSegment regex moves to tool.ts as the single source of truth; tool-runtime's bracket-notation toolExpression imports it. - applyCompoundAssignment applied raw JS operators to interpreter wrapper objects, so x += y diverged from x = x + y (a sandbox Date d += 1 produced "[object Object]1"; d -= 400 gave NaN instead of epoch arithmetic). The operator table and coercion move verbatim from evaluateBinaryExpression into a shared applyBinaryOperator; compound assignment validates against the compoundable set and dispatches through it. Logical assignments keep their short-circuit path, and compound assignment now rejects opaque references, consistent with binary operators. --- packages/codemode/src/codemode.ts | 145 +++++++++++------------ packages/codemode/src/tool-runtime.ts | 3 +- packages/codemode/src/tool.ts | 11 +- packages/codemode/test/parity.test.ts | 70 +++++++++++ packages/codemode/test/signature.test.ts | 60 ++++++++++ 5 files changed, 207 insertions(+), 82 deletions(-) diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index b5b60cbe03f9..5f879a294f29 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -911,6 +911,9 @@ const coerceToString = (value: unknown): string => { return String(value) } +/** Compound assignment operators (`x op= y`), each applying the binary operator `op`. */ +const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="]) + const coerceToNumber = (value: unknown): number => { if (value instanceof SandboxDate) return value.time if (isSandboxValue(value)) return Number.NaN @@ -2164,57 +2167,64 @@ class Interpreter { // function operand is a legitimate question, not an error), so it is handled before // the data-only operand check. if (operator === "instanceof") return instanceofValue(lhs, rhs, node) - if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) { - throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue") - } - // Data objects/arrays are null-prototype, so JS's ToPrimitive throws an opaque host - // "No default value" TypeError when an operator coerces them. Coerce to their JS string - // form first (as String(x) / template literals do) so operators behave like JavaScript. - // A Date follows its ToPrimitive hints: string for `+` (concatenation), its time value - // for arithmetic and ordering — so `end - start` and `a < b` work as in JS. - // Identity (=== / !==) and the right operand of `in` keep their raw object value. - const coerceOperand = (operand: unknown): unknown => { - if (operand instanceof SandboxDate) return operator === "+" ? coerceToString(operand) : operand.time - return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand - } - const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object" - const l = coerceOperand(lhs) as any - const r = coerceOperand(rhs) as any - let result: unknown - switch (operator) { - case "+": result = l + r; break - case "-": result = l - r; break - case "*": result = l * r; break - case "/": result = l / r; break - case "%": result = l % r; break - case "**": result = l ** r; break - // Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces. - case "==": result = bothObjects ? lhs === rhs : l == r; break - case "===": result = lhs === rhs; break - case "!=": result = bothObjects ? lhs !== rhs : l != r; break - case "!==": result = lhs !== rhs; break - case "<": result = l < r; break - case "<=": result = l <= r; break - case ">": result = l > r; break - case ">=": result = l >= r; break - case "&": result = l & r; break - case "|": result = l | r; break - case "^": result = l ^ r; break - case "<<": result = l << r; break - case ">>": result = l >> r; break - case ">>>": result = l >>> r; break - case "in": - if (rhs === null || typeof rhs !== "object") { - throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node) - } - // Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...). - result = Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey); break - default: throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node) - } - return boundedData(result, "Binary expression result") + return boundedData(self.applyBinaryOperator(operator, lhs, rhs, node), "Binary expression result") }) } + /** + * Applies a binary operator to two already-evaluated operands with CodeMode's coercion + * semantics. Shared by binary expressions and compound assignment (`x op= y` must behave + * exactly like `x = x op y`, coercion included). + */ + private applyBinaryOperator(operator: string, lhs: any, rhs: any, node: AstNode): unknown { + if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) { + throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue") + } + // Data objects/arrays are null-prototype, so JS's ToPrimitive throws an opaque host + // "No default value" TypeError when an operator coerces them. Coerce to their JS string + // form first (as String(x) / template literals do) so operators behave like JavaScript. + // A Date follows its ToPrimitive hints: string for `+` (concatenation), its time value + // for arithmetic and ordering — so `end - start` and `a < b` work as in JS. + // Identity (=== / !==) and the right operand of `in` keep their raw object value. + const coerceOperand = (operand: unknown): unknown => { + if (operand instanceof SandboxDate) return operator === "+" ? coerceToString(operand) : operand.time + return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand + } + const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object" + const l = coerceOperand(lhs) as any + const r = coerceOperand(rhs) as any + switch (operator) { + case "+": return l + r + case "-": return l - r + case "*": return l * r + case "/": return l / r + case "%": return l % r + case "**": return l ** r + // Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces. + case "==": return bothObjects ? lhs === rhs : l == r + case "===": return lhs === rhs + case "!=": return bothObjects ? lhs !== rhs : l != r + case "!==": return lhs !== rhs + case "<": return l < r + case "<=": return l <= r + case ">": return l > r + case ">=": return l >= r + case "&": return l & r + case "|": return l | r + case "^": return l ^ r + case "<<": return l << r + case ">>": return l >> r + case ">>>": return l >>> r + case "in": + if (rhs === null || typeof rhs !== "object") { + throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node) + } + // Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...). + return Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey) + default: throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node) + } + } + private evaluateLogicalExpression(node: AstNode): Effect.Effect { const operator = getString(node, "operator") return Effect.flatMap(this.evaluateExpression(getNode(node, "left")), (left) => { @@ -3104,37 +3114,14 @@ class Interpreter { incoming: unknown, node: AstNode, ): unknown { - const lhs = current as any - const rhs = incoming as any - - switch (operator) { - case "+=": - return lhs + rhs - case "-=": - return lhs - rhs - case "*=": - return lhs * rhs - case "/=": - return lhs / rhs - case "%=": - return lhs % rhs - case "**=": - return lhs ** rhs - case "&=": - return lhs & rhs - case "|=": - return lhs | rhs - case "^=": - return lhs ^ rhs - case "<<=": - return lhs << rhs - case ">>=": - return lhs >> rhs - case ">>>=": - return lhs >>> rhs - default: - throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node) - } + // `x op= y` is `x = x op y`: dispatch through the shared binary operator implementation + // so compound assignment inherits the same coercion semantics (Dates, data objects, ...). + // Only the arithmetic/bitwise operators are compoundable; logical assignments (&&=/||=/??=) + // short-circuit and are handled by evaluateLogicalAssignment before reaching here. + if (!compoundOperators.has(operator)) { + throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node) + } + return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node) } private getMemberReference(node: AstNode): Effect.Effect { diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 64ba85f8ecff..10e54dcb2cb4 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -3,6 +3,7 @@ import { ToolError, toolError } from "./tool-error.js" import { decodeInput as decodeToolInput, decodeOutput as decodeToolOutput, + identifierSegment, inputProperties, inputTypeScript, isDefinition as isToolDefinition, @@ -68,8 +69,6 @@ const reservedNamespace = "$codemode" const defaultMaxInlineCatalogTokens = 2_000 const defaultSearchLimit = 10 const searchSignature = "tools.$codemode.search({ query?: string, namespace?: string, limit?: number }): Promise<{ items: Array<{ path: string; description: string; signature: string }>; total: number }>" -const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/ - const toolExpression = (path: string) => "tools" + path .split(".") diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index c42dc2760973..dce61bd37282 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -62,6 +62,15 @@ const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder & const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown" +/** + * Bare TypeScript identifier — usable unquoted as an object key (and, in the tool runtime, + * with dot access as a tool-path segment). Anything else must be quoted/bracketed. + */ +export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/ + +/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */ +const renderKey = (name: string): string => identifierSegment.test(name) ? name : JSON.stringify(name) + /** * Recursion ceiling for schema rendering. Object, array, and union recursion all increment * depth, so this bounds every recursion path — pathological or structurally cyclic schemas @@ -152,7 +161,7 @@ const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: R const additional = schema.additionalProperties const indexType = additional && typeof additional === "object" ? renderSchema(additional, ctx, depth + 1, seen) : undefined const field = ([name, value]: readonly [string, JsonSchema]) => - `${name}${required.has(name) ? "" : "?"}: ${renderSchema(value, ctx, depth + 1, seen)}` + `${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, ctx, depth + 1, seen)}` if (!ctx.pretty) { const fields = properties.map(field) diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 7ee1579e2794..36b6883cd0f5 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -289,6 +289,76 @@ describe("string methods: localeCompare, normalize, trim aliases", () => { }) }) +describe("compound assignment matches its binary operator", () => { + // `x op= y` must behave exactly like `x = x op y`, sharing the binary operator's coercion + // semantics (Dates string-coerce for `+` and use their time value for arithmetic; data + // objects/arrays coerce to their JS string form). + const pair = async (compound: string, expanded: string) => { + const [a, b] = await Promise.all([value(compound), value(expanded)]) + expect(a).toEqual(b) + return a + } + + test("sandbox Date += concatenates its string form, like d = d + 1", async () => { + const result = await pair( + `let d = new Date(1000); d += 1; return d`, + `let d = new Date(1000); d = d + 1; return d`, + ) + expect(result).toBe("1970-01-01T00:00:01.000Z1") + }) + + test("sandbox Date numeric compound ops use its time value", async () => { + expect(await pair( + `let d = new Date(1000); d -= 400; return d`, + `let d = new Date(1000); d = d - 400; return d`, + )).toBe(600) + expect(await pair( + `let d = new Date(1000); d /= 4; return d`, + `let d = new Date(1000); d = d / 4; return d`, + )).toBe(250) + }) + + test("string += object/array matches x = x + obj", async () => { + expect(await pair( + `let x = "a"; x += { b: 1 }; return x`, + `let x = "a"; x = x + { b: 1 }; return x`, + )).toBe("a[object Object]") + expect(await pair( + `let x = "a"; x += [1, 2]; return x`, + `let x = "a"; x = x + [1, 2]; return x`, + )).toBe("a1,2") + }) + + test("compound assignment through a member target coerces the same way", async () => { + expect(await pair( + `const o = { s: "t" }; o.s += new Date(0); return o.s`, + `const o = { s: "t" }; o.s = o.s + new Date(0); return o.s`, + )).toBe("t1970-01-01T00:00:00.000Z") + }) + + test("numeric and string compound operators sweep identically to their expansions", async () => { + const cases: Array<[string, number | string]> = [ + [`let x = 7; x += 3; return x`, 7 + 3], + [`let x = 7; x -= 3; return x`, 7 - 3], + [`let x = 7; x *= 3; return x`, 7 * 3], + [`let x = 7; x /= 2; return x`, 7 / 2], + [`let x = 7; x %= 3; return x`, 7 % 3], + [`let x = 7; x **= 2; return x`, 7 ** 2], + [`let x = 7; x &= 3; return x`, 7 & 3], + [`let x = 7; x |= 8; return x`, 7 | 8], + [`let x = 7; x ^= 2; return x`, 7 ^ 2], + [`let x = 7; x <<= 2; return x`, 7 << 2], + [`let x = -7; x >>= 1; return x`, -7 >> 1], + [`let x = -7; x >>>= 1; return x`, -7 >>> 1], + [`let x = "a"; x += "b"; return x`, "ab"], + ] + for (const [compound, expected] of cases) { + expect(await value(compound)).toBe(expected) + expect(await value(compound.replace(/x (\S+)= /, (_, op) => `x = x ${op} `))).toBe(expected) + } + }) +}) + describe("H5: builtin coercion functions work as array callbacks", () => { test("filter(Boolean) drops falsy values", async () => { expect(await value(`return [0, 1, "", 2, null, 3].filter(Boolean)`)).toEqual([1, 2, 3]) diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index ccbeae8ee2f4..533b402ce010 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -171,6 +171,66 @@ describe("pretty signature rendering", () => { }) }) +describe("non-identifier property names render as quoted keys", () => { + // MCP-style schemas routinely carry property names that are not bare TS identifiers + // (`foo-bar`, `@type`, dotted names); the rendered signature must quote them so the + // model sees a valid TypeScript object type. Bare identifiers stay unquoted. + const rawSchema = { + type: "object", + properties: { + "foo-bar": { type: "string" }, + "@type": { type: "string" }, + "x.y": { type: "number", description: "Dotted name" }, + "123": { type: "number" }, + plain: { type: "boolean" }, + }, + required: ["@type"], + } as const + + test("compact rendering quotes non-identifier keys and leaves identifiers bare", () => { + expect(jsonSchemaToTypeScript(rawSchema)).toBe( + '{ "123"?: number; "foo-bar"?: string; "@type": string; "x.y"?: number; plain?: boolean }', + ) + }) + + test("pretty rendering quotes non-identifier keys and keeps their JSDoc", () => { + expect(jsonSchemaToTypeScript(rawSchema, true)).toBe( + [ + "{", + ' "123"?: number', + ' "foo-bar"?: string', + ' "@type": string', + " /** Dotted name */", + ' "x.y"?: number', + " plain?: boolean", + "}", + ].join("\n"), + ) + }) + + test("JSON Schema input and output signatures of a tool both quote", () => { + const tool = Tool.make({ + description: "Adapter tool with awkward field names", + input: rawSchema, + output: { type: "object", properties: { "content-type": { type: "string" } }, required: ["content-type"] } as const, + run: () => Effect.succeed({ "content-type": "text/plain" }), + }) + expect(inputTypeScript(tool)).toContain('"foo-bar"?: string') + expect(outputTypeScript(tool)).toBe('{ "content-type": string }') + expect(outputTypeScript(tool, true)).toBe(["{", ' "content-type": string', "}"].join("\n")) + }) + + test("Effect Schema structs with non-identifier field names quote too", () => { + const tool = Tool.make({ + description: "Schema tool with awkward field names", + input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }), + run: () => Effect.succeed(null), + }) + expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }') + expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string', " plain?: number", "}"].join("\n")) + }) +}) + describe("pretty signatures in search results", () => { const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } }) From 1b7f5651cfbce951460238208e0adebcbb85c1c7 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 22:34:20 -0500 Subject: [PATCH 38/41] refactor(opencode): share the MCP invocation middle between direct and code-mode calls The legacy per-MCP registration and code-mode child dispatch each carried their own copy of the invoke sequence, and the code-mode copy silently missed the plugin tool.execute hooks and the tracing span. McpInvoke.invoke (src/mcp/invoke.ts) now owns the shared middle: plugin before hook -> permission ask -> Tool.execute span -> dispatch through the ai-sdk execute -> plugin after hook, returning the raw result. Each caller keeps its shaping edge (model shaping/truncation vs toSandboxResult). The after hook fires with the raw MCP result on both paths - exactly what the legacy loop always passed - so legacy behavior is preserved and the payload cannot drift. Code-mode child calls report the MCP key with a synthetic parentCallID/N call id (opaque; the ai-sdk toolCallId is unchanged), and any failure inside a child call - plugin hook, permission denial, or tool error - fails only that call as a catchable in-program error. Also pins SessionTools.resolve at the real-registry seam: raw per-MCP registration is suppressed behind execute when code mode is on, present when off, and legacy hook firing is covered. --- packages/codemode/codemode.md | 112 +++++++++++- packages/opencode/src/mcp/invoke.ts | 65 +++++++ packages/opencode/src/session/tools.ts | 37 ++-- packages/opencode/src/tool/code-mode.ts | 60 ++++--- packages/opencode/test/session/tools.test.ts | 164 ++++++++++++++++++ .../test/tool/code-mode-integration.test.ts | 5 + packages/opencode/test/tool/code-mode.test.ts | 89 +++++++++- 7 files changed, 482 insertions(+), 50 deletions(-) create mode 100644 packages/opencode/src/mcp/invoke.ts create mode 100644 packages/opencode/test/session/tools.test.ts diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 9c4d991783d8..0c790843d931 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -883,6 +883,72 @@ restructure; fixes the §4 permission-advertising bug): excluded without MCP tools, excluded with flag off, and deny/ask catalog filtering through `registry.tools()`. Suites: 43 + 16 adapter tests, 16 registry tests, all green. +**Shared MCP invocation middle (`McpInvoke.invoke`)** (closes the §4 "plugin hooks skip +child calls" gap): + - `packages/opencode/src/mcp/invoke.ts` extracts the duplicated "invoke an MCP tool" + middle into one shared `McpInvoke.invoke(input)`: plugin `tool.execute.before` hook → + permission ask (`{ permission: key, patterns: ["*"], always: ["*"] }` via the caller's + `ctx.ask`) → dispatch through the ai-sdk tool's execute inside the `Tool.execute` + tracing span (`tool.name`/`tool.call_id`/`session.id`/`message.id` attributes) → + plugin `tool.execute.after` hook. It returns the RAW result the ai-sdk execute + resolved with; each caller keeps its own shaping edge — the legacy per-MCP loop in + `SessionTools.resolve` applies its existing model-facing shaping/truncation, code + mode applies `toSandboxResult`. It lives under `src/mcp/` because both callers + already depend on MCP and the function is about invoking an MCP-backed ai-sdk tool, + not about sessions or code mode. + - **After-hook payload**: fired inside `McpInvoke.invoke` with the raw MCP result — + which is exactly what the legacy loop always passed (the raw `CallToolResult`, not + the shaped `{title, output, metadata}`), so legacy behavior is preserved bit-for-bit + and the hook payload cannot drift between callers. No callback/edge-firing design + was needed. + - **Synthetic child callID**: code-mode child calls pass `${parentCallID}/${n}` as the + hook/span callID (`parentCallID` = the `execute` call's `ctx.callID`, falling back to + the entry key; `n` = per-execution counter starting at 1, shared across all child + calls in one program). callID is an opaque string — nothing parses it. The ai-sdk + `toolCallId` (`options.toolCallId`) stays each caller's existing value + (`ctx.callID ?? entry.key` for code mode). + - **Child-scoped hook failures**: `CodeModeTool` (which now also yields + `Plugin.Service`) wraps the whole child call — hooks, ask, dispatch — in + `toCatchable` (the generalization of the old `askPermission` catchCause), so a plugin + hook failure fails ONLY that child call as a catchable in-program `toolError`; other + calls in the same program keep running and interruption still propagates as + interruption. Legacy semantics unchanged: a hook failure fails the tool call. + - **Tests**: `test/tool/code-mode.test.ts` +2 (child calls fire before/after with the + MCP key and `parent/1`, `parent/2` ids, after hook carries the raw MCP result; a + failing before hook is caught in-program, gates dispatch, and leaves the outer + execute ok) — both code-mode harnesses gained a `Plugin.Service` mock (pass-through + trigger by default, overridable). New `test/session/tools.test.ts` (3 tests) pins + `SessionTools.resolve` at the real-registry seam (LayerNode.compile, fake MCP layer): + flag on + MCP tools → `execute` present, raw MCP keys suppressed; flag off → raw + keys present, `execute` absent; and the legacy raw-MCP execute fires before/after + hooks keyed by the ai-sdk toolCallId with the raw result payload. Suites: adapter + 45 + 16, session/tool/permission all green; this package untouched (211 pass). + +**Signature rendering + compound-assignment parity fixes** (externally reported, both +verified real with failing tests before fixing): + - **Non-identifier property names in rendered signatures** (`src/tool.ts`): `renderSchema` + emitted raw property names, so schema properties like `foo-bar`/`@type`/`x.y`/`123` + rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper — + bare identifiers stay bare, everything else is `JSON.stringify`-quoted — applied in the + single `field` closure both the compact and pretty renderings share. The + `identifierSegment` regex now lives in `tool.ts` (exported) and `tool-runtime.ts`'s + bracket-notation `toolExpression` imports it: one source of truth for "is this a bare + identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact, + pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct). + - **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`): + `applyCompoundAssignment` did raw JS ops on interpreter wrapper objects, so `x += y` + diverged from `x = x + y` (sandbox Date `d += 1` produced `"[object Object]1"`; + `d -= 400` gave `NaN` instead of epoch arithmetic). The operator table + coercion moved + verbatim out of `evaluateBinaryExpression` into a shared `applyBinaryOperator`; + compound assignment validates against a `compoundOperators` set (`+=` … `>>>=`) and + dispatches through it (`operator.slice(0, -1)`). Logical assignments (`&&=`/`||=`/`??=`) + keep their separate short-circuit path (`evaluateLogicalAssignment`), and both + assignment call sites still wrap results in `boundedData`. Deliberate side effect: + compound assignment now rejects opaque references, consistent with binary operators. + Tests: `parity.test.ts` +5 (Date `+=` concat parity, Date `-=`/`/=` epoch parity, + string `+=` object/array, member-target compound, 13-case operator sweep vs real JS). + Package suite: 220 pass. + --- ## 4. Remaining work (detailed TODO) @@ -918,6 +984,42 @@ focused interpreter-surface pass rather than picked off piecemeal. signatures). Result quality is dominated by whether servers declare output schemas; revisit once real usage shows which failure modes matter. +### Next iteration: stdlib surface (prioritized) +Current instructions say "usual Array/String/Object/Math/JSON methods," but the interpreter is +intentionally a subset. Keep CodeMode focused on orchestration and data shaping, not a full host +runtime, but close the high-friction gaps models are likely to reach for. + +- [ ] **P0: tighten wording first** — change instructions/docs to say "common stdlib subset" + until the surface is broader. This avoids misleading the model into assuming every JS + helper exists. +- [ ] **P1: URL parsing helpers** — add `URL` and `URLSearchParams`. These are high-value for + tool orchestration (query strings, ids in URLs, API links), deterministic, and do not add + ambient host authority. +- [ ] **P2: Math completion** — add the missing standard deterministic `Math` methods + (`sin`/`cos`/`tan`, inverse/hyperbolic variants, `atan2`, `log1p`, `expm1`, `imul`, + `fround`, `clz32`, etc.). Decide explicitly on `Math.random`: likely acceptable because + `Date.now()` is already exposed, but document the nondeterminism if enabled. +- [ ] **P3: base64 helpers** — add string-only `atob`/`btoa` equivalents. Useful for API/tool + payload cleanup and does not require opening the broader binary boundary. +- [ ] **P4: small crypto helper** — consider `crypto.randomUUID()` only, not full `crypto`. + UUID generation is a common orchestration need; broader crypto can wait until there is a + concrete use case and a clear capability boundary. +- [ ] **P5: text/binary primitives** — consider `TextEncoder`/`TextDecoder` first, then + `ArrayBuffer`/typed arrays/`DataView`/`Blob`/`File` only with an explicit boundary design + (serialization, size limits, and how values cross tool args/results). This is reasonable + but lower priority than URL/base64 because CodeMode is still plain-data oriented. +- [ ] **P6: date/formatting conveniences** — consider `Date` setters and common formatting + helpers (`toUTCString`, maybe `Intl` later). Lower priority; most orchestration can use + existing getters, `Date.parse`, `Date.UTC`, and ISO strings. +- [ ] **P7: environment/config access** — do not expose raw `process.env` as a global ambient + authority. If this becomes useful, add an explicit host-provided/whitelisted capability + (for example a small env/config tool or injected read-only object) so secrets are not + accidentally exposed to arbitrary CodeMode programs. + +Explicit non-goals for now: `structuredClone`, `WeakMap`/`WeakSet`, and timers +(`setTimeout`/`setInterval`/`queueMicrotask`). They do not materially improve the current tool +orchestration use case. + ### Wiring-review findings (subagent code review of the OpenCode integration, triaged) Pre-PR fixes (user-approved cut): - [x] **Cancellation does not interrupt the interpreter** — the no-limits rationale claimed @@ -964,10 +1066,14 @@ Pre-PR fixes (user-approved cut): `title: "execute"` sites in `code-mode.ts` now reference `CODE_MODE_TOOL`. Post-MVP (logged, not blocking an experimental flag): -- [ ] **Plugin `tool.execute.before/after` hooks skip child calls** — legacy MCP +- [x] **Plugin `tool.execute.before/after` hooks skip child calls** — legacy MCP registration fires them per tool (`tools.ts:419-441`); under code mode only the outer `execute` fires them, so auditing/intercepting plugins silently lose MCP coverage when the flag flips. + DONE (see the "Shared MCP invocation middle" entry in §3): both paths now run + `McpInvoke.invoke` (`src/mcp/invoke.ts`) — hooks AND the `Tool.execute` span fire + for child calls with synthetic `${parentCallID}/${n}` callIDs; hook failures are + child-scoped, catchable in-program errors. - [x] Description/preview rebuilt every assistant turn — `registry.tools()` re-runs `groupByServer` + a throwaway `CodeMode.make(...).instructions()` per turn (`describeCodeMode`). DECIDED as an explicit non-goal: memoizing the catalog @@ -992,6 +1098,10 @@ Post-MVP (logged, not blocking an experimental flag): names that are no longer directly callable under code mode. ### Backlog / loose ends (non-blocking, any order) +- [ ] `evaluateUpdateExpression` (`++`/`--`) still uses raw `Number(current)`, so `d++` on a + sandbox Date yields `NaN` where `d += 1` now uses epoch semantics (and real JS `d++` + would give epoch+0 numeric). Pre-existing, out of scope of the compound-assignment + parity fix; route it through `applyBinaryOperator` if it ever matters. - [ ] Media-only marker could name what it attached when MCP provides names: `image`/`audio` blocks carry no filename (mime + data only) so the generic `[N images attached to the result]` stays, but `resource`/`resource_link` blocks have diff --git a/packages/opencode/src/mcp/invoke.ts b/packages/opencode/src/mcp/invoke.ts new file mode 100644 index 000000000000..52bc5e4f73ad --- /dev/null +++ b/packages/opencode/src/mcp/invoke.ts @@ -0,0 +1,65 @@ +import type { ToolExecutionOptions } from "ai" +import { Effect } from "effect" +import type { Plugin } from "@/plugin" +import type { Tool } from "@/tool/tool" + +/** + * The shared middle of every raw MCP tool invocation: plugin `tool.execute.before` + * hook → permission ask → dispatch through the ai-sdk tool's execute inside the + * `Tool.execute` tracing span → plugin `tool.execute.after` hook. Used by both the + * legacy per-tool registration in `SessionTools.resolve` and code-mode child calls, + * so MCP tools execute identically on either path. + * + * Returns the RAW result the ai-sdk execute resolved with — callers own their + * shaping edge (model-facing text/attachment shaping + truncation on the legacy + * path, `toSandboxResult` for code-mode child calls). The after hook fires here + * with that same raw result, which is exactly what the legacy loop always passed + * (the raw MCP result, not the shaped `{title, output, metadata}`), so the hook + * payload cannot drift between callers. + * + * `callID` is the hook/span identity — an opaque string nothing parses. Legacy + * passes the ai-sdk `toolCallId`; code-mode child calls pass a synthetic + * `${parentCallID}/${n}`. `options.toolCallId` is what the ai-sdk execute sees and + * stays each caller's existing value. Failure semantics belong to the caller: hook + * failures, permission denials, and tool failures all propagate — the legacy path + * lets them fail the tool call as before; code mode converts them into catchable + * in-program tool errors at its edge. + */ +export const invoke = Effect.fn("McpInvoke.invoke")(function* (input: { + plugin: Plugin.Interface + key: string + execute: (args: any, options: ToolExecutionOptions) => R | PromiseLike + args: any + callID: string + options: ToolExecutionOptions + sessionID: string + messageID: string + ask: Tool.Context["ask"] +}) { + yield* input.plugin.trigger( + "tool.execute.before", + { tool: input.key, sessionID: input.sessionID, callID: input.callID }, + { args: input.args }, + ) + const result: R = yield* Effect.gen(function* () { + yield* input.ask({ permission: input.key, metadata: {}, patterns: ["*"], always: ["*"] }) + return yield* Effect.promise(() => Promise.resolve(input.execute(input.args, input.options))) + }).pipe( + Effect.withSpan("Tool.execute", { + attributes: { + "tool.name": input.key, + "tool.call_id": input.callID, + "session.id": input.sessionID, + "message.id": input.messageID, + }, + }), + ) + yield* input.plugin.trigger( + "tool.execute.after", + { tool: input.key, sessionID: input.sessionID, callID: input.callID, args: input.args }, + result, + ) + return result +}) + +export * as McpInvoke from "./invoke" diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 92099a399bbe..52f2d5157270 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -3,6 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { MCP } from "@/mcp" +import { McpInvoke } from "@/mcp/invoke" import { Permission } from "@/permission" import { Tool } from "@/tool/tool" import { ToolJsonSchema } from "@/tool/json-schema" @@ -404,29 +405,19 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { run.promise( Effect.gen(function* () { const ctx = context(args, opts) - yield* plugin.trigger( - "tool.execute.before", - { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, - { args }, - ) - const result: Awaited>> = yield* Effect.gen(function* () { - yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) - return yield* Effect.promise(() => execute(args, opts)) - }).pipe( - Effect.withSpan("Tool.execute", { - attributes: { - "tool.name": key, - "tool.call_id": opts.toolCallId, - "session.id": ctx.sessionID, - "message.id": input.processor.message.id, - }, - }), - ) - yield* plugin.trigger( - "tool.execute.after", - { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, - result, - ) + // Shared MCP middle (before hook → permission ask → Tool.execute span → + // dispatch → after hook); this caller keeps the model-facing shaping edge below. + const result: Awaited>> = yield* McpInvoke.invoke({ + plugin, + key, + execute, + args, + callID: opts.toolCallId, + options: opts, + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + ask: ctx.ask, + }) const textParts: string[] = [] const attachments: Omit[] = [] diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts index ebb573825930..bef135b03a2e 100644 --- a/packages/opencode/src/tool/code-mode.ts +++ b/packages/opencode/src/tool/code-mode.ts @@ -12,9 +12,11 @@ import { } from "@opencode-ai/codemode" import { MCP } from "@/mcp" import { McpCatalog } from "@/mcp/catalog" +import { McpInvoke } from "@/mcp/invoke" import { Agent } from "@/agent/agent" import { Session } from "@/session/session" import { Permission } from "@/permission" +import { Plugin } from "@/plugin" export const CODE_MODE_TOOL = "execute" @@ -279,11 +281,12 @@ function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) = return tree } -/** Per-child permission gate: every MCP call inside a program asks before it runs — - * approving `execute` does not approve any child call. Denials become safe, - * catchable tool failures inside the program. */ -const askPermission = (ctx: Tool.Context, entry: CatalogEntry) => - ctx.ask({ permission: entry.key, metadata: {}, patterns: ["*"], always: ["*"] }).pipe( +/** Failures inside a child call — plugin hook failures, permission denials, and tool + * failures alike — become safe, catchable in-program errors via toolError, so a + * program can try/catch one call without the whole execution dying. Interruption + * (user cancel) keeps propagating as interruption. */ +const toCatchable = (effect: Effect.Effect) => + effect.pipe( Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt const error = Cause.squash(cause) @@ -297,6 +300,7 @@ export const CodeModeTool = Tool.define( const mcp = yield* MCP.Service const agents = yield* Agent.Service const sessions = yield* Session.Service + const plugin = yield* Plugin.Service const init: Tool.DefWithoutID = { description: DESCRIPTION, @@ -332,26 +336,34 @@ export const CodeModeTool = Tool.define( // tool part shows each child call appearing and resolving while the program runs. const publish = () => ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } }) - // One CodeMode tool per MCP tool: gate on permission, dispatch through the - // ai-sdk wrapper (which owns callTool timeouts/progress and turns an MCP - // isError into a thrown Error), and shape the raw result for the sandbox. - // Failures surface as safe, catchable in-program errors via toolError. + // One CodeMode tool per MCP tool, running the same shared middle as legacy + // per-tool registration (McpInvoke.invoke: plugin before hook → permission + // ask → Tool.execute span → dispatch through the ai-sdk wrapper, which owns + // callTool timeouts/progress and turns an MCP isError into a thrown Error → + // plugin after hook), so plugins observe child calls too. Each child gets a + // synthetic hook/span callID `${parentCallID}/${n}` (per-execution counter, + // opaque — nothing parses it); the ai-sdk toolCallId is unchanged. Failures — + // hook, denial, or tool — fail only that child call as a safe, catchable + // in-program error (toCatchable); the raw result is then shaped for the sandbox. + let childCalls = 0 const callTool = (entry: CatalogEntry) => (input: unknown) => - Effect.gen(function* () { - yield* askPermission(ctx, entry) - const raw = yield* Effect.tryPromise({ - try: () => - Promise.resolve( - entry.tool.execute!(input ?? {}, { - toolCallId: ctx.callID ?? entry.key, - abortSignal: ctx.abort, - messages: [], - }), - ), - catch: (error) => toolError(error instanceof Error ? error.message : String(error), error), - }) - return toSandboxResult(raw, collect) - }) + toCatchable( + Effect.gen(function* () { + childCalls += 1 + const raw = yield* McpInvoke.invoke({ + plugin, + key: entry.key, + execute: entry.tool.execute!, + args: input ?? {}, + callID: `${ctx.callID ?? entry.key}/${childCalls}`, + options: { toolCallId: ctx.callID ?? entry.key, abortSignal: ctx.abort, messages: [] }, + sessionID: ctx.sessionID, + messageID: ctx.messageID, + ask: ctx.ask, + }) + return toSandboxResult(raw, collect) + }), + ) const runtime = CodeMode.make({ tools: toolTree(catalog, callTool), diff --git a/packages/opencode/test/session/tools.test.ts b/packages/opencode/test/session/tools.test.ts new file mode 100644 index 000000000000..8c8e439a529f --- /dev/null +++ b/packages/opencode/test/session/tools.test.ts @@ -0,0 +1,164 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionTools } from "@/session/tools" +import { ToolRegistry } from "@/tool/registry" +import { Agent } from "@/agent/agent" +import { Config } from "@/config/config" +import { MCP } from "@/mcp" +import { McpCatalog } from "@/mcp/catalog" +import { Permission } from "@/permission" +import { Plugin } from "@/plugin" +import * as Truncate from "@/tool/truncate" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { InstanceState } from "@/effect/instance-state" +import { MessageID, SessionID } from "@/session/schema" +import { disposeAllInstances } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { TestConfig } from "../fixture/config" +import type { Tool as AITool } from "ai" + +const configLayer = TestConfig.layer({ + directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), +}) + +// Fake MCP.Service built through the same `convertTool` path the real service uses, +// so the raw registration loop dispatches through a genuine ai-sdk execute. The fake +// client answers every call with a small text result. +function fakeMcpLayer(tools: Record) { + const client = { callTool: async () => ({ content: [{ type: "text", text: "ok" }] }) } + const converted: Record = Object.fromEntries( + Object.entries(tools).map(([key, def]) => [ + key, + McpCatalog.convertTool( + { + name: key, + description: def.description, + inputSchema: { type: "object", properties: {} }, + } as any, + client as any, + ), + ]), + ) + return Layer.mock(MCP.Service, { + tools: () => Effect.succeed(converted), + defs: () => Effect.succeed({}), + clients: () => Effect.succeed({ github: { getServerCapabilities: () => undefined } } as any), + }) +} + +const mcpToolsLayer = fakeMcpLayer({ + github_create_issue: { description: "Create an issue" }, + github_list_issues: { description: "List issues" }, +}) + +const root = LayerNode.group([ToolRegistry.node, Agent.node, MCP.node, RuntimeFlags.node]) +const withCodeMode = testEffect( + LayerNode.compile(root, [ + [Config.node, configLayer], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })], + [MCP.node, mcpToolsLayer], + ]), +) +const withFlagOff = testEffect( + LayerNode.compile(root, [ + [Config.node, configLayer], + [RuntimeFlags.node, RuntimeFlags.layer()], + [MCP.node, mcpToolsLayer], + ]), +) + +// Resolve the session tool record with the smallest honest stand-ins for the pieces +// resolve reads: a fabricated model (only providerID/api.id are consulted by the +// schema transform for these fixtures), a pass-through Truncate, an always-allow +// Permission, and an optionally-observing Plugin.trigger. Registry, MCP, and flags +// come from the compiled instance context. +function resolveTools(trigger?: Plugin.Interface["trigger"]) { + return Effect.gen(function* () { + const agents = yield* Agent.Service + const agent = yield* agents.defaultInfo() + return yield* SessionTools.resolve({ + agent, + model: { providerID: "opencode", api: { id: "test" } } as any, + session: { id: SessionID.make("ses_session-tools"), permission: [] } as any, + processor: { + message: { id: MessageID.make("msg_session-tools") } as any, + updateToolCall: () => Effect.succeed(undefined), + completeToolCall: () => Effect.void, + }, + bypassAgentCheck: false, + messages: [], + promptOps: {} as any, + }) + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(Permission.Service, { ask: () => Effect.void }), + Layer.mock(Plugin.Service, { + trigger: + trigger ?? (((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"]), + }), + Layer.mock(Truncate.Service, { + output: (text: string) => Effect.succeed({ content: text, truncated: false as const }), + }), + ), + ), + ) +} + +afterEach(async () => { + await disposeAllInstances() +}) + +describe("session.tools resolve", () => { + withCodeMode.instance("code mode suppresses raw per-MCP registration behind the execute tool", () => + Effect.gen(function* () { + const tools = yield* resolveTools() + const ids = Object.keys(tools) + expect(ids).toContain("execute") + expect(ids).not.toContain("github_create_issue") + expect(ids).not.toContain("github_list_issues") + }), + ) + + withFlagOff.instance("without code mode the raw MCP tools register and execute is absent", () => + Effect.gen(function* () { + const tools = yield* resolveTools() + const ids = Object.keys(tools) + expect(ids).not.toContain("execute") + expect(ids).toContain("github_create_issue") + expect(ids).toContain("github_list_issues") + }), + ) + + withFlagOff.instance("legacy raw MCP execution fires plugin hooks keyed by the ai-sdk toolCallId", () => + Effect.gen(function* () { + const events: { name: string; input: any; output: any }[] = [] + const trigger = ((name: unknown, input: unknown, output: unknown) => + Effect.sync(() => { + events.push({ name: name as string, input, output }) + return output + })) as Plugin.Interface["trigger"] + const tools = yield* resolveTools(trigger) + const raw = tools["github_create_issue"]! + const result = yield* Effect.promise(() => + Promise.resolve( + raw.execute!( + { title: "x" }, + { toolCallId: "call_legacy", abortSignal: new AbortController().signal, messages: [] }, + ), + ), + ) + + expect((result as any).output).toBe("ok") + expect(events.map((e) => [e.name, e.input.tool, e.input.callID])).toEqual([ + ["tool.execute.before", "github_create_issue", "call_legacy"], + ["tool.execute.after", "github_create_issue", "call_legacy"], + ]) + expect(events[0]!.output).toEqual({ args: { title: "x" } }) + // The after hook receives the raw MCP result, before model-facing shaping. + expect(events[1]!.output).toMatchObject({ content: [{ type: "text", text: "ok" }] }) + }), + ) +}) diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts index ec95f9e105b0..6f148413a382 100644 --- a/packages/opencode/test/tool/code-mode-integration.test.ts +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -3,6 +3,7 @@ import { CodeModeTool, catalogInstructions } from "@/tool/code-mode" import { McpCatalog } from "@/mcp/catalog" import { Agent } from "@/agent/agent" import { MCP } from "@/mcp" +import { Plugin } from "@/plugin" import { Session } from "@/session/session" import { Tool } from "@/tool/tool" import * as Truncate from "@/tool/truncate" @@ -107,6 +108,10 @@ async function buildTool() { // supply the (empty) permission rulesets the execute path merges; MCP serves the tools // this real in-memory server listed — the same snapshot shape the live service returns. const layer = Layer.mergeAll( + Layer.mock(Plugin.Service, { + trigger: (((_name: unknown, _input: unknown, output: unknown) => + Effect.succeed(output)) as Plugin.Interface["trigger"]), + }), Layer.mock(Truncate.Service, { output: (text: string) => Effect.succeed({ content: text, truncated: false as const }), }), diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index 60aa0d252fe6..c4ded36904a7 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -15,6 +15,7 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Agent } from "@/agent/agent" import { MCP } from "@/mcp" import { Permission } from "@/permission" +import { Plugin } from "@/plugin" import { Session } from "@/session/session" import { Tool } from "@/tool/tool" import * as Truncate from "@/tool/truncate" @@ -50,14 +51,20 @@ function mcpTool( // Truncate echoes its input so assertions read the exact program output. Agent.get is // consulted by the shared wrapper during truncation AND at execute time for the // permission ruleset that filters the dispatchable tool tree; Session.get supplies the -// (empty) session-level ruleset half of that merge. +// (empty) session-level ruleset half of that merge. Plugin.trigger defaults to the +// pass-through the real service uses when no plugin implements a hook; tests observing +// or failing hooks override it. function harness(input: { mcpTools: Record defs?: Record servers: string[] permission?: PermissionV1.Rule[] + trigger?: Plugin.Interface["trigger"] }) { return Layer.mergeAll( + Layer.mock(Plugin.Service, { + trigger: input.trigger ?? (((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"]), + }), Layer.mock(Truncate.Service, { output: (text: string) => Effect.succeed({ content: text, truncated: false as const }), }), @@ -86,12 +93,13 @@ function build( defs: Record = {}, servers?: string[], permission?: PermissionV1.Rule[], + trigger?: Plugin.Interface["trigger"], ) { const names = serverNames(mcpTools, servers) return Effect.runPromise( CodeModeTool.pipe( Effect.flatMap(Tool.init), - Effect.provide(harness({ mcpTools, defs, servers: names, permission })), + Effect.provide(harness({ mcpTools, defs, servers: names, permission, trigger })), ), ) } @@ -411,6 +419,83 @@ describe("code mode execute", () => { expect(output.metadata.toolCalls).toEqual([{ tool: "a.tool", status: "error" }]) }) + test("child calls fire plugin tool.execute hooks with the MCP key and synthetic parent/N call ids", async () => { + const events: { name: string; input: any; output: any }[] = [] + const trigger = ((name: unknown, input: unknown, output: unknown) => + Effect.sync(() => { + events.push({ name: name as string, input, output }) + return output + })) as Plugin.Interface["trigger"] + const tool = await build( + { + a_tool: mcpTool("a", () => ({ content: [{ type: "text", text: "one" }] })), + b_tool: mcpTool("b", () => ({ content: [{ type: "text", text: "two" }] })), + }, + {}, + undefined, + undefined, + trigger, + ) + + const out = await Effect.runPromise( + tool.execute({ code: "await tools.a.tool({ x: 1 }); await tools.b.tool({}); return 'done'" }, ctx), + ) + + expect(out.output).toBe("done") + // callID is synthetic and per-execution: `${parentCallID}/${n}`, n starting at 1. + expect(events.map((e) => [e.name, e.input.tool, e.input.callID])).toEqual([ + ["tool.execute.before", "a_tool", "call_code_mode/1"], + ["tool.execute.after", "a_tool", "call_code_mode/1"], + ["tool.execute.before", "b_tool", "call_code_mode/2"], + ["tool.execute.after", "b_tool", "call_code_mode/2"], + ]) + const [before, after] = events + expect(before!.input.sessionID).toBe(ctx.sessionID) + expect(before!.output).toEqual({ args: { x: 1 } }) + expect(after!.input.args).toEqual({ x: 1 }) + // The after hook sees the raw MCP result — the same payload the legacy path passes. + expect(after!.output).toEqual({ content: [{ type: "text", text: "one" }] }) + }) + + test("a failing before hook fails only that child call as a catchable in-program error", async () => { + const trigger = ((name: unknown, input: any, output: unknown) => { + if (name === "tool.execute.before" && input.tool === "a_tool") return Effect.die(new Error("hook exploded")) + return Effect.succeed(output) + }) as Plugin.Interface["trigger"] + const called: string[] = [] + const record = (name: string) => () => { + called.push(name) + return { content: [{ type: "text", text: "ok" }] } + } + const tool = await build( + { a_tool: mcpTool("a", record("a")), b_tool: mcpTool("b", record("b")) }, + {}, + undefined, + undefined, + trigger, + ) + + const out = await Effect.runPromise( + tool.execute( + { + code: ` + let caught + try { await tools.a.tool({}) } catch (e) { caught = e.message } + const r = await tools.b.tool({}) + return caught + " / " + r + `, + }, + ctx, + ), + ) + + // The program handled the hook failure; the rest ran and the outer result is ok. + expect(out.metadata.error).toBeUndefined() + expect(out.output).toBe("hook exploded / ok") + // The before hook gates dispatch: the failed child's tool never executed. + expect(called).toEqual(["b"]) + }) + test("streams live per-call metadata as a call starts and finishes", async () => { const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record }[] }> = [] const recordingCtx: Tool.Context = { From 8ca700fbc704816712d47d3a395d3718b69ed58b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 22:44:52 -0500 Subject: [PATCH 39/41] fix(opencode): isolate the code-mode integration suite from bun module mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test/mcp/lifecycle.test.ts replaces @modelcontextprotocol/sdk/client via bun's mock.module, which is process-global and irreversible — in a full bun test run (CI) any later import of the SDK Client gets the mock, whose listTools returns a canned test_tool. The code-mode integration suite imported that Client, so in CI its catalog rendered the mock's tool and every real fixture call failed with unknown-tool. The suite now speaks raw JSON-RPC over the in-memory transport with a minimal client of its own (initialize handshake, tools/list, tools/call), keeping the real-server/real-protocol property while being immune to module-mock contamination. Full-package bun test now passes (3124 pass / 0 fail). --- .../test/tool/code-mode-integration.test.ts | 62 +++++++++++++++++-- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts index 6f148413a382..c2cec6c66848 100644 --- a/packages/opencode/test/tool/code-mode-integration.test.ts +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -10,9 +10,10 @@ import * as Truncate from "@/tool/truncate" import { MessageID, SessionID } from "@/session/schema" import { Server } from "@modelcontextprotocol/sdk/server/index.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" -import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import type { Client } from "@modelcontextprotocol/sdk/client/index.js" import { CallToolRequestSchema, + LATEST_PROTOCOL_VERSION, ListToolsRequestSchema, type Tool as MCPToolDef, } from "@modelcontextprotocol/sdk/types.js" @@ -36,6 +37,59 @@ const ctx: Tool.Context = { ask: () => Effect.void, } +/** + * A minimal JSON-RPC MCP client speaking the real protocol over the in-memory transport. + * Deliberately NOT the SDK `Client`: `test/mcp/lifecycle.test.ts` (and friends) replace + * `@modelcontextprotocol/sdk/client/index.js` via bun's `mock.module`, which is + * process-global and irreversible — in a full `bun test` run (CI) any later import of the + * SDK Client gets the mock, whose `listTools` returns a canned `test_tool`. Speaking raw + * JSON-RPC keeps this suite's "real server, real transport, real protocol" property while + * being immune to that contamination. Only the surface `convertTool`/this file use is + * implemented: connect handshake, tools/list, tools/call. + */ +class RawJsonRpcClient { + private nextId = 1 + private pending = new Map void; reject: (error: Error) => void }>() + + constructor(private transport: InMemoryTransport) {} + + async connect() { + this.transport.onmessage = (message) => { + const msg = message as { id?: number; result?: unknown; error?: { message: string } } + if (msg.id === undefined) return // notifications/requests from the server are not needed here + const entry = this.pending.get(msg.id) + if (!entry) return + this.pending.delete(msg.id) + if (msg.error) entry.reject(new Error(msg.error.message)) + else entry.resolve(msg.result) + } + await this.transport.start() + await this.request("initialize", { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }) + await this.transport.send({ jsonrpc: "2.0", method: "notifications/initialized" }) + } + + private request(method: string, params: unknown): Promise { + const id = this.nextId++ + const result = new Promise((resolve, reject) => this.pending.set(id, { resolve, reject })) + void this.transport.send({ jsonrpc: "2.0", id, method, params } as never) + return result + } + + listTools() { + return this.request("tools/list", {}) + } + + /** The `convertTool` surface: schema/options (timeouts, progress) are SDK-client + * concerns and are ignored here — the server never sees them. */ + callTool(params: { name: string; arguments?: Record }, _schema?: unknown, _options?: unknown) { + return this.request("tools/call", params) + } +} + // A real MCP server, exposed over an in-memory transport, with a representative mix // of tools: plain text, structured data (with an outputSchema), an image, and a // failing tool. Tools are defined with raw JSON Schema so outputSchema is exact. @@ -92,8 +146,8 @@ async function buildTool() { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() await server.connect(serverTransport) - const client = new Client({ name: "test-client", version: "1.0.0" }) - await client.connect(clientTransport) + const client = new RawJsonRpcClient(clientTransport) + await client.connect() const listed = (await client.listTools()).tools as MCPToolDef[] const mcpTools: Record = {} @@ -101,7 +155,7 @@ async function buildTool() { for (const def of listed) { const key = McpCatalog.toolName(SERVER, def.name) mcpDefs[key] = def - mcpTools[key] = McpCatalog.convertTool(def, client) + mcpTools[key] = McpCatalog.convertTool(def, client as unknown as Client) } // Truncate echoes its input so assertions read the exact program output; Agent/Session From dca428c3128aa298e5d574e93b4d7ec8f278f38a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 22:50:08 -0500 Subject: [PATCH 40/41] fix(ci): pin node-gyp so native install scripts stop resolving node-gyp@latest Installs began failing on every cache-miss bun install: the tree-sitter-powershell install script has no local node-gyp, so bun's lifecycle shim runs bunx node-gyp@latest, which resolved to last night's node-gyp 13.0.1 release - incompatible with the runners' Node 20.20 (its bundled undici calls webidl.util.markAsUncloneable, which does not exist there). Pinning node-gyp 12.4.0 at the workspace root puts a known-good version on the lifecycle PATH ahead of the bunx fallback. --- bun.lock | 41 ++++++++++++++++++++++++++--------------- package.json | 1 + 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/bun.lock b/bun.lock index abf8f592890c..9fa962d4b182 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "@typescript/native-preview": "catalog:", "glob": "13.0.5", "husky": "9.1.7", + "node-gyp": "12.4.0", "oxlint": "1.60.0", "oxlint-tsgolint": "0.21.0", "prettier": "3.6.2", @@ -3599,7 +3600,7 @@ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], @@ -4521,7 +4522,7 @@ "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], - "node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="], + "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], @@ -5371,7 +5372,7 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="], + "undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], @@ -5573,7 +5574,7 @@ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], @@ -5621,8 +5622,6 @@ "@actions/github/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], - "@actions/http-client/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="], - "@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="], @@ -5875,6 +5874,8 @@ "@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "@effect/platform-node/undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -5885,12 +5886,16 @@ "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + "@electron/get/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="], "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], + "@electron/rebuild/node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="], + "@electron/universal/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], "@electron/universal/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -5941,6 +5946,8 @@ "@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + "@npmcli/run-script/node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="], + "@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.10", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w=="], "@octokit/auth-app/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], @@ -6215,6 +6222,8 @@ "conf/dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], + "conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + "config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], "crc/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], @@ -6353,9 +6362,7 @@ "nitro/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="], - "node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - - "node-gyp/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="], + "node-gyp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -6459,8 +6466,6 @@ "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], - "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], @@ -6947,8 +6952,6 @@ "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -7079,6 +7082,10 @@ "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "motion/framer-motion/motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], "motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], @@ -7091,6 +7098,8 @@ "opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="], + "openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -7359,6 +7368,8 @@ "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], @@ -7381,8 +7392,6 @@ "editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "electron-builder-squirrel-windows/app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - "electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "electron-builder-squirrel-windows/app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -7491,6 +7500,8 @@ "electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "electron-builder-squirrel-windows/app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "electron-builder/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "electron-builder/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/package.json b/package.json index b9622583a76e..15f3b7cb6fe7 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "@typescript/native-preview": "catalog:", "glob": "13.0.5", "husky": "9.1.7", + "node-gyp": "12.4.0", "oxlint": "1.60.0", "oxlint-tsgolint": "0.21.0", "prettier": "3.6.2", From 16f3c8ee8d118f089185bc044bf3c9ad83430979 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 2 Jul 2026 23:02:54 -0500 Subject: [PATCH 41/41] fix(codemode): align code-mode catalog filtering and schema union rendering Address review findings from PR #34677: - ToolRegistry.tools now accepts optional session/request permissions and SessionTools.resolve passes the session ruleset through. The code-mode catalog is rendered with the same merged agent+session visibility rules used at execute time, so session-level hard-denied MCP tools are not advertised while ask-level tools stay visible. - The JSON Schema renderer no longer collapses every anyOf/oneOf that contains a number branch to just number. It only collapses Effect's number-schema sentinel artifact (number plus NaN/Infinity string enums), while real JSON Schema unions like string | number and number | null render all alternatives. Adds regression coverage for both cases. --- packages/codemode/codemode.md | 15 ++++++--- packages/codemode/src/tool.ts | 13 +++++++- packages/codemode/test/signature.test.ts | 34 ++++++++++++++++++++ packages/opencode/src/session/tools.ts | 1 + packages/opencode/src/tool/registry.ts | 8 +++-- packages/opencode/test/tool/registry.test.ts | 20 ++++++++++++ 6 files changed, 83 insertions(+), 8 deletions(-) diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index 0c790843d931..d824681f9279 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -859,10 +859,11 @@ restructure; fixes the §4 permission-advertising bug): (a record filter over `Permission.disabled` — only a hard `deny` with pattern `"*"` hides a tool; ask-level rules stay fully visible and prompt at call time) and `resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters - with the agent's ruleset before building the catalog/search index; `execute` rebuilds - the runtime per execution from a fresh, filtered `mcp.tools()` snapshot using the merged - agent+session ruleset (`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, the same - merge `SessionTools.context` wires into `ctx.ask`) — a denied tool is not dispatchable + with the merged agent+session ruleset that `SessionTools.resolve` passes into the + registry before building the catalog/search index; `execute` rebuilds the runtime per + execution from a fresh, filtered `mcp.tools()` snapshot using the same merged ruleset + (`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, matching the merge + `SessionTools.context` wires into `ctx.ask`) — a denied tool is not dispatchable even if the model guesses its name and yields the normal unknown-tool diagnostic. Documented gap (out of scope by design): per-message `user.tools[key] === false` arrives at request-prep after descriptions are built and has no child-call equivalent. @@ -935,6 +936,12 @@ verified real with failing tests before fixing): bracket-notation `toolExpression` imports it: one source of truth for "is this a bare identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact, pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct). + - **Numeric schema unions keep their real alternatives** (`src/tool.ts`): the old + `anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just + `number`, dropping real JSON Schema alternatives (`string | number`, `number | null`, + etc.). The collapse is now restricted to Effect's number-schema artifact + (`number | "NaN" | "Infinity" | "-Infinity"`, emitted as single-value string enums), + while raw JSON Schema unions render every branch. Tests: `signature.test.ts` +3. - **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`): `applyCompoundAssignment` did raw JS ops on interpreter wrapper objects, so `x += y` diverged from `x = x + y` (sandbox Date `d += 1` produced `"[object Object]1"`; diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index dce61bd37282..95d9f75c8b6f 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -71,6 +71,10 @@ export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/ /** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */ const renderKey = (name: string): string => identifierSegment.test(name) ? name : JSON.stringify(name) +const effectNumberSentinel = (schema: JsonSchema) => + schema.type === "string" && Array.isArray(schema.enum) && schema.enum.length === 1 && + (schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity") + /** * Recursion ceiling for schema rendering. Object, array, and union recursion all increment * depth, so this bounds every recursion path — pathological or structurally cyclic schemas @@ -135,7 +139,14 @@ const renderSchema = (schema: JsonSchema, ctx: RenderContext, depth = 0, seen: R if (schema.enum) return schema.enum.map(renderLiteral).join(" | ") const alternatives = schema.anyOf ?? schema.oneOf if (alternatives) { - if (alternatives.some((item) => item.type === "number")) return "number" + // Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" }, + // { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact; + // real JSON Schema unions such as `string | number` or `number | null` must keep + // every branch. + if ( + alternatives.some((item) => item.type === "number") && + alternatives.every((item) => item.type === "number" || effectNumberSentinel(item)) + ) return "number" // An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]` // (no properties/items); render the bare shape as {} instead of `{} | Array`. if ( diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index 533b402ce010..252070a069bd 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -231,6 +231,40 @@ describe("non-identifier property names render as quoted keys", () => { }) }) +describe("union schemas render every alternative", () => { + test("anyOf with a number branch keeps sibling alternatives", () => { + const schema = { + anyOf: [{ type: "string" }, { type: "number" }], + } as const + expect(jsonSchemaToTypeScript(schema)).toBe("string | number") + expect(jsonSchemaToTypeScript(schema, true)).toBe("string | number") + }) + + test("nullable numeric unions keep null", () => { + const schema = { + oneOf: [{ type: "number" }, { type: "null" }], + } as const + expect(jsonSchemaToTypeScript(schema)).toBe("number | null") + expect(jsonSchemaToTypeScript(schema, true)).toBe("number | null") + }) + + test("tool input and output signatures preserve numeric unions", () => { + const tool = Tool.make({ + description: "Tool with numeric unions", + input: { + type: "object", + properties: { + value: { anyOf: [{ type: "string" }, { type: "number" }] }, + }, + } as const, + output: { anyOf: [{ type: "number" }, { type: "boolean" }] } as const, + run: () => Effect.succeed(1), + }) + expect(inputTypeScript(tool)).toBe("{ value?: string | number }") + expect(outputTypeScript(tool)).toBe("number | boolean") + }) +}) + describe("pretty signatures in search results", () => { const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } }) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 52f2d5157270..535cbb2f3d05 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -99,6 +99,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { modelID: ModelV2.ID.make(input.model.api.id), providerID: input.model.providerID, agent: input.agent, + permission: input.session.permission, }) for (const item of registryTools) { diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 1f60b80a181d..98224a6d1a44 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -54,6 +54,7 @@ import { BackgroundJob } from "@/background/job" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import type { PermissionV1 } from "@opencode-ai/core/v1/permission" export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) { return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel @@ -77,6 +78,7 @@ export interface Interface { providerID: ProviderV2.ID modelID: ModelV2.ID agent: Agent.Info + permission?: PermissionV1.Ruleset }) => Effect.Effect } @@ -274,8 +276,8 @@ const layer = Layer.effect( // fresh per turn so it tracks live tool-list changes. Hard-denied tools (the shared // Permission.visibleTools predicate over the agent's ruleset) never enter the // catalog, its inlined signatures, or the in-program search index. - const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* (agent: Agent.Info) { - const visible = Permission.visibleTools(yield* mcp.tools(), agent.permission) + const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* (agent: Agent.Info, permission?: PermissionV1.Ruleset) { + const visible = Permission.visibleTools(yield* mcp.tools(), Permission.merge(agent.permission, permission ?? [])) const servers = Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize) return catalogInstructions(visible, yield* mcp.defs(), servers) }) @@ -316,7 +318,7 @@ const layer = Layer.effect( description: [ output.description, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined, - tool.id === CodeModeTool.id ? yield* describeCodeMode(input.agent) : undefined, + tool.id === CodeModeTool.id ? yield* describeCodeMode(input.agent, input.permission) : undefined, ] .filter(Boolean) .join("\n"), diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 389e2d4a4e8a..526576bfe6ec 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -590,4 +590,24 @@ describe("tool.registry code mode", () => { expect(execute.description).toContain("- github (1 tool)") }), ) + + withCodeMode.instance("session-level hard-denied MCP tools are dropped from the code-mode catalog", () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agents = yield* Agent.Service + const tools = yield* registry.tools({ + ...model, + agent: yield* agents.defaultInfo(), + permission: [ + { permission: "github_create_issue", pattern: "*" as const, action: "deny" as const }, + { permission: "github_list_issues", pattern: "*" as const, action: "ask" as const }, + ], + }) + const execute = tools.find((tool) => tool.id === "execute") + if (!execute) throw new Error("code-mode execute tool was not registered") + expect(execute.description).not.toContain("create_issue") + expect(execute.description).toContain("tools.github.list_issues(") + expect(execute.description).toContain("- github (1 tool)") + }), + ) })