Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce351802d9 | |||
| d6013dc281 | |||
| c7fe65df3e | |||
| 0be055a336 | |||
| 1bf70d83fd | |||
| 9c9fcc2968 | |||
| 9924437035 | |||
| 0da23bb4f0 | |||
| 319f14952f | |||
| 1218c4cfd9 | |||
| 57ed778c51 | |||
| 947d11a675 | |||
| bd7af5f557 |
@@ -322,6 +322,7 @@
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.1",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"@ff-labs/fff-bun": "0.9.4",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.1",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Config } from "effect"
|
||||
|
||||
export function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
return value === "true" || value === "1"
|
||||
@@ -43,6 +41,9 @@ export const Flag = {
|
||||
|
||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
get OPENCODE_CODE_MODE() {
|
||||
return process.env["OPENCODE_CODE_MODE"] === undefined ? true : truthy("OPENCODE_CODE_MODE")
|
||||
},
|
||||
|
||||
// Evaluated at access time (not module load) because tests, the CLI, and
|
||||
// external tooling set these env vars at runtime.
|
||||
|
||||
@@ -8,7 +8,7 @@ This folder owns Core's one local tool representation, process and Location regi
|
||||
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
|
||||
- `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding.
|
||||
|
||||
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
|
||||
Do not add a second executable entry type, registration store, authorization callback, output-path callback, or legacy normalization path.
|
||||
|
||||
## Construction
|
||||
|
||||
@@ -28,7 +28,7 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
|
||||
|
||||
## Registration
|
||||
|
||||
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`.
|
||||
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Core producers may attach an internal `execute` path as registration metadata; this changes model projection only and does not duplicate the canonical registration.
|
||||
|
||||
Registrations are scoped:
|
||||
|
||||
@@ -46,11 +46,11 @@ Definition filtering is catalog visibility, not execution authorization. A call
|
||||
|
||||
## Output
|
||||
|
||||
Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths.
|
||||
Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the execution and generic model-output bounding boundary and owns managed retention paths. Calls nested under `execute` re-enter the same registry settlement path so hooks and leaf authorization remain intact.
|
||||
|
||||
Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`.
|
||||
|
||||
## Current Gaps
|
||||
|
||||
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
|
||||
- Future Session-scoped registrations still need an explicit canonical registration design.
|
||||
- The public Session result shape currently exposes managed `outputPaths`; full storage encapsulation requires a future opaque managed-output reference design.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
export * as ExecuteTool from "./execute"
|
||||
|
||||
import { CodeMode, Tool, toolError, type ToolDefinition } from "@opencode-ai/codemode"
|
||||
import { type ToolOutput, type ToolResultValue } from "@opencode-ai/llm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { make, type Context } from "./tool"
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
code: Schema.String.annotate({ description: "Code to execute using the available tools" }),
|
||||
})
|
||||
|
||||
const Call = Schema.Struct({
|
||||
tool: Schema.String,
|
||||
status: Schema.Literals(["running", "completed", "error"]),
|
||||
input: Schema.Unknown.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const Attachment = Schema.Struct({
|
||||
data: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
output: Schema.String,
|
||||
toolCalls: Schema.Array(Call),
|
||||
error: Schema.Literal(true).pipe(Schema.optional),
|
||||
attachments: Schema.Array(Attachment),
|
||||
})
|
||||
|
||||
export interface Invocation {
|
||||
readonly result: ToolResultValue
|
||||
readonly output?: ToolOutput
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
readonly description: string
|
||||
readonly input: ToolDefinition["input"]
|
||||
readonly output: ToolDefinition["output"]
|
||||
readonly invoke: (input: unknown, callID: string, context: Context) => Effect.Effect<Invocation, unknown>
|
||||
}
|
||||
|
||||
export type Items = Readonly<Record<string, Readonly<Record<string, Item>>>>
|
||||
type ExecuteCall = typeof Call.Type
|
||||
|
||||
export const makeTool = (items: Items) => {
|
||||
const createRuntime = (calls: ExecuteCall[], attachments: Array<typeof Attachment.Type>, context?: Context) => {
|
||||
const tools: Record<string, Record<string, ToolDefinition>> = Object.create(null)
|
||||
let child = 0
|
||||
for (const [namespace, members] of Object.entries(items)) {
|
||||
tools[namespace] = Object.create(null)
|
||||
for (const [member, item] of Object.entries(members)) {
|
||||
tools[namespace][member] = Tool.make({
|
||||
description: item.description,
|
||||
input: item.input,
|
||||
output: item.output,
|
||||
run: (input) => {
|
||||
if (!context) return Effect.die(new Error("Execute tool context is unavailable"))
|
||||
const callID = `${context.toolCallID}/${child++}`
|
||||
return item.invoke(input, callID, context).pipe(
|
||||
Effect.mapError((error) => toolError("Tool execution failed", error)),
|
||||
Effect.flatMap((invocation) => {
|
||||
if (invocation.result.type === "error") return Effect.fail(toolError(invocation.result.value))
|
||||
if (!invocation.output) return Effect.succeed(invocation.result.value)
|
||||
attachments.push(
|
||||
...invocation.output.content.flatMap((part) => {
|
||||
if (part.type !== "file") return []
|
||||
const prefix = `data:${part.mime};base64,`
|
||||
if (!part.uri.startsWith(prefix)) return []
|
||||
return [{ data: part.uri.slice(prefix.length), mime: part.mime, name: part.name }]
|
||||
}),
|
||||
)
|
||||
return Effect.succeed(invocation.output.structured)
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return CodeMode.make({
|
||||
tools,
|
||||
onToolCallStart: (call) =>
|
||||
Effect.sync(() => {
|
||||
calls[call.index] = { tool: call.name, status: "running", input: call.input }
|
||||
}),
|
||||
onToolCallEnd: (call) =>
|
||||
Effect.sync(() => {
|
||||
calls[call.index] = {
|
||||
tool: call.name,
|
||||
status: call.outcome === "failure" ? "error" : "completed",
|
||||
input: call.input,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return make({
|
||||
description: createRuntime([], []).instructions(),
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: Schema.Struct({
|
||||
output: Output.fields.output,
|
||||
toolCalls: Output.fields.toolCalls,
|
||||
error: Output.fields.error,
|
||||
}),
|
||||
toStructuredOutput: ({ output }) => ({
|
||||
output: output.output,
|
||||
toolCalls: output.toolCalls,
|
||||
...(output.error ? { error: true as const } : {}),
|
||||
}),
|
||||
toModelOutput: ({ output }) => [
|
||||
{ type: "text", text: output.output },
|
||||
...output.attachments.map((attachment) => ({ type: "file" as const, ...attachment })),
|
||||
],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const calls: ExecuteCall[] = []
|
||||
const attachments: Array<typeof Attachment.Type> = []
|
||||
const result = yield* createRuntime(calls, attachments, context).execute(input.code)
|
||||
const logs = result.logs?.length ? `\n\nLogs:\n${result.logs.join("\n")}` : ""
|
||||
return {
|
||||
output: !result.ok
|
||||
? `${result.error.message}${logs}`
|
||||
: typeof result.value === "string"
|
||||
? result.value + logs
|
||||
: `${JSON.stringify(result.value, null, 2) ?? "null"}${logs}`,
|
||||
toolCalls: calls,
|
||||
...(result.ok ? {} : { error: true as const }),
|
||||
attachments,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -1,36 +1,23 @@
|
||||
export * as McpTool from "./mcp"
|
||||
|
||||
import { createHash } from "node:crypto"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { Effect, Exit, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { EventV2 } from "../event"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { MCP } from "../mcp"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { Tools, type ExecutePath } from "./tools"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
const MAX_NAME_LENGTH = 64
|
||||
const HASH_LENGTH = 8
|
||||
|
||||
const sanitize = (value: string) => value.replace(/[^A-Za-z0-9_-]/g, "_")
|
||||
|
||||
// Deterministic short suffix used to keep overlong or colliding names unique and stable across restarts.
|
||||
const hashSuffix = (raw: string) => "_" + createHash("sha1").update(raw).digest("hex").slice(0, HASH_LENGTH)
|
||||
|
||||
const fit = (base: string, raw: string) => base.slice(0, MAX_NAME_LENGTH - HASH_LENGTH - 1) + hashSuffix(raw)
|
||||
|
||||
/**
|
||||
* Registry/permission action name for an MCP tool: V1-compatible `<server>_<tool>` so existing deny
|
||||
* rules keep working. Sanitized to a valid tool name, prefixed when it would not start with a letter,
|
||||
* and hashed down when it would exceed the 64-char limit.
|
||||
* V1-compatible registry and permission name: `<server>_<tool>` with unsupported characters replaced.
|
||||
*/
|
||||
export const name = (server: string, tool: string) => {
|
||||
const joined = sanitize(server) + "_" + sanitize(tool)
|
||||
const base = /^[A-Za-z]/.test(joined) ? joined : "mcp_" + joined
|
||||
return base.length > MAX_NAME_LENGTH ? fit(base, `${server}\u0000${tool}`) : base
|
||||
}
|
||||
export const name = (server: string, tool: string) => sanitize(server) + "_" + sanitize(tool)
|
||||
|
||||
const toContent = (part: MCP.ToolResultContent): Tool.Content =>
|
||||
part.type === "text" ? { type: "text", text: part.text } : { type: "file", data: part.data, mime: part.mimeType }
|
||||
@@ -46,56 +33,88 @@ export const layer = Layer.effectDiscard(
|
||||
const mcp = yield* MCP.Service
|
||||
const tools = yield* Tools.Service
|
||||
const events = yield* EventV2.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let current: Scope.Closeable | undefined
|
||||
|
||||
const make = (server: MCP.ServerName, tool: MCP.Tool) =>
|
||||
Tool.make({
|
||||
description: tool.description ?? "",
|
||||
jsonSchema: (tool.inputSchema as JsonSchema.JsonSchema | undefined) ?? { type: "object", properties: {} },
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* mcp.callTool({ server, name: tool.name, args: (input ?? {}) as Record<string, unknown> }).pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) => new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({ message: errorText(result.content) || "MCP tool returned an error" })
|
||||
return { structured: result.structured ?? {}, content: result.content.map(toContent) }
|
||||
}),
|
||||
})
|
||||
const make = (server: MCP.ServerName, tool: MCP.Tool, action: string) =>
|
||||
Tool.withPermission(
|
||||
Tool.make({
|
||||
description: tool.description ?? "",
|
||||
jsonSchema:
|
||||
typeof tool.inputSchema === "object" && tool.inputSchema !== null && !Array.isArray(tool.inputSchema)
|
||||
? { ...tool.inputSchema }
|
||||
: { type: "object", properties: {} },
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const args = typeof input === "object" && input !== null && !Array.isArray(input) ? { ...input } : {}
|
||||
yield* permission
|
||||
.assert({
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
action,
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
metadata: { server, tool: tool.name, arguments: args },
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: error instanceof PermissionV2.CorrectedError ? error.feedback : "Permission denied",
|
||||
}),
|
||||
),
|
||||
)
|
||||
const result = yield* mcp.callTool({ server, name: tool.name, args }).pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) =>
|
||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({ message: errorText(result.content) || "MCP tool returned an error" })
|
||||
return {
|
||||
structured: result.structured !== undefined ? result.structured : errorText(result.content),
|
||||
content: result.content.map(toContent),
|
||||
}
|
||||
}),
|
||||
}),
|
||||
action,
|
||||
)
|
||||
|
||||
// Register the current tool set under a fresh child scope, then close the previous one so the
|
||||
// registry never has a gap where MCP tools disappear mid-swap.
|
||||
const reconcile = lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const used = new Set<string>()
|
||||
const record: Record<string, Tool.AnyTool> = {}
|
||||
const execute: Record<string, ExecutePath> = {}
|
||||
for (const tool of yield* mcp.tools()) {
|
||||
const initial = name(tool.server, tool.name)
|
||||
const key = used.has(initial) ? fit(initial, `${tool.server}\u0000${tool.name}`) : initial
|
||||
used.add(key)
|
||||
record[key] = make(tool.server, tool)
|
||||
const key = name(tool.server, tool.name)
|
||||
record[key] = make(tool.server, tool, key)
|
||||
execute[key] = [sanitize(tool.server), sanitize(tool.name)]
|
||||
}
|
||||
const next = yield* Scope.fork(scope)
|
||||
yield* tools.register(record).pipe(Scope.provide(next), Effect.orDie)
|
||||
yield* tools
|
||||
.register(record, Flag.OPENCODE_CODE_MODE ? { execute } : undefined)
|
||||
.pipe(Scope.provide(next), Effect.orDie)
|
||||
if (current) yield* Scope.close(current, Exit.void)
|
||||
current = next
|
||||
}),
|
||||
)
|
||||
|
||||
yield* reconcile.pipe(Effect.forkScoped)
|
||||
yield* events
|
||||
.subscribe(McpEvent.ToolsChanged)
|
||||
.pipe(Stream.runForEach(() => reconcile), Effect.forkScoped({ startImmediately: true }))
|
||||
yield* events.subscribe(McpEvent.ToolsChanged).pipe(
|
||||
Stream.runForEach(() => reconcile),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "mcp-tools",
|
||||
layer,
|
||||
deps: [ToolRegistry.toolsNode, MCP.node, EventV2.node],
|
||||
deps: [ToolRegistry.toolsNode, MCP.node, EventV2.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
@@ -9,9 +9,10 @@ import { SessionSchema } from "../session/schema"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { Tools, type ExecutePath, type RegisterOptions } from "./tools"
|
||||
import { ToolHooks } from "./hooks"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { ExecuteTool } from "./execute"
|
||||
|
||||
export type ExecuteInput = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
@@ -23,7 +24,10 @@ export type ExecuteInput = {
|
||||
export interface Interface {
|
||||
readonly materialize: (input: MaterializeInput) => Effect.Effect<Materialization>
|
||||
/** Internal registration capability exposed publicly only through Tools.Service. */
|
||||
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, AnyTool>>,
|
||||
options?: RegisterOptions,
|
||||
) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
}
|
||||
|
||||
export interface MaterializeInput {
|
||||
@@ -49,21 +53,14 @@ const registryLayer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
type Registration = { readonly identity: object; readonly tool: AnyTool }
|
||||
type Registration = { readonly identity: object; readonly tool: AnyTool; readonly execute?: ExecutePath }
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
|
||||
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
|
||||
const registration = local.get(input.call.name)?.at(-1)?.registration
|
||||
if (!registration)
|
||||
return {
|
||||
result: {
|
||||
type: "error" as const,
|
||||
value: advertised ? `Stale tool call: ${input.call.name}` : `Unknown tool: ${input.call.name}`,
|
||||
},
|
||||
}
|
||||
if (advertised && registration.identity !== advertised)
|
||||
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
||||
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleWith.
|
||||
const settleRegistration = Effect.fn("ToolRegistry.settleRegistration")(function* (
|
||||
input: ExecuteInput,
|
||||
registration: Registration,
|
||||
) {
|
||||
// Hooks fire only for hosted/local tools; provider-executed calls never reach the registry.
|
||||
const beforeEvent: ToolHooks.BeforeEvent = {
|
||||
tool: input.call.name,
|
||||
sessionID: input.sessionID,
|
||||
@@ -73,12 +70,16 @@ const registryLayer = Layer.effect(
|
||||
input: input.call.input,
|
||||
}
|
||||
yield* toolHooks.runBefore(beforeEvent)
|
||||
const pending = yield* settle(registration.tool, { ...input.call, input: beforeEvent.input }, {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
toolCallID: input.call.id,
|
||||
}).pipe(
|
||||
const pending = yield* settle(
|
||||
registration.tool,
|
||||
{ ...input.call, input: beforeEvent.input },
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
toolCallID: input.call.id,
|
||||
},
|
||||
).pipe(
|
||||
Effect.map((output) => ({ output })),
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
||||
@@ -88,7 +89,11 @@ const registryLayer = Layer.effect(
|
||||
if ("result" in pending) {
|
||||
settlement = pending
|
||||
} else {
|
||||
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output: pending.output })
|
||||
const bounded = yield* resources.bound({
|
||||
sessionID: input.sessionID,
|
||||
toolCallID: input.call.id,
|
||||
output: pending.output,
|
||||
})
|
||||
const result = ToolOutput.toResultValue(bounded.output)
|
||||
settlement =
|
||||
result.type === "error"
|
||||
@@ -118,15 +123,32 @@ const registryLayer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const settleLocal = Effect.fn("ToolRegistry.settleLocal")(function* (input: ExecuteInput, advertised?: object) {
|
||||
const registration = local.get(input.call.name)?.at(-1)?.registration
|
||||
if (!registration)
|
||||
return {
|
||||
result: {
|
||||
type: "error" as const,
|
||||
value: advertised ? `Stale tool call: ${input.call.name}` : `Unknown tool: ${input.call.name}`,
|
||||
},
|
||||
}
|
||||
if (advertised && registration.identity !== advertised)
|
||||
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
||||
return yield* settleRegistration(input, registration)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("ToolRegistry.register")(function* (tools) {
|
||||
register: Effect.fn("ToolRegistry.register")(function* (tools, options) {
|
||||
const entries = registrationEntries(tools)
|
||||
if (entries.length === 0) return
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const token = {}
|
||||
for (const [name, tool] of entries)
|
||||
local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }])
|
||||
local.set(name, [
|
||||
...(local.get(name) ?? []),
|
||||
{ token, registration: { identity: {}, tool, execute: options?.execute?.[name] } },
|
||||
])
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const [name] of entries) {
|
||||
@@ -140,23 +162,73 @@ const registryLayer = Layer.effect(
|
||||
)
|
||||
}),
|
||||
materialize: Effect.fn("ToolRegistry.materialize")(function* (input) {
|
||||
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
|
||||
const eligible = (name: string, registration: Registration) => {
|
||||
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
|
||||
return !wrongEditTool && !whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
|
||||
}
|
||||
const hasProjectedTools = () =>
|
||||
Array.from(local).some(([name, entries]) => {
|
||||
const registration = entries.at(-1)?.registration
|
||||
return registration?.execute !== undefined && eligible(name, registration)
|
||||
})
|
||||
const registrations = new Map<string, Registration>()
|
||||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (registration) registrations.set(name, registration)
|
||||
}
|
||||
// OpenAI/GPT models use apply_patch; every other model uses edit and write.
|
||||
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
|
||||
for (const [name, registration] of registrations) {
|
||||
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
|
||||
if (wrongEditTool || whollyDisabled(permission(registration.tool, name), input.permissions ?? []))
|
||||
registrations.delete(name)
|
||||
if (!eligible(name, registration)) registrations.delete(name)
|
||||
}
|
||||
|
||||
const children = Array.from(registrations).flatMap(([name, registration]) =>
|
||||
registration.execute ? [{ name, path: registration.execute, registration }] : [],
|
||||
)
|
||||
const executeItems: Record<string, Record<string, ExecuteTool.Item>> = Object.create(null)
|
||||
for (const child of children) {
|
||||
registrations.delete(child.name)
|
||||
const [namespace, member] = child.path
|
||||
executeItems[namespace] ??= Object.create(null)
|
||||
const info = definition(child.name, child.registration.tool)
|
||||
executeItems[namespace][member] = {
|
||||
description: info.description,
|
||||
input: info.inputSchema,
|
||||
output: info.outputSchema,
|
||||
invoke: (value, callID, context) =>
|
||||
settleLocal(
|
||||
{
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
assistantMessageID: context.assistantMessageID,
|
||||
call: { type: "tool-call", id: callID, name: child.name, input: value },
|
||||
},
|
||||
child.registration.identity,
|
||||
),
|
||||
}
|
||||
}
|
||||
const executeRegistration =
|
||||
children.length > 0 && !whollyDisabled("execute", input.permissions ?? [])
|
||||
? { identity: {}, tool: ExecuteTool.makeTool(executeItems) }
|
||||
: undefined
|
||||
if (executeRegistration) registrations.set("execute", executeRegistration)
|
||||
|
||||
return {
|
||||
definitions: Array.from(registrations, ([name, registration]) => definition(name, registration.tool)),
|
||||
settle: (input) => {
|
||||
const registration = registrations.get(input.call.name)
|
||||
if (registration) return settleWith(input, registration.identity)
|
||||
if (executeRegistration && registration === executeRegistration) {
|
||||
if (
|
||||
children.some(
|
||||
(child) => local.get(child.name)?.at(-1)?.registration.identity !== child.registration.identity,
|
||||
)
|
||||
)
|
||||
return Effect.succeed({ result: { type: "error" as const, value: "Stale tool call: execute" } })
|
||||
return settleRegistration(input, registration)
|
||||
}
|
||||
if (registration && input.call.name === "execute" && hasProjectedTools())
|
||||
return Effect.succeed({ result: { type: "error" as const, value: "Stale tool call: execute" } })
|
||||
if (registration) return settleLocal(input, registration.identity)
|
||||
return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,9 +3,17 @@ export * as Tools from "./tools"
|
||||
import { Context, Effect, Scope } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export type ExecutePath = readonly [namespace: string, name: string]
|
||||
|
||||
export interface RegisterOptions {
|
||||
/** Internal projection metadata. The tool remains one canonical registration. */
|
||||
readonly execute?: Readonly<Record<string, ExecutePath>>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, Tool.AnyTool>>,
|
||||
options?: RegisterOptions,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { Tools } from "@opencode-ai/core/tool/tools"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { testModel } from "./lib/tool"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
@@ -168,4 +169,63 @@ describe("PluginV2", () => {
|
||||
expect(settlement.output).toEqual({ structured: { rewritten: true }, content: [] })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fires plugin hooks around tools nested under execute", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const tools = yield* Tools.Service
|
||||
const events: string[] = []
|
||||
const executed: unknown[] = []
|
||||
|
||||
yield* plugins.add(
|
||||
PluginV2.ID.make("execute-hooks"),
|
||||
define({
|
||||
id: "execute-hooks",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.tool.execute.before((event) => {
|
||||
events.push(`before:${event.tool}:${event.toolCallID}`)
|
||||
if (event.tool === "echo") event.input = { text: "mutated" }
|
||||
})
|
||||
yield* ctx.tool.execute.after((event) => {
|
||||
events.push(`after:${event.tool}:${event.toolCallID}`)
|
||||
})
|
||||
}),
|
||||
}).effect,
|
||||
)
|
||||
yield* tools.register(
|
||||
{
|
||||
echo: Tool.make({
|
||||
description: "Echo",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
|
||||
}),
|
||||
},
|
||||
{ execute: { echo: ["test", "echo"] } },
|
||||
)
|
||||
|
||||
const materialized = yield* registry.materialize({ model: testModel })
|
||||
yield* materialized.settle({
|
||||
sessionID: SessionV2.ID.make("ses_execute_hooks"),
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_execute_hooks"),
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-execute-hooks",
|
||||
name: "execute",
|
||||
input: { code: 'return await tools.test.echo({ text: "original" })' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(executed).toEqual([{ text: "mutated" }])
|
||||
expect(events).toEqual([
|
||||
"before:execute:call-execute-hooks",
|
||||
"before:echo:call-execute-hooks/0",
|
||||
"after:echo:call-execute-hooks/0",
|
||||
"after:execute:call-execute-hooks",
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { settleTool, toolDefinitions, toolIdentity } from "./lib/tool"
|
||||
|
||||
const outputStore = Layer.mock(ToolOutputStore.Service, {
|
||||
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
|
||||
})
|
||||
const it = testEffect(AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]))
|
||||
const sessionID = SessionV2.ID.make("ses_execute")
|
||||
|
||||
describe("execute tool", () => {
|
||||
it.effect("projects any canonical tool without registering it twice", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.register(
|
||||
{
|
||||
lookup: Tool.make({
|
||||
description: "Look up a package",
|
||||
input: Schema.Struct({ name: Schema.String }),
|
||||
output: Schema.Struct({ id: Schema.String }),
|
||||
execute: ({ name }) => Effect.succeed({ id: `pkg:${name}` }),
|
||||
}),
|
||||
},
|
||||
{ execute: { lookup: ["packages", "lookup"] } },
|
||||
)
|
||||
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
expect(definitions.map((item) => item.name)).toEqual(["execute"])
|
||||
expect(definitions[0]?.description).toContain("tools.packages.lookup")
|
||||
|
||||
const settlement = yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call_execute",
|
||||
name: "execute",
|
||||
input: { code: 'return await tools.packages.lookup({ name: "effect" })' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(settlement.output?.structured).toEqual({
|
||||
output: '{\n "id": "pkg:effect"\n}',
|
||||
toolCalls: [{ tool: "packages.lookup", status: "completed", input: { name: "effect" } }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits execute when every projected child is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.register(
|
||||
{
|
||||
lookup: Tool.make({
|
||||
description: "Look up a package",
|
||||
input: Schema.Struct({ name: Schema.String }),
|
||||
output: Schema.Struct({ id: Schema.String }),
|
||||
execute: ({ name }) => Effect.succeed({ id: name }),
|
||||
}),
|
||||
},
|
||||
{ execute: { lookup: ["packages", "lookup"] } },
|
||||
)
|
||||
|
||||
expect(yield* toolDefinitions(registry, [{ action: "lookup", resource: "*", effect: "deny" }])).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a previously materialized direct execute after projection takes over", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.register({
|
||||
execute: Tool.make({
|
||||
description: "Direct execute",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ direct: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ direct: true }),
|
||||
}),
|
||||
})
|
||||
const materialized = yield* registry.materialize({ model: { id: "claude-test", provider: "anthropic" } })
|
||||
yield* registry.register(
|
||||
{
|
||||
lookup: Tool.make({
|
||||
description: "Look up a package",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({}),
|
||||
}),
|
||||
},
|
||||
{ execute: { lookup: ["packages", "lookup"] } },
|
||||
)
|
||||
|
||||
expect(
|
||||
yield* materialized.settle({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call_stale_execute", name: "execute", input: {} },
|
||||
}),
|
||||
).toEqual({ result: { type: "error", value: "Stale tool call: execute" } })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, beforeEach, describe, expect } from "bun:test"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
const previous = process.env.OPENCODE_CODE_MODE
|
||||
const calls: Array<{ server: string; name: string; args: Record<string, unknown> | undefined }> = []
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
let deny = false
|
||||
|
||||
const mcp = Layer.succeed(
|
||||
MCP.Service,
|
||||
MCP.Service.of({
|
||||
servers: () => Effect.succeed([]),
|
||||
tools: () =>
|
||||
Effect.succeed([
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("context7"),
|
||||
name: "resolve-library-id",
|
||||
description: "Resolve a library ID",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
required: ["query"],
|
||||
},
|
||||
}),
|
||||
]),
|
||||
callTool: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push({ server: input.server.toString(), name: input.name, args: input.args })
|
||||
return new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server.toString()),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
structured: { id: "/reactjs/react.dev" },
|
||||
content: [{ type: "text", text: "/reactjs/react.dev" }],
|
||||
})
|
||||
}),
|
||||
instructions: () => Effect.succeed([]),
|
||||
prompts: () => Effect.succeed([]),
|
||||
prompt: () => Effect.succeed(undefined),
|
||||
resourceCatalog: () => Effect.succeed(new MCP.ResourceCatalog({ resources: [], templates: [] })),
|
||||
readResource: () => Effect.succeed(undefined),
|
||||
}),
|
||||
)
|
||||
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => assertions.push(input)).pipe(
|
||||
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
|
||||
),
|
||||
ask: () => Effect.die("unused permission.ask"),
|
||||
reply: () => Effect.die("unused permission.reply"),
|
||||
get: () => Effect.die("unused permission.get"),
|
||||
forSession: () => Effect.die("unused permission.forSession"),
|
||||
list: () => Effect.die("unused permission.list"),
|
||||
}),
|
||||
)
|
||||
|
||||
const outputStore = Layer.mock(ToolOutputStore.Service, {
|
||||
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, EventV2.node, ToolRegistry.node, ToolRegistry.toolsNode, McpTool.node]),
|
||||
[
|
||||
[MCP.node, mcp],
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, outputStore],
|
||||
],
|
||||
),
|
||||
)
|
||||
const sessionID = SessionV2.ID.make("ses_mcp_tool")
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENCODE_CODE_MODE
|
||||
calls.length = 0
|
||||
assertions.length = 0
|
||||
deny = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (previous === undefined) delete process.env.OPENCODE_CODE_MODE
|
||||
else process.env.OPENCODE_CODE_MODE = previous
|
||||
})
|
||||
|
||||
describe("MCP tools", () => {
|
||||
it.effect("projects MCP into execute while preserving canonical permission identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "execute")
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
expect(definitions.map((item) => item.name)).toEqual(["execute"])
|
||||
expect(definitions[0]?.description).toContain('tools.context7["resolve-library-id"]')
|
||||
|
||||
const settlement = yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call_mcp",
|
||||
name: "execute",
|
||||
input: { code: 'return await tools.context7["resolve-library-id"]({ query: "react" })' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(settlement.output?.structured).toMatchObject({
|
||||
output: '{\n "id": "/reactjs/react.dev"\n}',
|
||||
toolCalls: [{ tool: "context7.resolve-library-id", status: "completed" }],
|
||||
})
|
||||
expect(calls).toEqual([{ server: "context7", name: "resolve-library-id", args: { query: "react" } }])
|
||||
expect(assertions[0]).toMatchObject({
|
||||
action: "context7_resolve-library-id",
|
||||
source: { type: "tool", messageID: toolIdentity.assistantMessageID, callID: "call_mcp/0" },
|
||||
})
|
||||
|
||||
expect(
|
||||
yield* toolDefinitions(registry, [{ action: "context7_resolve-library-id", resource: "*", effect: "deny" }]),
|
||||
).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not let execute bypass a denied MCP child", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "execute")
|
||||
deny = true
|
||||
|
||||
const settlement = yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call_denied",
|
||||
name: "execute",
|
||||
input: { code: 'return await tools.context7["resolve-library-id"]({ query: "react" })' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(calls).toEqual([])
|
||||
expect(settlement.output?.structured).toMatchObject({
|
||||
error: true,
|
||||
toolCalls: [{ tool: "context7.resolve-library-id", status: "error" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
describe("with CodeMode disabled", () => {
|
||||
beforeEach(() => {
|
||||
process.env.OPENCODE_CODE_MODE = "false"
|
||||
})
|
||||
|
||||
it.effect("keeps the same canonical MCP tool and permission action", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "context7_resolve-library-id")
|
||||
expect((yield* toolDefinitions(registry)).map((item) => item.name)).toEqual(["context7_resolve-library-id"])
|
||||
|
||||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call_direct",
|
||||
name: "context7_resolve-library-id",
|
||||
input: { query: "react" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(assertions[0]).toMatchObject({
|
||||
action: "context7_resolve-library-id",
|
||||
source: { callID: "call_direct" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user