Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
49f20b6
feat(opencode): add experimental code mode execute tool
rekram1-node Jun 29, 2026
b0aa6bf
feat(opencode): namespace code mode tools by MCP server
rekram1-node Jun 29, 2026
cad83ab
feat(opencode): progressive tool discovery for code mode
rekram1-node Jun 29, 2026
caa5f28
refactor(opencode): simplify code mode namespace listing
rekram1-node Jun 29, 2026
3a6621c
chore(opencode): vendor rune interpreter for code mode
rekram1-node Jun 30, 2026
14527d2
feat(opencode): code mode result+attachments envelope and typed describe
rekram1-node Jun 30, 2026
71ffc73
feat(opencode): run code mode on the vendored rune interpreter
rekram1-node Jun 30, 2026
1448f24
feat(opencode): budgeted tool preview in code mode description
rekram1-node Jun 30, 2026
acc1742
test(opencode): end-to-end code mode test over a real MCP server
rekram1-node Jun 30, 2026
ba12049
feat(opencode): tokenized, ranked tool search in code mode
rekram1-node Jun 30, 2026
a3bfba8
feat(opencode): unify code mode discovery under tools.$rune
rekram1-node Jun 30, 2026
cbcc67b
refactor(opencode): single source of discovery docs, clearer attachme…
rekram1-node Jun 30, 2026
79275e6
fix(opencode): describe attachments as routable values, not opaque
rekram1-node Jun 30, 2026
394c370
docs(opencode): add rune.md (how it works, what's missing)
rekram1-node Jun 30, 2026
55aa8cc
feat(opencode): inline budgeted call signatures in code mode preview
rekram1-node Jun 30, 2026
dc75ea0
feat(opencode): state whether the code mode tool list is complete or …
rekram1-node Jun 30, 2026
bc427b1
feat: live code-mode execute UI in the TUI
rekram1-node Jun 30, 2026
2726a74
fix(tui): normalize execute tool styling
rekram1-node Jun 30, 2026
05b9346
fix(tui): surface execute child call details
rekram1-node Jul 1, 2026
5085a13
feat(opencode): render code-mode types as TypeScript, not JSON Schema
rekram1-node Jul 1, 2026
c16bba8
feat(opencode): JSDoc tags, Result<T> return hints, and opaque attach…
rekram1-node Jul 1, 2026
2d9015c
fix(tui): simplify execute running state
rekram1-node Jul 1, 2026
064c34b
feat(opencode): capture console output and surface it to the model
rekram1-node Jul 1, 2026
cc437b9
fix(opencode): make Rune tolerate idiomatic defensive JavaScript
rekram1-node Jul 1, 2026
e06a099
fix(opencode): let NaN/Infinity flow in Rune, normalize to null at th…
rekram1-node Jul 1, 2026
51ea0ac
feat(codemode): add @opencode-ai/codemode confined execution package
rekram1-node Jul 2, 2026
90b4af6
feat(opencode): rebuild code mode as an MCP adapter over @opencode-ai…
rekram1-node Jul 2, 2026
2a13900
feat(codemode): simplify limits, enrich search, condense instructions
rekram1-node Jul 2, 2026
cafcec4
feat(opencode): run code mode without execution limits
rekram1-node Jul 2, 2026
560fc7f
feat(codemode): expand JS parity and make output truncation opt-in
rekram1-node Jul 3, 2026
27fbf7c
feat(opencode): rely on native tool-output truncation for code mode
rekram1-node Jul 3, 2026
231144f
docs(codemode): fix stale claims, log wiring-review findings
rekram1-node Jul 3, 2026
9a6fdc4
fix(opencode): interrupt code mode execution on cancel
rekram1-node Jul 3, 2026
9be249a
refactor(opencode): align code mode module with session conventions
rekram1-node Jul 3, 2026
8f330de
feat(codemode): render bracket notation for non-identifier tool names
rekram1-node Jul 3, 2026
9431ba3
refactor(opencode): promote code mode to a registry tool service
rekram1-node Jul 3, 2026
3b3c6d6
Merge remote-tracking branch 'origin/dev' into codemode-v2
rekram1-node Jul 3, 2026
fedae57
fix(codemode): quote non-identifier keys in signatures; unify compoun…
rekram1-node Jul 3, 2026
1b7f565
refactor(opencode): share the MCP invocation middle between direct an…
rekram1-node Jul 3, 2026
8ca700f
fix(opencode): isolate the code-mode integration suite from bun modul…
rekram1-node Jul 3, 2026
dca428c
fix(ci): pin node-gyp so native install scripts stop resolving node-g…
rekram1-node Jul 3, 2026
16f3c8e
fix(codemode): align code-mode catalog filtering and schema union ren…
rekram1-node Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(opencode): add experimental code mode execute tool
Add an experimental, off-by-default `execute` tool that runs LLM-authored
JavaScript with a `tools.<name>(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.
  • Loading branch information
rekram1-node committed Jun 29, 2026
commit 49f20b6a30b7baa0b428adeb1d59428b108b6813
1 change: 1 addition & 0 deletions packages/opencode/src/effect/runtime-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class Service extends ConfigService.Service<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"),
Expand Down
143 changes: 143 additions & 0 deletions packages/opencode/src/session/code-mode.ts
Original file line number Diff line number Diff line change
@@ -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.<name>(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<unknown>
}

function describe(mcpTools: Record<string, AITool>) {
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.<name>(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<string, AITool>) {
return Tool.define(
CODE_MODE_TOOL,
Effect.succeed<Tool.DefWithoutID<typeof Parameters, Metadata>>({
description: describe(mcpTools),
parameters: Parameters,
execute: Effect.fn("CodeMode.execute")(function* (params, ctx) {
const run = yield* EffectBridge.make()
const calls: string[] = []

// Each `tools.<name>(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<string, unknown>, {
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<Metadata>,
),
Effect.catch((error) =>
Effect.succeed({
title: "Code mode",
metadata: { toolCalls: calls, error: true },
output: errorMessage(error),
} satisfies Tool.ExecuteResult<Metadata>),
),
)
}),
}),
)
}
2 changes: 2 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
21 changes: 18 additions & 3 deletions packages/opencode/src/session/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
sessionID: input.session.id,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
175 changes: 175 additions & 0 deletions packages/opencode/test/session/code-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => unknown): AITool {
const client = {
callTool: async (params: { arguments?: Record<string, unknown> }) => 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<string, AITool>) {
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<string, unknown>[] = []
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")
})
})