429 lines
19 KiB
TypeScript
429 lines
19 KiB
TypeScript
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 SandboxTool,
|
|
toolError,
|
|
type ExecuteResult,
|
|
type JsonSchema,
|
|
type ToolDefinition,
|
|
} 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"
|
|
|
|
// OpenCode sets NO execution limits: no timeout, no tool-call cap, and no CodeMode output
|
|
// 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.
|
|
|
|
// 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.",
|
|
"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(" "),
|
|
}),
|
|
})
|
|
|
|
/** 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"; input?: Record<string, unknown> }
|
|
|
|
type Metadata = {
|
|
toolCalls: CallEntry[]
|
|
error?: boolean
|
|
}
|
|
|
|
/**
|
|
* 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<Tool.ExecuteResult["attachments"]>[number]
|
|
|
|
/** 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
|
|
inputSchema: JsonSchema
|
|
outputSchema?: JsonSchema
|
|
}
|
|
|
|
/** Render-only cast: MCP definitions carry JSON Schema documents already. */
|
|
const toJsonSchema = (schema: unknown): JsonSchema => schema as JsonSchema
|
|
|
|
/** 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: {} }
|
|
}
|
|
|
|
/**
|
|
* 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 original
|
|
* `inputSchema`/`outputSchema` for signature rendering.
|
|
*/
|
|
export function groupByServer(
|
|
mcpTools: Record<string, AITool>,
|
|
servers: readonly string[],
|
|
mcpDefs: Record<string, MCPToolDef> = {},
|
|
): 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 def = mcpDefs[key]
|
|
const entry: CatalogEntry = {
|
|
path: `${server}.${local}`,
|
|
key,
|
|
server,
|
|
local,
|
|
description: mcpTools[key]!.description ?? def?.description ?? "",
|
|
tool: mcpTools[key]!,
|
|
inputSchema: def?.inputSchema ? toJsonSchema(def.inputSchema) : fallbackInputSchema(mcpTools[key]!),
|
|
...(def?.outputSchema ? { outputSchema: toJsonSchema(def.outputSchema) } : {}),
|
|
}
|
|
groups.set(server, [...(groups.get(server) ?? []), entry])
|
|
}
|
|
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<string, AITool>,
|
|
mcpDefs: Record<string, MCPToolDef>,
|
|
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<string, AITool>,
|
|
mcpDefs: Record<string, MCPToolDef>,
|
|
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<string, unknown> | undefined {
|
|
if (input === null || input === undefined) return
|
|
if (typeof input === "object" && !Array.isArray(input)) {
|
|
const value = input as Record<string, unknown>
|
|
if (Object.keys(value).length > 0) return value
|
|
return
|
|
}
|
|
return { input }
|
|
}
|
|
|
|
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}`
|
|
|
|
/** 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 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 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<string, unknown>
|
|
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") {
|
|
push({ type: "file", mime: block.mimeType, url: dataUrl(block.mimeType, block.data) })
|
|
}
|
|
break
|
|
case "resource": {
|
|
const res = block.resource as Record<string, unknown> | 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") {
|
|
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") {
|
|
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
|
|
}
|
|
}
|
|
|
|
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 — 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> = []): string {
|
|
if (logs.length === 0) return output
|
|
const section = "Logs:\n" + logs.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
|
|
if (value === undefined) return "undefined"
|
|
try {
|
|
return JSON.stringify(value, null, 2) ?? String(value)
|
|
} catch {
|
|
return String(value)
|
|
}
|
|
}
|
|
|
|
type Run = (input: unknown) => Effect.Effect<unknown, unknown>
|
|
|
|
/** Build the `tools.<server>.<tool>` 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<string, Record<string, ToolDefinition>> = {}
|
|
for (const entry of catalog) {
|
|
const namespace = (tree[entry.server] ??= {})
|
|
namespace[entry.local] = SandboxTool.make({
|
|
description: entry.description,
|
|
input: entry.inputSchema,
|
|
output: entry.outputSchema,
|
|
run: run(entry),
|
|
})
|
|
}
|
|
return tree
|
|
}
|
|
|
|
/** 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 = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
effect.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 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) {
|
|
// 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: CODE_MODE_TOOL,
|
|
metadata: { toolCalls: [], error: true },
|
|
output: "Execution cancelled.",
|
|
} satisfies Tool.ExecuteResult<Metadata>
|
|
}
|
|
// 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).
|
|
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 = () => ctx.metadata({ title: CODE_MODE_TOOL, metadata: { toolCalls: calls.map((c) => ({ ...c })) } })
|
|
|
|
// 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) =>
|
|
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),
|
|
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()
|
|
}),
|
|
})
|
|
|
|
// 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<ExecuteResult>((resume) => {
|
|
const onAbort = () =>
|
|
resume(
|
|
Effect.succeed<ExecuteResult>({
|
|
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 } : {}
|
|
|
|
if (result.ok) {
|
|
return {
|
|
title: CODE_MODE_TOOL,
|
|
metadata: { toolCalls: calls },
|
|
output: withLogs(formatValue(result.value), logs),
|
|
...attached,
|
|
} satisfies Tool.ExecuteResult<Metadata>
|
|
}
|
|
// 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: CODE_MODE_TOOL,
|
|
metadata: { toolCalls: calls, error: true },
|
|
output: withLogs([result.error.message, ...hints].join("\n"), logs),
|
|
...attached,
|
|
} satisfies Tool.ExecuteResult<Metadata>
|
|
}),
|
|
}
|
|
return init
|
|
}),
|
|
)
|