revert: back out experimental codemode (#35077)
This commit is contained in:
@@ -43,7 +43,6 @@ 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"),
|
||||
|
||||
@@ -159,12 +159,6 @@ export interface Interface {
|
||||
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
|
||||
readonly instructions: () => Effect.Effect<ServerInstructions[]>
|
||||
readonly tools: () => Effect.Effect<Record<string, Tool>>
|
||||
/**
|
||||
* 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<Record<string, MCPToolDef>>
|
||||
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
|
||||
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
|
||||
readonly resourceTemplates: (
|
||||
@@ -686,18 +680,6 @@ const layer = Layer.effect(
|
||||
return result
|
||||
})
|
||||
|
||||
const defs = Effect.fn("MCP.defs")(function* () {
|
||||
const result: Record<string, MCPToolDef> = {}
|
||||
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<T extends { name: string }>(
|
||||
s: State,
|
||||
listFn: (c: Client, timeout?: number) => Promise<T[]>,
|
||||
@@ -1000,7 +982,6 @@ const layer = Layer.effect(
|
||||
clients,
|
||||
instructions,
|
||||
tools,
|
||||
defs,
|
||||
prompts,
|
||||
resources,
|
||||
resourceTemplates,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
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* <R>(input: {
|
||||
plugin: Plugin.Interface
|
||||
key: string
|
||||
execute: (args: any, options: ToolExecutionOptions) => R | PromiseLike<R>
|
||||
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"
|
||||
@@ -213,18 +213,6 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<st
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared tool-visibility predicate: drop every tool a hard deny hides
|
||||
* ({@link disabled} semantics — a matching `deny` rule with pattern `"*"`).
|
||||
* Ask-level rules leave a tool fully visible and callable (it prompts at call
|
||||
* time). Used both when preparing the LLM tool list (request prep) and when
|
||||
* building/dispatching the code-mode MCP catalog, so the two cannot drift.
|
||||
*/
|
||||
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 "."
|
||||
|
||||
@@ -206,8 +206,11 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
|
||||
})
|
||||
|
||||
function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
|
||||
const visible = Permission.visibleTools(input.tools, Permission.merge(input.agent.permission, input.permission ?? []))
|
||||
return Record.filter(visible, (_, k) => input.user.tools?.[k] !== false)
|
||||
const disabled = Permission.disabled(
|
||||
Object.keys(input.tools),
|
||||
Permission.merge(input.agent.permission, input.permission ?? []),
|
||||
)
|
||||
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
|
||||
}
|
||||
|
||||
export function hasToolCalls(messages: ModelMessage[]): boolean {
|
||||
|
||||
@@ -1237,8 +1237,6 @@ 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") {
|
||||
|
||||
@@ -3,7 +3,6 @@ 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"
|
||||
@@ -22,7 +21,6 @@ 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"
|
||||
|
||||
const MCP_RESOURCE_TOOLS = {
|
||||
list: "list_mcp_resources",
|
||||
@@ -54,7 +52,6 @@ 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,
|
||||
@@ -89,20 +86,11 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
const mcpTools = yield* mcp.tools()
|
||||
// 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({
|
||||
for (const item of yield* registry.tools({
|
||||
modelID: ModelV2.ID.make(input.model.api.id),
|
||||
providerID: input.model.providerID,
|
||||
agent: input.agent,
|
||||
permission: input.session.permission,
|
||||
})
|
||||
|
||||
for (const item of registryTools) {
|
||||
})) {
|
||||
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
||||
tools[item.id] = tool({
|
||||
description: item.description,
|
||||
@@ -393,9 +381,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
})
|
||||
}
|
||||
|
||||
if (codeMode) return tools
|
||||
|
||||
for (const [key, item] of Object.entries(mcpTools)) {
|
||||
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
|
||||
@@ -406,19 +392,29 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
run.promise(
|
||||
Effect.gen(function* () {
|
||||
const ctx = context(args, opts)
|
||||
// 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<ReturnType<NonNullable<typeof execute>>> = yield* McpInvoke.invoke({
|
||||
plugin,
|
||||
key,
|
||||
execute,
|
||||
args,
|
||||
callID: opts.toolCallId,
|
||||
options: opts,
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: input.processor.message.id,
|
||||
ask: ctx.ask,
|
||||
})
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||
{ args },
|
||||
)
|
||||
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = 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,
|
||||
)
|
||||
|
||||
const textParts: string[] = []
|
||||
const attachments: Omit<SessionV1.FilePart, "id" | "sessionID" | "messageID">[] = []
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
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
|
||||
}),
|
||||
)
|
||||
@@ -26,9 +26,6 @@ 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"
|
||||
@@ -54,7 +51,6 @@ 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
|
||||
@@ -78,7 +74,6 @@ export interface Interface {
|
||||
providerID: ProviderV2.ID
|
||||
modelID: ModelV2.ID
|
||||
agent: Agent.Info
|
||||
permission?: PermissionV1.Ruleset
|
||||
}) => Effect.Effect<Tool.Def[]>
|
||||
}
|
||||
|
||||
@@ -92,7 +87,6 @@ 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
|
||||
@@ -110,7 +104,6 @@ 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<State>(
|
||||
@@ -218,7 +211,6 @@ const layer = Layer.effect(
|
||||
question: Tool.init(question),
|
||||
lsp: Tool.init(lsptool),
|
||||
plan: Tool.init(plan),
|
||||
codemode: Tool.init(codemode),
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -240,7 +232,6 @@ 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,
|
||||
@@ -272,28 +263,11 @@ 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,
|
||||
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)
|
||||
})
|
||||
|
||||
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")
|
||||
@@ -318,11 +292,7 @@ const layer = Layer.effect(
|
||||
: undefined
|
||||
return {
|
||||
id: tool.id,
|
||||
description: [
|
||||
output.description,
|
||||
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
|
||||
tool.id === CodeModeTool.id ? yield* describeCodeMode(input.agent, input.permission) : undefined,
|
||||
]
|
||||
description: [output.description, tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
parameters: output.parameters,
|
||||
@@ -436,7 +406,6 @@ export const node = LayerNode.make({
|
||||
LSP.node,
|
||||
Instruction.node,
|
||||
FSUtil.node,
|
||||
MCP.node,
|
||||
EventV2Bridge.node,
|
||||
httpClient,
|
||||
CrossSpawnSpawner.node,
|
||||
|
||||
Reference in New Issue
Block a user