Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,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:*",
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,11 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<st
)
}

export function visibleTools<T>(tools: Record<string, T>, ruleset: PermissionV1.Ruleset): Record<string, T> {
const hidden = disabled(Object.keys(tools), ruleset)
return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name)))
}

export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] })

export * as Permission from "."
322 changes: 322 additions & 0 deletions packages/opencode/src/tool/code-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,322 @@
import * as Tool from "./tool"
import { CallToolResultSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Schema } from "effect"
import {
CodeMode,
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"
import { Plugin } from "@/plugin"

export const CODE_MODE_TOOL = "execute"

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.",
"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(" "),
}),
})

type CallEntry = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }

type Metadata = {
toolCalls: CallEntry[]
error?: boolean
}

type Attachment = NonNullable<Tool.ExecuteResult["attachments"]>[number]

type CatalogEntry = {
path: string
key: string
server: string
local: string
tool: MCP.McpTool
}

function groupByServer(mcpTools: Record<string, MCP.McpTool>, servers: readonly string[]): Map<string, CatalogEntry[]> {
const byLongest = [...servers].sort((a, b) => b.length - a.length)
const groups = new Map<string, CatalogEntry[]>()
for (const key of Object.keys(mcpTools).sort((a, b) => a.localeCompare(b))) {
const server =
byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key)
const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key
const entry: CatalogEntry = {
path: `${server}.${local}`,
key,
server,
local,
tool: mcpTools[key]!,
}
groups.set(server, [...(groups.get(server) ?? []), entry])
}
return groups
}

export function describeCatalog(mcpTools: Record<string, MCP.McpTool>, servers: readonly string[]): string {
return CodeMode.make({
tools: toolTree(
[...groupByServer(mcpTools, servers).values()].flat(),
() => () => Effect.fail(toolError("Tool preview is not executable.")),
),
}).instructions()
}

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}`

function projectMcpResult(result: CallToolResult, collect: (attachment: Attachment) => void): unknown {
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 block of result.content) {
switch (block.type) {
case "text":
text.push(block.text)
break
case "image":
case "audio":
push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
break
case "resource": {
if ("text" in block.resource) {
text.push(block.resource.text)
break
}
const mime = block.resource.mimeType ?? "application/octet-stream"
push({ type: "file", mime, url: dataUrl(mime, block.resource.blob), filename: lastSegment(block.resource.uri) })
break
}
case "resource_link":
// A link is a reference, not fetchable media; hand it to the program instead of the attachment channel.
text.push(`${block.name}: ${block.uri}`)
break
}
}

if (result.structuredContent !== undefined && result.structuredContent !== null) return result.structuredContent
if (text.length > 0) return text.join("\n")
if (files > 0) {
const noun = files === images ? "image" : "file"
return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]`
}
return null
}

type Run = (input: unknown) => Effect.Effect<unknown, unknown>

function toolTree(catalog: readonly CatalogEntry[], run: (entry: CatalogEntry) => Run) {
const tree: Record<string, Record<string, ToolDefinition>> = {}
for (const entry of catalog) {
const namespace = (tree[entry.server] ??= {})
namespace[entry.local] = SandboxTool.make({
description: entry.tool.def.description ?? "",
input: entry.tool.def.inputSchema as JsonSchema,
output: entry.tool.def.outputSchema as JsonSchema | undefined,
run: run(entry),
})
}
return tree
}

const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: {
plugin: Plugin.Interface
entry: CatalogEntry
args: Record<string, unknown>
callID: string
ctx: Tool.Context
}) {
yield* input.plugin.trigger(
"tool.execute.before",
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID },
{ args: input.args },
)
const result: CallToolResult = yield* Effect.gen(function* () {
yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
// Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns.
return yield* Effect.promise(async () => {
const raw = await input.entry.tool.client.callTool(
{ name: input.entry.tool.def.name, arguments: input.args },
CallToolResultSchema,
{
resetTimeoutOnProgress: true,
signal: input.ctx.abort,
timeout: input.entry.tool.timeout,
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
onprogress: () => {},
},
)
if (raw.isError)
throw new Error(
raw.content
.flatMap((item) => (item.type === "text" ? [item.text] : []))
.filter((text) => text.trim())
.join("\n\n") || "MCP tool returned an error",
)
return raw
})
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
"tool.name": input.entry.key,
"tool.call_id": input.callID,
"session.id": input.ctx.sessionID,
"message.id": input.ctx.messageID,
},
}),
)
yield* input.plugin.trigger(
"tool.execute.after",
{ tool: input.entry.key, sessionID: input.ctx.sessionID, callID: input.callID, args: input.args },
result,
)
return result
})

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
const plugin = yield* Plugin.Service

const init: Tool.DefWithoutID<typeof Parameters, Metadata> = {
description: DESCRIPTION,
parameters: Parameters,
execute: Effect.fn("CodeMode.execute")(function* (params, ctx) {
if (ctx.abort.aborted) {
return {
title: CODE_MODE_TOOL,
metadata: { toolCalls: [], error: true },
output: "Execution cancelled.",
} satisfies Tool.ExecuteResult<Metadata>
}
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 = [...groupByServer(mcpTools, servers).values()].flat()

const calls: CallEntry[] = []
const attachments: Attachment[] = []
const publish = () =>
ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } })

let childCalls = 0
const callTool = (entry: CatalogEntry) => (input: unknown) =>
Effect.gen(function* () {
childCalls += 1
const result = yield* invokeChildTool({
plugin,
entry,
args: (input ?? {}) as Record<string, unknown>,
callID: `${ctx.callID ?? entry.key}/${childCalls}`,
ctx,
})
return projectMcpResult(result, (attachment: Attachment) => void attachments.push(attachment))
}).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))
}),
)

const runtime = CodeMode.make({
tools: toolTree(catalog, callTool),
onToolCallStart: ({ index, name, input }) =>
Effect.suspend(() => {
const shown = (() => {
if (input === null || input === undefined) return
if (typeof input === "object" && !Array.isArray(input)) {
const value = input as Record<string, unknown>
return Object.keys(value).length > 0 ? value : undefined
}
return { 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 abort = Effect.callback<void>((resume) => {
if (ctx.abort.aborted) return resume(Effect.void)
const handler = () => resume(Effect.void)
ctx.abort.addEventListener("abort", handler, { once: true })
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
})
const cancelled = (): ExecuteResult => ({
ok: false,
error: { kind: "ExecutionFailure", message: "Execution cancelled." },
toolCalls: calls.map((call) => ({ name: call.tool })),
})

const result = yield* Effect.raceFirst(runtime.execute(params.code), abort.pipe(Effect.map(cancelled)))
const logs = result.logs ?? []
const attached = attachments.length > 0 ? { attachments } : {}
const hints = result.ok
? []
: (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))
const metadata: Metadata = result.ok ? { toolCalls: calls } : { toolCalls: calls, error: true }
let output: string
if (result.ok) {
if (typeof result.value === "string") output = result.value
else if (result.value === undefined) output = "undefined"
else {
try {
output = JSON.stringify(result.value, null, 2) ?? String(result.value)
} catch {
output = String(result.value)
}
}
} else {
output = [result.error.message, ...hints].join("\n")
}
if (logs.length > 0)
output = output.length > 0 ? `${output}\n\nLogs:\n${logs.join("\n")}` : `Logs:\n${logs.join("\n")}`

return {
title: CODE_MODE_TOOL,
metadata,
output,
...attached,
} satisfies Tool.ExecuteResult<Metadata>
}),
}
return init
}),
)
Loading
Loading