export * as Tool from "./tool.js" export { CallID, Content, Error, FileContent, TextContent } from "@opencode/schema/tool" export type { Context, Metadata, Namespace, Options, Result } from "@opencode/schema/tool" import { ToolDefinition, type ToolCall } from "@opencode/ai" import { Tool } from "@opencode/schema/tool" import { Context, Effect, Layer, Result, Schema, SchemaIssue, Types } from "effect" import { makeLocationNode } from "@opencode/util/effect/app-node" import type { Agent } from "./agent.js" import { CodeModeCatalog } from "./codemode/catalog.js" import { CodeModeTool } from "./codemode/tool.js" import { Image } from "./image.js" import { Permission } from "./permission.js" import { PluginHooks } from "./plugin/hooks.js" import { SessionMessage } from "./session/message.js" import { SessionSchema } from "./session/schema.js" import { State } from "./state.js" import { definition, effectiveName, execute, normalizedName, normalizeContent } from "./tool/runtime.js" import { Wildcard } from "./util/wildcard.js" export class RegistrationError extends Schema.TaggedError()("Tool.RegistrationError", { name: Schema.String, message: Schema.String, }) {} export interface Editor { readonly list: () => readonly (Tool.Info & { readonly id: string })[] readonly get: (id: string) => (Tool.Info & { readonly id: string }) | undefined readonly namespace: (namespace: Tool.Namespace) => void readonly add: (tool: Tool.Info) => void readonly update: (id: string, update: (tool: Types.Mutable) => void) => void readonly remove: (id: string) => void } type Data = { tools: Map namespaces: Map errors: { kind: "tool" | "namespace"; name: string; namespace?: string; error: RegistrationError }[] } export interface Interface extends State.Transformable { readonly list: () => Effect.Effect> readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect } /** A local execution result after hooks and content normalization. */ export interface NormalizedResult extends Tool.Result { readonly content: ReadonlyArray } export interface Snapshot { readonly definitions: ReadonlyArray readonly codeModeCatalog?: CodeModeCatalog.Inventory readonly execute: (input: { readonly sessionID: SessionSchema.ID readonly agent: Agent.ID readonly messageID: SessionMessage.ID readonly call: ToolCall readonly progress?: (update: Tool.Metadata) => Effect.Effect /** Surviving request definitions, keyed by the names advertised after session context hooks. */ readonly definitions?: ReadonlyMap }) => Effect.Effect } export class Service extends Context.Service()("@opencode/Tool") {} const layer = Layer.effect( Service, Effect.gen(function* () { const hooks = yield* PluginHooks.Service const image = yield* Image.Service type NormalizedItem = Tool.Content | "decode" | "size" const normalizeImages = Effect.fnUntraced(function* (content: ReadonlyArray) { const normalized = yield* Effect.forEach(content, (item): Effect.Effect => { if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item) const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1] if (base64 === undefined) return Effect.succeed(item) const resource = item.name ?? `${item.mime} tool output` return image.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }).pipe( Effect.map((result) => ({ ...item, uri: `data:${result.mime};base64,${result.content}`, mime: result.mime, })), Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)), Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)), Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)), ) }) const note = (reason: "decode" | "size", text: string) => { const count = normalized.filter((item) => item === reason).length if (count === 0) return [] return [{ type: "text" as const, text: `[${count} image${count === 1 ? "" : "s"} omitted: ${text}]` }] } return [ ...normalized.filter((item) => typeof item !== "string"), ...note("decode", "could not be decoded."), ...note("size", "could not be resized below the image size limit."), ] }) const beforeExecute = (name: string, input: unknown, context: Tool.Context) => hooks.trigger("tool", "execute.before", { tool: name, sessionID: context.sessionID, agent: context.agent, messageID: context.messageID, id: context.id, input, }) const executeTool = Effect.fn("Tool.execute")(function* ( tool: Tool.Info, name: string, input: unknown, context: Tool.Context, ) { const execution = yield* execute(tool, input, context).pipe( Effect.map((value) => ({ value })), Effect.catchTag("Tool.Error", (failure) => Effect.succeed({ failure })), ) const base = { tool: name, sessionID: context.sessionID, agent: context.agent, messageID: context.messageID, id: context.id, input, } if ("failure" in execution) { const afterEvent: PluginHooks.Domains["tool"]["execute.after"] = { ...base, status: "error", error: execution.failure, } yield* hooks.trigger("tool", "execute.after", afterEvent) return yield* afterEvent.error } const afterEvent: PluginHooks.Domains["tool"]["execute.after"] = { ...base, status: "completed", result: { ...(execution.value.output === undefined ? {} : { output: execution.value.output }), content: execution.value.content, ...(execution.value.metadata === undefined ? {} : { metadata: execution.value.metadata }), }, } yield* hooks.trigger("tool", "execute.after", afterEvent) const afterContent = yield* normalizeImages(normalizeContent(afterEvent.result.content, afterEvent.result.output)) return { ...(afterEvent.result.output === undefined ? {} : { output: afterEvent.result.output }), content: afterContent, ...(afterEvent.result.metadata === undefined ? {} : { metadata: afterEvent.result.metadata }), } }) let catalog: { data: Data; names: string; value: CodeModeCatalog.Inventory } | undefined const state = State.create({ name: "tool", initial: () => ({ tools: new Map(), namespaces: new Map(), errors: [], }), editor: (editor) => ({ list: () => Array.from(editor.tools.values()), get: (id) => editor.tools.get(id), namespace: (namespace) => { const error = namespaceError(namespace.name) if (error) { editor.errors.push({ kind: "namespace", name: namespace.name, namespace: namespace.name, error }) return } editor.namespaces.set(namespace.name, { ...namespace }) }, add: (tool) => { const error = registrationError(tool) if (error) { editor.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error }) return } const id = effectiveName(tool) editor.tools.set(id, { ...tool, id, options: tool.options && { ...tool.options } }) }, update: (id, update) => { const current = editor.tools.get(id) if (!current) return const tool = { ...current, options: current.options && { ...current.options } } update(tool) tool.name = current.name tool.id = id if (tool.options?.namespace !== current.options?.namespace) tool.options = { ...tool.options, namespace: current.options?.namespace } const error = registrationError(tool) if (error) { editor.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error }) return } editor.tools.set(id, tool) }, remove: (id) => { editor.tools.delete(id) }, }), notify: (value) => { catalog = undefined return Effect.forEach( value.errors, ({ kind, name, namespace, error }) => Effect.logError(`Skipping invalid ${kind} registration`, { name, namespace, error: error.message, }), { discard: true }, ) }, }) return Service.of({ transform: state.transform, reload: state.reload, list: () => Effect.sync(() => Array.from(state.get().tools.values())), snapshot: Effect.fn("Tool.snapshot")((permissions) => Effect.sync(() => { const data = state.get() const active = new Map() const rules = permissions ?? [] for (const [name, tool] of data.tools) { if (whollyDisabled(tool.options?.permission ?? name, rules)) continue active.set(name, tool) } const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false)) const codeModeTools = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false)) const namespaces = data.namespaces const codeModeInventory = { tools: codeModeTools, namespaces } const codeModeEnabled = !whollyDisabled("execute", rules) const codeModeTool = codeModeEnabled ? CodeModeTool.create(codeModeInventory, (name, tool, input, context) => beforeExecute(name, input, context).pipe( Effect.flatMap((event) => executeTool(tool, name, event.input, context)), ), ) : undefined const names = Array.from(codeModeTools.keys()).join("\0") // Discovery is immutable for a registry revision and visible tool set. Keep request // definitions/executors fresh, but share the much larger rendered catalog across steps. const codeModeCatalog = !codeModeEnabled ? undefined : catalog?.data === data && catalog.names === names ? catalog.value : CodeModeTool.catalog(codeModeInventory) if (codeModeCatalog) catalog = { data, names, value: codeModeCatalog } return { ...(codeModeCatalog === undefined ? {} : { codeModeCatalog }), definitions: [ ...Array.from(direct) .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .map(([, tool]) => definition(tool)), ...(codeModeTool ? [definition(codeModeTool)] : []), ], execute: Effect.fnUntraced(function* (input: Parameters[0]) { const context: Tool.Context = { sessionID: input.sessionID, agent: input.agent, messageID: input.messageID, id: Tool.CallID.make(input.call.id), progress: input.progress ?? (() => Effect.void), } const event = yield* beforeExecute(input.call.name, input.call.input, context) const requested = input.definitions?.get(event.tool) // Preserve session context removal and alias resolution, now after the repair hook. if (!requested && input.definitions && (direct.has(event.tool) || codeModeTool?.name === event.tool)) return yield* new Tool.Error({ message: `Tool is not available for this request: ${event.tool}` }) const name = requested?.name ?? event.tool if (name === "execute" && codeModeTool) return yield* executeTool(codeModeTool, name, event.input, context) const tool = direct.get(name) if (tool) return yield* executeTool(tool, name, event.input, context) return yield* new Tool.Error({ message: `No tool named "${name}" is currently available. Please use a tool from the available tool list.`, }) }), } }), ), }) }), ) const whollyDisabled = (action: string, rules: Permission.Ruleset) => { const rule = rules.findLast((rule) => Wildcard.match(action, rule.action)) return rule?.resource === "*" && rule.effect === "deny" } const formatSchemaIssue = SchemaIssue.makeFormatterDefault() function schemaMakeError(error: unknown) { if (error instanceof Error && SchemaIssue.isIssue(error.cause)) return formatSchemaIssue(error.cause) return error instanceof Error ? error.message : String(error) } function registrationError(tool: Tool.Info) { const namespace = tool.options?.namespace if (namespace !== undefined) { const error = namespaceError(namespace) if (error) return error } const name = normalizedName(tool) if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) return new RegistrationError({ name, message: `Invalid tool name: ${name}` }) const id = effectiveName(tool) if (tool.options?.codemode === false && id === "execute") return new RegistrationError({ name: id, message: 'Tool name "execute" is reserved for CodeMode' }) const result = Result.try({ try: () => ToolDefinition.make(definition(tool)), catch: (error) => new RegistrationError({ name: id, message: `Invalid tool definition ${id}: ${schemaMakeError(error)}` }), }) return Result.isFailure(result) ? result.failure : undefined } function namespaceError(name: string) { if (name.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))) return return new RegistrationError({ name, message: `Invalid tool namespace: ${JSON.stringify(name)}` }) } export const node = makeLocationNode({ service: Service, layer, deps: [PluginHooks.node, Image.node], })