chore: merge dev into v2 (#35591)
Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Jay <air@live.ca> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
This commit is contained in:
@@ -132,6 +132,8 @@ export const TuiThreadCommand = cmd({
|
||||
const config = await TuiConfig.get()
|
||||
|
||||
const network = resolveNetworkOptionsNoConfig(args)
|
||||
const external = hasArg("--port") || hasArg("--hostname") || network.mdns === true
|
||||
const headers = external ? ServerAuth.headers() : undefined
|
||||
const url = (await client.call("server", network)).url
|
||||
|
||||
try {
|
||||
@@ -139,6 +141,7 @@ export const TuiThreadCommand = cmd({
|
||||
url,
|
||||
sessionID: args.session,
|
||||
directory: cwd,
|
||||
headers,
|
||||
})
|
||||
} catch (error) {
|
||||
UI.error(errorMessage(error))
|
||||
@@ -154,10 +157,10 @@ export const TuiThreadCommand = cmd({
|
||||
const { Effect } = await import("effect")
|
||||
const { run } = await import("../tui/layer")
|
||||
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
|
||||
await Effect.runPromise(
|
||||
await Effect.runPromise(
|
||||
run({
|
||||
client: createOpencodeClient({ baseUrl: url, directory: cwd }),
|
||||
api: OpenCode.make({ baseUrl: url }),
|
||||
client: createOpencodeClient({ baseUrl: url, headers, directory: cwd }),
|
||||
api: OpenCode.make({ baseUrl: url, headers }),
|
||||
async onSnapshot() {
|
||||
const tui = writeHeapSnapshot("tui.heapsnapshot")
|
||||
const server = await client.call("snapshot", undefined)
|
||||
|
||||
@@ -45,6 +45,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
||||
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
||||
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
|
||||
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
|
||||
experimentalCodeMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"),
|
||||
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import open from "open"
|
||||
|
||||
export interface Interface {
|
||||
readonly open: (url: string) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/McpBrowser") {}
|
||||
|
||||
const layer = Layer.succeed(
|
||||
Service,
|
||||
Service.of({
|
||||
open: Effect.fn("McpBrowser.open")(function* (url: string) {
|
||||
const subprocess = yield* Effect.tryPromise({
|
||||
try: () => open(url),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
yield* Effect.callback<void, Error>((resume) => {
|
||||
const timer = setTimeout(() => resume(Effect.void), 500)
|
||||
subprocess.on("error", (error) => {
|
||||
clearTimeout(timer)
|
||||
resume(Effect.fail(error))
|
||||
})
|
||||
subprocess.on("exit", (code) => {
|
||||
if (code === null || code === 0) return
|
||||
clearTimeout(timer)
|
||||
resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`)))
|
||||
})
|
||||
})
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = LayerNode.make({ service: Service, layer, deps: [] })
|
||||
|
||||
export * as McpBrowser from "./browser"
|
||||
@@ -1,7 +1,6 @@
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { type Tool } from "ai"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
@@ -27,7 +26,6 @@ import { McpOAuthCallback } from "./oauth-callback"
|
||||
import { McpAuth } from "./auth"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { TuiEvent } from "@/server/tui-event"
|
||||
import open from "open"
|
||||
import { Cause, Effect, Exit, Layer, Context, Schema, Stream } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
@@ -35,6 +33,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { McpCatalog } from "./catalog"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { McpBrowser } from "./browser"
|
||||
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
const CLIENT_OPTIONS = {
|
||||
@@ -154,11 +153,19 @@ export interface ServerInstructions {
|
||||
tools: string[]
|
||||
}
|
||||
|
||||
/** An MCP tool in its native shape; consumers adapt it to their own tool format. */
|
||||
export interface McpTool {
|
||||
/** Shared cached definition; consumers must copy rather than mutate it. */
|
||||
readonly def: MCPToolDef
|
||||
readonly client: MCPClient
|
||||
readonly timeout?: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly status: () => Effect.Effect<Record<string, Status>>
|
||||
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
|
||||
readonly instructions: () => Effect.Effect<ServerInstructions[]>
|
||||
readonly tools: () => Effect.Effect<Record<string, Tool>>
|
||||
readonly tools: () => Effect.Effect<Record<string, McpTool>>
|
||||
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
|
||||
readonly resources: (clientName?: string) => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
|
||||
readonly resourceTemplates: (
|
||||
@@ -200,6 +207,7 @@ const layer = Layer.effect(
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const auth = yield* McpAuth.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const browser = yield* McpBrowser.Service
|
||||
|
||||
type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport
|
||||
|
||||
@@ -656,7 +664,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
const tools = Effect.fn("MCP.tools")(function* () {
|
||||
const result: Record<string, Tool> = {}
|
||||
const result: Record<string, McpTool> = {}
|
||||
const s = yield* InstanceState.get(state)
|
||||
|
||||
const cfg = yield* cfgSvc.get()
|
||||
@@ -672,9 +680,8 @@ const layer = Layer.effect(
|
||||
continue
|
||||
}
|
||||
const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout)
|
||||
for (const mcpTool of listed) {
|
||||
const key = McpCatalog.toolName(clientName, mcpTool.name)
|
||||
result[key] = McpCatalog.convertTool(mcpTool, client, timeout)
|
||||
for (const def of listed) {
|
||||
result[McpCatalog.toolName(clientName, def.name)] = { def, client, timeout }
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -891,22 +898,7 @@ const layer = Layer.effect(
|
||||
const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName)
|
||||
onAuthorization?.(result.authorizationUrl)
|
||||
|
||||
yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe(
|
||||
Effect.flatMap((subprocess) =>
|
||||
Effect.callback<void, Error>((resume) => {
|
||||
const timer = setTimeout(() => resume(Effect.void), 500)
|
||||
subprocess.on("error", (err) => {
|
||||
clearTimeout(timer)
|
||||
resume(Effect.fail(err))
|
||||
})
|
||||
subprocess.on("exit", (code) => {
|
||||
if (code !== null && code !== 0) {
|
||||
clearTimeout(timer)
|
||||
resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`)))
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
yield* browser.open(result.authorizationUrl).pipe(
|
||||
Effect.catch(() => {
|
||||
return events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore)
|
||||
}),
|
||||
@@ -1006,7 +998,7 @@ export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node],
|
||||
deps: [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node, McpBrowser.node],
|
||||
})
|
||||
|
||||
export * as MCP from "."
|
||||
|
||||
@@ -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 "."
|
||||
|
||||
@@ -72,6 +72,10 @@ type SelectableItem = Item & {
|
||||
}
|
||||
}
|
||||
}
|
||||
type CopilotEndpoint = "chat" | "responses" | "messages"
|
||||
type CopilotModel = Omit<Model, "api"> & {
|
||||
api: Model["api"] & { endpoint?: CopilotEndpoint }
|
||||
}
|
||||
const decodeModels = Schema.decodeUnknownSync(schema)
|
||||
const decodeItem = Schema.decodeUnknownOption(item)
|
||||
|
||||
@@ -86,17 +90,25 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model):
|
||||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
||||
|
||||
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
|
||||
const endpoint: CopilotEndpoint | undefined = isMsgApi
|
||||
? "messages"
|
||||
: remote.supported_endpoints?.includes("/responses")
|
||||
? "responses"
|
||||
: remote.supported_endpoints?.includes("/chat/completions")
|
||||
? "chat"
|
||||
: undefined
|
||||
const prices = remote.billing?.token_prices
|
||||
// Copilot prices are AIC per billing batch; OpenCode stores USD per million tokens.
|
||||
const usdPerMillion = prices ? 10_000 / prices.batch_size : 0
|
||||
|
||||
const model: Model = {
|
||||
const model: CopilotModel = {
|
||||
id: key,
|
||||
providerID: "github-copilot",
|
||||
api: {
|
||||
id: remote.id,
|
||||
url: isMsgApi ? `${url}/v1` : url,
|
||||
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
|
||||
...(endpoint ? { endpoint } : {}),
|
||||
},
|
||||
// API response wins
|
||||
status: "active",
|
||||
|
||||
@@ -218,8 +218,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
"github-copilot": () =>
|
||||
Effect.succeed({
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>, model?: Model) {
|
||||
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
|
||||
if (model && "endpoint" in model.api) {
|
||||
if (model.api.endpoint === "responses" && sdk.responses) return sdk.responses(modelID)
|
||||
if (model.api.endpoint === "chat" && sdk.chat) return sdk.chat(modelID)
|
||||
}
|
||||
const match = /^gpt-(\d+)/.exec(modelID)
|
||||
if (match && Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")) return sdk.responses(modelID)
|
||||
return sdk.chat(modelID)
|
||||
|
||||
@@ -1240,9 +1240,6 @@ export function smallOptions(model: Provider.Model) {
|
||||
return mergeDeep(base, small)
|
||||
}
|
||||
if (model.providerID === "openrouter" || model.providerID === "llmgateway") {
|
||||
if (model.providerID === "openrouter" && small.reasoning?.effort === "low") {
|
||||
return { reasoning: { effort: "none" } }
|
||||
}
|
||||
if (Object.keys(small).length === 0 && model.api.id.includes("google")) {
|
||||
return { reasoning: { enabled: false } }
|
||||
}
|
||||
|
||||
@@ -137,8 +137,9 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
||||
|
||||
const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
|
||||
const limit = ctx.query.limit ?? 100
|
||||
const directory = ctx.query.directory ? yield* InstanceState.directory : undefined
|
||||
const all = yield* sessions.listGlobal({
|
||||
directory: ctx.query.directory,
|
||||
directory,
|
||||
roots: ctx.query.roots,
|
||||
start: ctx.query.start,
|
||||
cursor: ctx.query.cursor,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { MessageID, PartID, SessionID } from "@/session/schema"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Cause, Effect, Option, Schema, Scope } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
@@ -61,8 +62,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
|
||||
const directory = ctx.query.directory ? yield* InstanceState.directory : undefined
|
||||
return yield* session.list({
|
||||
directory: ctx.query.scope === "project" ? undefined : ctx.query.directory,
|
||||
directory: ctx.query.scope === "project" ? undefined : directory,
|
||||
scope: ctx.query.scope,
|
||||
path: ctx.query.path,
|
||||
roots: ctx.query.roots,
|
||||
|
||||
@@ -192,6 +192,8 @@ const layer = Layer.effect(
|
||||
status: "error",
|
||||
input: match.part.state.input,
|
||||
error: errorMessage(error),
|
||||
// Keep metadata streamed while running so failures retain progress detail (e.g. execute's child calls).
|
||||
metadata: match.part.state.metadata,
|
||||
time: { start: match.part.state.time.start, end: Date.now() },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1237,6 +1237,7 @@ const layer = Layer.effect(
|
||||
Effect.provideService(ToolRegistry.Service, registry),
|
||||
Effect.provideService(MCP.Service, mcp),
|
||||
Effect.provideService(Truncate.Service, truncate),
|
||||
Effect.provideService(RuntimeFlags.Service, flags),
|
||||
)
|
||||
|
||||
if (lastUser.format?.type === "json_schema") {
|
||||
|
||||
@@ -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 { McpCatalog } from "@/mcp/catalog"
|
||||
import { Permission } from "@/permission"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
@@ -21,6 +22,7 @@ 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",
|
||||
@@ -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,
|
||||
@@ -90,6 +93,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,
|
||||
})) {
|
||||
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
||||
tools[item.id] = tool({
|
||||
@@ -381,7 +385,10 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
})
|
||||
}
|
||||
|
||||
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
||||
if (flags.experimentalCodeMode) return tools
|
||||
|
||||
for (const [key, entry] of Object.entries(yield* mcp.tools())) {
|
||||
const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout)
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
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 } 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 = "Run a confined orchestration script with access to connected MCP tools."
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
code: Schema.String.annotate({
|
||||
description: "Script body executed by the confined interpreter.",
|
||||
}),
|
||||
})
|
||||
|
||||
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, SandboxTool.Definition>> = {}
|
||||
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 SandboxTool.JsonSchema,
|
||||
output: entry.tool.def.outputSchema as SandboxTool.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 = (): CodeMode.Result => ({
|
||||
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 withLogs = (text: string) => {
|
||||
if (logs.length === 0) return text
|
||||
return text.length > 0 ? `${text}\n\nLogs:\n${logs.join("\n")}` : `Logs:\n${logs.join("\n")}`
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
if (ctx.abort.aborted) {
|
||||
return {
|
||||
title: CODE_MODE_TOOL,
|
||||
metadata: { toolCalls: calls, error: true },
|
||||
output: "Execution cancelled.",
|
||||
} satisfies Tool.ExecuteResult<Metadata>
|
||||
}
|
||||
const hints = (result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))
|
||||
return yield* Effect.fail(new Error(withLogs([result.error.message, ...hints].join("\n"))))
|
||||
}
|
||||
|
||||
// The interpreter validates returned values as plain JSON, so stringify cannot throw;
|
||||
// it yields undefined only for a program that returns undefined.
|
||||
const output =
|
||||
typeof result.value === "string"
|
||||
? result.value
|
||||
: (JSON.stringify(result.value, null, 2) ?? String(result.value))
|
||||
|
||||
return {
|
||||
title: CODE_MODE_TOOL,
|
||||
metadata: { toolCalls: calls },
|
||||
output: withLogs(output),
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
} satisfies Tool.ExecuteResult<Metadata>
|
||||
}, Effect.orDie),
|
||||
}
|
||||
return init
|
||||
}),
|
||||
)
|
||||
@@ -51,6 +51,9 @@ import { Job } from "@/job"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { MCP } from "@/mcp"
|
||||
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
|
||||
export function webSearchEnabled(providerID: ProviderV2.ID, flags = { exa: false, parallel: false }) {
|
||||
return providerID === ProviderV2.ID.opencode || flags.exa || flags.parallel
|
||||
@@ -74,6 +77,7 @@ export interface Interface {
|
||||
providerID: ProviderV2.ID
|
||||
modelID: ModelV2.ID
|
||||
agent: Agent.Info
|
||||
permission?: PermissionV1.Ruleset
|
||||
}) => Effect.Effect<Tool.Def[]>
|
||||
}
|
||||
|
||||
@@ -87,6 +91,7 @@ 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 +110,8 @@ const layer = Layer.effect(
|
||||
const patchtool = yield* ApplyPatchTool
|
||||
const skilltool = yield* SkillTool
|
||||
const agent = yield* Agent.Service
|
||||
const codeMode = flags.experimentalCodeMode ? yield* Effect.promise(() => import("./code-mode")) : undefined
|
||||
const codeModeTool = codeMode ? yield* codeMode.CodeModeTool : undefined
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||
@@ -211,6 +218,7 @@ const layer = Layer.effect(
|
||||
question: Tool.init(question),
|
||||
lsp: Tool.init(lsptool),
|
||||
plan: Tool.init(plan),
|
||||
...(codeModeTool ? { execute: Tool.init(codeModeTool) } : {}),
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -230,6 +238,7 @@ const layer = Layer.effect(
|
||||
tool.search,
|
||||
tool.skill,
|
||||
tool.patch,
|
||||
...(tool.execute ? [tool.execute] : []),
|
||||
...(flags.experimentalLspTool ? [tool.lsp] : []),
|
||||
...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),
|
||||
],
|
||||
@@ -263,6 +272,17 @@ const layer = Layer.effect(
|
||||
return ["Available agent types and the tools they have access to:", description].join("\n")
|
||||
})
|
||||
|
||||
const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* (input: {
|
||||
agent: Agent.Info
|
||||
permission?: PermissionV1.Ruleset
|
||||
}) {
|
||||
if (!codeMode) return
|
||||
const ruleset = Permission.merge(input.agent.permission, input.permission ?? [])
|
||||
const tools = Permission.visibleTools(yield* mcp.tools(), ruleset)
|
||||
if (Object.keys(tools).length === 0) return
|
||||
return codeMode.describeCatalog(tools, Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize))
|
||||
})
|
||||
|
||||
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
|
||||
const filtered = (yield* all()).filter((tool) => {
|
||||
if (tool.id === WebSearchTool.id) {
|
||||
@@ -277,8 +297,13 @@ const layer = Layer.effect(
|
||||
return true
|
||||
})
|
||||
|
||||
const codeModeDescription = filtered.some((tool) => tool.id === "execute")
|
||||
? yield* describeCodeMode(input)
|
||||
: undefined
|
||||
const visible = filtered.filter((tool) => tool.id !== "execute" || codeModeDescription)
|
||||
|
||||
return yield* Effect.forEach(
|
||||
filtered,
|
||||
visible,
|
||||
Effect.fnUntraced(function* (tool: Tool.Def) {
|
||||
const output = {
|
||||
description: tool.description,
|
||||
@@ -292,7 +317,11 @@ 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 === "execute" ? codeModeDescription : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
parameters: output.parameters,
|
||||
@@ -412,6 +441,7 @@ export const node = LayerNode.make({
|
||||
Format.node,
|
||||
Truncate.node,
|
||||
RuntimeFlags.node,
|
||||
MCP.node,
|
||||
Database.node,
|
||||
Ripgrep.node,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user