refactor(core): consolidate tool architecture

This commit is contained in:
Dax Raad 2026-07-26 20:08:55 -04:00
commit 8db7487c89
466 changed files with 9405 additions and 11071 deletions

View file

@ -1,20 +1,22 @@
# Core Tool Architecture
This folder owns Core's local tools, Location-scoped registrations, effective lookup, execution, and terminal outcomes.
`src/tool.ts` owns the Location-scoped tool service, registrations, effective lookup, execution, and terminal outcomes. This folder contains its supporting runtime modules and built-in plugins.
## Representations
- `tool.ts` defines the structural canonical `Tool.make({ description, input, output?, execute })` tool. Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same type.
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
- `registry.ts` stores only canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
- Plugin authors get schema-derived input types at the `ToolDraft.add` boundary through `Tool`.
- The heterogeneous Core registry deliberately erases registered definitions to `Tool.Info`. Use `any` at this internal boundary; do not replace it with `unknown`, JSON-value plumbing, casts, or compiled wrapper types solely to preserve type safety after registration.
- Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same runtime shape after registration.
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
- Built-in tool plugins live in `tool/plugin`.
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
## Construction
Tool schemas use `input` and `output` terminology. A tool carries schemas and executable behavior without public identity. A registration binds its name, namespace, CodeMode placement, and optional catalog permission action.
Tool schemas use `input` and `output` terminology. Each tool carries its name, options, schemas, and executable behavior in one object.
Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context:
Location-scoped built-in layers acquire `Permission.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context:
```ts
const source = {
@ -24,13 +26,11 @@ const source = {
}
```
Leaves own resolution, permission, and side-effect ordering. Translate only expected typed errors into `ToolFailure`; do not use `catchCause`, because interruption and defects must survive. User declines from `PermissionV2.assert` and question dismissals travel as defects beneath leaf `mapError` blankets and resurface as typed failures at `SessionModelRequest.executeTool`; leaves must never catch or convert them. A decline with feedback (`PermissionV2.CorrectedError`) stays typed so the leaf converts it into `ToolFailure` and the model continues.
Leaves own resolution, permission, and side-effect ordering. Translate only expected typed errors into `ToolFailure`; do not use `catchCause`, because interruption and defects must survive. User declines from `Permission.assert` and question dismissals travel as defects beneath leaf `mapError` blankets and resurface as typed failures at `SessionModelRequest.executeTool`; leaves must never catch or convert them. A decline with feedback (`Permission.CorrectedError`) stays typed so the leaf converts it into `ToolFailure` and the model continues.
## Registration
Built-ins and plugin tools register through `Tools.Service.register({ [name]: tool })`. Registrations may provide a
namespace, which flattens direct model names to `<namespace>_<tool>`, and default into CodeMode (`codemode` defaults true;
`codemode: false` keeps the tool on the provider's native tool list).
Built-ins, plugins, and MCP install tools through `ToolRegistry.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
Registrations are scoped:
@ -38,21 +38,22 @@ Registrations are scoped:
- Closing any registration removes only that registration and reveals the next active one.
- Each model request captures the effective tools it advertises; later registration changes affect later requests.
Type safety ends at registration. The registry validates model input and declared output at runtime and should not carry producer schema generics through storage or execution.
`ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
## Permissions
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. Registration options may attach a permission action solely to preserve whole-tool definition filtering. Most registrations default to their effective name; `edit`, `write`, and `patch` use the shared `edit` action.
The registry has no `Permission.Service` dependency and performs no execution authorization. Registration options may attach a permission action solely to preserve whole-tool definition filtering. Most registrations default to their effective name; `edit`, `write`, and `patch` use the shared `edit` action.
Tool filtering is catalog visibility, not execution authorization. A call still executes the captured tool's leaf policy if it reaches execution.
## Output
Built-ins return complete tool responses. `ToolRegistry.ToolSet.execute` is the only local execution and generic model-output bounding boundary and owns managed retention paths.
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary.
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`.
Producer capture limits remain local to producers. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss.
## Current Gaps
- MCP and 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.

View file

@ -1,196 +0,0 @@
export * as ExecuteTool from "./execute"
export type { Registration } from "./tool"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import type { ToolContent } from "@opencode-ai/ai"
import { Effect, Ref, Schema, Semaphore } from "effect"
import { execute, make, toLLMDefinition, type Content, type Metadata, type Registration } from "./tool"
const ExecuteFile = Schema.Struct({
data: Schema.String,
mime: Schema.String,
name: Schema.optionalKey(Schema.String),
})
const ExecuteCall = Schema.Struct({
tool: Schema.String,
status: Schema.Literals(["running", "completed", "error"]),
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
})
type ExecuteCall = typeof ExecuteCall.Type
const ExecuteOutput = Schema.Struct({
output: Schema.String,
toolCalls: Schema.Array(ExecuteCall),
error: Schema.optionalKey(Schema.Literal(true)),
files: Schema.Array(ExecuteFile),
})
type CollectedFiles = {
readonly index: number
readonly files: Array<typeof ExecuteFile.Type>
}
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
].join("\n")
export const create = (registrations: ReadonlyMap<string, Registration>) => {
return make({
description,
input: CodeMode.Input,
output: ExecuteOutput,
execute: ({ code }, context) =>
Effect.gen(function* () {
const callIndex = yield* Ref.make(0)
const files = yield* Ref.make<Array<CollectedFiles>>([])
const calls = yield* Ref.make<Array<ExecuteCall>>([])
const lock = Semaphore.makeUnsafe(1)
const updateCalls = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
lock.withPermit(
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
)
const result = yield* runtime(
registrations,
(name, registration, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const executed = yield* execute(registration.tool, input, {
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
progress: () => Effect.void,
}).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
const outputFileParts = outputFiles(executed.content)
if (outputFileParts.length > 0)
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
return executed.output
}),
{
onToolCallStart: ({ index, name, input }) => {
const shown = displayInput(input)
return updateCalls((items) => {
const next = [...items]
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
return next
})
},
onToolCallEnd: ({ index, name, input, outcome }) => {
const shown = displayInput(input)
return updateCalls((items) => {
const next = [...items]
next[index] = {
...(items[index] ?? { tool: name, ...(shown ? { input: shown } : {}) }),
status: outcome === "success" ? "completed" : "error",
}
return next
})
},
},
).execute(code)
const toolCalls = yield* Ref.get(calls)
const collected = (yield* Ref.get(files))
.toSorted((left, right) => left.index - right.index)
.flatMap((item) => item.files)
const output = formatResult(result)
const value: typeof ExecuteOutput.Type = {
output,
toolCalls,
files: collected,
...(result.ok ? {} : { error: true }),
}
const content: [Content, ...Content[]] = [{ type: "text", text: value.output }]
content.push(
...value.files.map((file) => ({
type: "file" as const,
data: file.data,
mime: file.mime,
...(file.name === undefined ? {} : { name: file.name }),
})),
)
const metadata: Metadata = {
toolCalls: value.toolCalls,
...(value.error ? { error: true } : {}),
}
return {
output: value,
content,
metadata,
}
}),
})
}
export const catalog = (registrations: ReadonlyMap<string, Registration>) => {
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
}
function runtime(
registrations: ReadonlyMap<string, Registration>,
executeTool: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) {
const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) {
const child = toLLMDefinition(name, registration.tool)
const path =
registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
output: child.outputSchema,
execute: (input) => executeTool(name, registration, input),
})
}
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
if (input === null || input === undefined) return
if (typeof input !== "object" || Array.isArray(input)) return { input: input as typeof Schema.Json.Type }
if (Object.keys(input).length === 0) return
return input as Record<string, typeof Schema.Json.Type>
}
function formatResult(result: CodeMode.Result) {
const output = result.ok
? formatValue(result.value)
: [result.error.message, ...(result.error.suggestions ?? []).filter((hint) => !result.error.message.includes(hint))]
.join("\n")
.trim()
const warnings =
result.ok && result.warnings && result.warnings.length > 0
? `Warnings:\n${result.warnings.map((item) => `- [${item.kind}] ${item.message}`).join("\n")}`
: undefined
const logs = result.logs && result.logs.length > 0 ? `Logs:\n${result.logs.join("\n")}` : undefined
return [output, warnings, logs].filter((part) => part !== undefined && part !== "").join("\n\n")
}
function formatValue(value: CodeMode.DataValue) {
if (typeof value === "string") return value
return JSON.stringify(value, null, 2) ?? String(value)
}
function outputFiles(content: ReadonlyArray<ToolContent>): Array<typeof ExecuteFile.Type> {
return 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,
...(part.name === undefined ? {} : { name: part.name }),
},
]
})
}

View file

@ -1,79 +0,0 @@
export * as ToolHooks from "./hooks"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { State } from "../state"
import { Context, Effect, Layer, Scope } from "effect"
import type { Tool } from "./tool"
export type BeforeEvent = Tool.ToolExecuteBeforeEvent
/** The canonical execution outcome. Hooks never observe the raw domain output. */
export type AfterEvent = Tool.ToolExecuteAfterEvent
export interface Interface {
readonly hook: {
readonly before: (
callback: (event: BeforeEvent) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly after: (
callback: (event: AfterEvent) => Effect.Effect<void> | void,
) => Effect.Effect<State.Registration, never, Scope.Scope>
}
readonly runBefore: (event: BeforeEvent) => Effect.Effect<BeforeEvent>
readonly runAfter: (event: AfterEvent) => Effect.Effect<AfterEvent>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolHooks") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
let beforeHooks: ((event: BeforeEvent) => Effect.Effect<void> | void)[] = []
let afterHooks: ((event: AfterEvent) => Effect.Effect<void> | void)[] = []
const register = <Event>(
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
update: (hooks: ((event: Event) => Effect.Effect<void> | void)[]) => void,
) =>
Effect.fn("ToolHooks.hook")(function* (callback: (event: Event) => Effect.Effect<void> | void) {
const scope = yield* Scope.Scope
let active = true
update([...hooks(), callback])
const dispose = Effect.sync(() => {
if (!active) return
active = false
update(hooks().filter((item) => item !== callback))
})
yield* Scope.addFinalizer(scope, dispose)
return { dispose }
})
const run = Effect.fnUntraced(function* <Event>(
hooks: readonly ((event: Event) => Effect.Effect<void> | void)[],
event: Event,
) {
for (const hook of hooks) {
const result = hook(event)
if (Effect.isEffect(result)) yield* result
}
return event
})
return Service.of({
hook: {
before: register(
() => beforeHooks,
(next) => (beforeHooks = next),
),
after: register(
() => afterHooks,
(next) => (afterHooks = next),
),
},
runBefore: (event) => run(beforeHooks, event),
runAfter: (event) => run(afterHooks, event),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [] })

View file

@ -4,13 +4,11 @@ import { ToolFailure } from "@opencode-ai/ai"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { EventV2 } from "../event"
import { Bus } from "../bus"
import { MCP } from "../mcp"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { ToolRegistry } from "./registry"
import { Permission } from "../permission"
import { Tool } from "../tool"
/**
* Registry namespace and permission action names for MCP tools.
@ -21,9 +19,9 @@ export const name = (server: string, tool: string) => `${namespace(server)}_${to
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const tools = yield* Tools.Service
const events = yield* EventV2.Service
const permission = yield* PermissionV2.Service
const tools = yield* Tool.Service
const bus = yield* Bus.Service
const permission = yield* Permission.Service
const scope = yield* Scope.Scope
const lock = Semaphore.makeUnsafe(1)
let current: Scope.Closeable | undefined
@ -32,27 +30,25 @@ export const layer = Layer.effectDiscard(
// registry never has a gap where MCP tools disappear mid-swap.
const reconcile = lock.withPermit(
Effect.gen(function* () {
const groups = new Map<
string,
{
tools: Record<string, Tool.Any>
codemode: boolean
}
>()
for (const tool of yield* mcp.tools()) {
const group = groups.get(tool.server) ?? { tools: {}, codemode: tool.codemode !== false }
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
group.tools[tool.name] = Tool.make({
description: tool.description ?? "",
input: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
},
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
execute: (input, context) =>
Effect.gen(function* () {
const discovered = yield* mcp.tools()
const next = yield* Scope.fork(scope)
yield* tools
.transform((draft) => {
for (const tool of discovered) {
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
draft.add({
name: tool.name,
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
description: tool.description ?? "",
input: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
},
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name(tool.server, tool.name),
resources: ["*"],
@ -90,31 +86,27 @@ export const layer = Layer.effectDiscard(
const content = result.content.map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: { type: "file" as const, data: part.data, mime: part.mimeType },
: {
type: "file" as const,
uri: `data:${part.mimeType};base64,${part.data}`,
mime: part.mimeType,
},
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return {
output: result.structured ?? (text === "" ? null : text),
...(content.length === 0 ? {} : { content: content as [Tool.Content, ...Tool.Content[]] }),
...(content.length === 0 ? {} : { content }),
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
),
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
),
})
}
})
groups.set(tool.server, group)
}
const next = yield* Scope.fork(scope)
yield* tools
.registerBatch(
Array.from(groups, ([server, group]) => ({
tools: group.tools,
options: { namespace: namespace(server), codemode: group.codemode },
})),
)
.pipe(Scope.provide(next), Effect.orDie)
if (current) yield* Scope.close(current, Exit.void)
current = next
@ -122,7 +114,7 @@ export const layer = Layer.effectDiscard(
)
yield* reconcile.pipe(Effect.forkScoped)
yield* events.subscribe(McpEvent.ToolsChanged).pipe(
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
Stream.runForEach(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
@ -132,5 +124,5 @@ export const layer = Layer.effectDiscard(
export const node = makeLocationNode({
name: "mcp-tools",
layer,
deps: [ToolRegistry.toolsNode, MCP.node, EventV2.node, PermissionV2.node],
deps: [Tool.node, MCP.node, Bus.node, Permission.node],
})

View file

@ -1,21 +1,20 @@
/**
* Model-facing V2 exact-edit leaf. Relative paths resolve within the active
* Model-facing exact-edit leaf. Relative paths resolve within the active
* Location. Absolute paths inside that Location are accepted, while explicit
* absolute external paths retain mutation capability through a separate
* external_directory approval before edit approval.
*/
export * as EditTool from "./edit"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FileMutation } from "../../file-mutation"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
export const name = "edit"
@ -78,12 +77,12 @@ export const toModelOutput = (output: Output, oldString: string, newString: stri
"```",
].join("\n")
/** Deferred V2 edit behavior and UX integrations remain visible at the model-facing seam. */
/** Deferred edit behavior and UX integrations remain visible at the model-facing seam. */
// TODO: Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.
// TODO: Add formatter integration after V2 formatter runtime exists.
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after design exists.
// TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
export const Plugin = {
id: "opencode.tool.edit",
@ -91,13 +90,14 @@ export const Plugin = {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service
const permission = yield* PermissionV2.Service
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
name,
Tool.make({
({
name,
options: { codemode: false, permission: "edit" },
description:
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
@ -212,7 +212,6 @@ export const Plugin = {
)
},
}),
{ codemode: false, permission: "edit" },
),
)
.pipe(Effect.orDie)

View file

@ -1,17 +1,16 @@
export * as GlobTool from "./glob"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema } from "effect"
import path from "path"
import { FileSystem } from "../filesystem"
import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation"
import { Ripgrep } from "../../ripgrep"
import { RelativePath } from "../../schema"
import { Permission } from "../../permission"
export const name = "glob"
@ -47,13 +46,14 @@ export const Plugin = {
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
const permission = yield* PermissionV2.Service
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
name,
Tool.make({
({
name,
options: { codemode: false },
description:
'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
input: Input,
@ -130,7 +130,6 @@ export const Plugin = {
),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)

View file

@ -1,17 +1,16 @@
export * as GrepTool from "./grep"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import path from "path"
import { FileSystem } from "../filesystem"
import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { Tool } from "./tool"
import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { Ripgrep } from "../../ripgrep"
import { RelativePath } from "../../schema"
export const name = "grep"
@ -63,13 +62,14 @@ export const Plugin = {
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
const permission = yield* PermissionV2.Service
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
name,
Tool.make({
({
name,
options: { codemode: false },
description:
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
input: Input,
@ -154,7 +154,6 @@ export const Plugin = {
),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)

View file

@ -1,6 +1,6 @@
export * as PatchTool from "./patch"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
@ -8,11 +8,10 @@ import { Effect, Schema } from "effect"
import { PlatformError } from "effect/PlatformError"
import path from "path"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { Location } from "../../location"
import { Patch } from "@opencode-ai/util/patch"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import DESCRIPTION from "./patch.txt"
import { Permission } from "../../permission"
import DESCRIPTION from "../patch.txt"
export const name = "patch"
@ -70,13 +69,14 @@ export const Plugin = {
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const permission = yield* PermissionV2.Service
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
name,
Tool.make({
({
name,
options: { codemode: false, permission: "edit" },
description: DESCRIPTION,
input: Input,
output: Output,
@ -310,7 +310,6 @@ export const Plugin = {
)
},
}),
{ codemode: false, permission: "edit" },
),
)
.pipe(Effect.orDie)

View file

@ -1,12 +1,11 @@
export * as QuestionTool from "./question"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Form } from "../form"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
import { Tool } from "./tool"
import { Form } from "../../form"
import { Permission } from "../../permission"
import { Question } from "../../question"
export const name = "question"
@ -22,11 +21,11 @@ Usage notes:
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
export const Input = Schema.Struct({
questions: Schema.NonEmptyArray(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
questions: Schema.NonEmptyArray(Question.Prompt).annotate({ description: "Questions to ask" }),
})
export const Output = Schema.Struct({
answers: Schema.Array(QuestionV2.Answer),
answers: Schema.Array(Question.Answer),
})
export type Output = typeof Output.Type
@ -37,8 +36,8 @@ export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("Q
}
export const toModelOutput = (
questions: ReadonlyArray<QuestionV2.Prompt>,
answers: ReadonlyArray<QuestionV2.Answer>,
questions: ReadonlyArray<Question.Prompt>,
answers: ReadonlyArray<Question.Answer>,
) => {
const formatted = questions
.map(
@ -53,13 +52,14 @@ export const Plugin = {
id: "opencode.tool.question",
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
const forms = yield* Form.Service
const permission = yield* PermissionV2.Service
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
name,
Tool.make({
({
name,
options: { codemode: false },
description,
input: Input,
output: Output,
@ -91,12 +91,12 @@ export const Plugin = {
.pipe(Effect.orDie),
),
Effect.flatMap((state) => {
// Deliberate defect tunnel (see PermissionV2.assert): a dismissal must dodge
// Deliberate defect tunnel (see Permission.assert): a dismissal must dodge
// leaf `mapError` blankets so it never becomes model-facing tool output; it
// resurfaces as a typed failure at SessionModelRequest.executeTool.
if (state.status === "cancelled") return Effect.die(new CancelledError())
const output = {
answers: input.questions.map((_, index): QuestionV2.Answer => {
answers: input.questions.map((_, index): Question.Answer => {
const value = state.answer[`q${index}`]
if (value === undefined) return []
if (typeof value === "object") return Array.from(value)
@ -111,14 +111,13 @@ export const Plugin = {
}),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
}),
}
function toField(question: QuestionV2.Prompt, index: number): Form.Field {
function toField(question: Question.Prompt, index: number): Form.Field {
return {
key: `q${index}`,
title: question.header,

View file

@ -1,18 +1,17 @@
export * as ReadTool from "./read"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { dirname } from "path"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { SessionInstructions } from "../session/instructions"
import { AbsolutePath } from "../schema"
import { ReadToolFileSystem } from "./read-filesystem"
import { Tool } from "./tool"
import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { SessionInstructions } from "../../session/instructions"
import { AbsolutePath } from "../../schema"
import { ReadToolFileSystem } from "../read-filesystem"
export const name = "read"
const FILENAME = "AGENTS.md"
@ -34,7 +33,7 @@ export const Plugin = {
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
const reader = yield* ReadToolFileSystem.Service
const mutation = yield* LocationMutation.Service
const permission = yield* PermissionV2.Service
const permission = yield* Permission.Service
const sessionInstructions = yield* SessionInstructions.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
@ -42,8 +41,9 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add(
name,
Tool.make({
({
name,
options: { codemode: false },
description:
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.",
input: Input,
@ -119,7 +119,12 @@ export const Plugin = {
? SUPPORTED_IMAGE_MIMES.has(output.mime)
? ([
{ type: "text", text: "Image read successfully" },
{ type: "file", data: output.content, mime: output.mime, name: input.path },
{
type: "file",
uri: `data:${output.mime};base64,${output.content}`,
mime: output.mime,
name: input.path,
},
] as const)
: JSON.stringify({ ...output, content: "" }, null, 2)
: JSON.stringify(output, null, 2)
@ -136,7 +141,6 @@ export const Plugin = {
)
},
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)

View file

@ -2,16 +2,16 @@ export * as ShellTool from "./shell"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema, Scope } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { PluginRuntime } from "../plugin/runtime"
import { NonNegativeInt } from "../schema"
import { SessionSchema } from "../session/schema"
import { Shell } from "../shell"
import { Tool, type Content } from "./tool"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { PluginRuntime } from "../../plugin/runtime"
import { NonNegativeInt } from "../../schema"
import { SessionSchema } from "../../session/schema"
import { Shell } from "../../shell"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
@ -64,14 +64,14 @@ const modelOutput = (output: Output): string | undefined => {
}
/**
* Minimal V2 core shell boundary. Keep parity debt visible without pulling the
* Minimal core shell boundary. Keep parity debt visible without pulling the
* legacy shell runtime into core.
*/
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
// TODO: Port BashArity reusable command-prefix approvals.
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
// TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist.
// TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
// TODO: Persist job status and define restart recovery before exposing remote observation.
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
@ -104,7 +104,7 @@ export const Plugin = {
const fsUtil = yield* FSUtil.Service
const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service
const permission = yield* PermissionV2.Service
const permission = yield* Permission.Service
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
sessionID: SessionSchema.ID,
@ -142,8 +142,9 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add(
name,
Tool.make({
({
name,
options: { codemode: false },
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
input: Input,
output: Output,
@ -267,7 +268,7 @@ export const Plugin = {
return { ...(yield* Deferred.await(settled)), ...(warnings.length ? { warnings } : {}) }
}).pipe(
Effect.map((output) => {
const content: [Content, ...Content[]] = [{ type: "text", text: output.output }]
const content: Array<Content> = [{ type: "text", text: output.output }]
const model = modelOutput(output)
if (model) content.push({ type: "text", text: model })
return {
@ -286,7 +287,6 @@ export const Plugin = {
),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)

View file

@ -1,23 +1,22 @@
export * as SkillTool from "./skill"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { SkillV2 } from "../skill"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Skill } from "../../skill"
import { Permission } from "../../permission"
export const name = "skill"
const FILE_LIMIT = 10
export const Input = Schema.Struct({
id: SkillV2.ID.annotate({ description: "The ID of the skill from the available skills list" }),
id: Skill.ID.annotate({ description: "The ID of the skill from the available skills list" }),
})
export const Output = Schema.Struct({
name: SkillV2.Name,
name: Skill.Name,
directory: Schema.String,
output: Schema.String,
})
@ -27,7 +26,7 @@ export const description = [
"The skill ID must match one of the available skills in the instructions.",
].join("\n")
export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray<string>) => {
export const toModelOutput = (skill: Skill.Info, files: ReadonlyArray<string>) => {
const directory = path.dirname(skill.location)
return [
`<skill_content name="${skill.name}">`,
@ -53,13 +52,14 @@ export const Plugin = {
id: "opencode.tool.skill",
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) {
const fs = yield* FSUtil.Service
const skills = yield* SkillV2.Service
const permission = yield* PermissionV2.Service
const skills = yield* Skill.Service
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
name,
Tool.make({
({
name,
options: { codemode: false },
description,
input: Input,
output: Output,
@ -99,7 +99,6 @@ export const Plugin = {
})),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)

View file

@ -1,14 +1,13 @@
export * as SubagentTool from "./subagent"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { AgentV2 } from "../agent"
import { Config } from "../config"
import { PluginRuntime } from "../plugin/runtime"
import { PermissionV2 } from "../permission"
import { SessionSchema } from "../session/schema"
import { Tool } from "./tool"
import { Agent } from "../../agent"
import { Config } from "../../config"
import { PluginRuntime } from "../../plugin/runtime"
import { Permission } from "../../permission"
import { SessionSchema } from "../../session/schema"
export const name = "subagent"
@ -42,9 +41,9 @@ export const Plugin = {
id: "opencode.tool.subagent",
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service
const agents = yield* AgentV2.Service
const agents = yield* Agent.Service
const config = yield* Config.Service
const permission = yield* PermissionV2.Service
const permission = yield* Permission.Service
const scope = yield* Scope.Scope
// Concatenate the child's final completed assistant text. Distinguishes "completed with no
@ -109,8 +108,9 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add(
name,
Tool.make({
({
name,
options: { codemode: false },
description,
input: Input,
output: Output,
@ -165,7 +165,7 @@ export const Plugin = {
.create({
parentID: context.sessionID,
title: input.description,
agent: AgentV2.ID.make(input.agent),
agent: Agent.ID.make(input.agent),
model,
// TODO(opencode kkdvxn): derive restricted subagent permissions from the parent
// session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions.
@ -238,7 +238,6 @@ export const Plugin = {
})),
),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)
@ -254,7 +253,7 @@ export const Plugin = {
(agent) =>
agent.mode !== "primary" &&
!agent.hidden &&
PermissionV2.evaluate(name, agent.id, selected.permissions).effect !== "deny",
Permission.evaluate(name, agent.id, selected.permissions).effect !== "deny",
)
.toSorted((a, b) => a.id.localeCompare(b.id))
if (available.length === 0) return

View file

@ -1,14 +1,13 @@
export * as WebFetchTool from "./webfetch"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2"
import TurndownService from "turndown"
import { PermissionV2 } from "../permission"
import { collectBoundedResponseBody } from "./http-body"
import { Tool } from "./tool"
import { Permission } from "../../permission"
import { collectBoundedResponseBody } from "../http-body"
export const name = "webfetch"
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
@ -115,13 +114,14 @@ export const Plugin = {
id: "opencode.tool.webfetch",
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: PluginContext) {
const http = yield* HttpClient.HttpClient
const permission = yield* PermissionV2.Service
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
name,
Tool.make({
({
name,
options: { codemode: false },
description,
input: Input,
output: Output,
@ -173,7 +173,6 @@ export const Plugin = {
return { output: result, content: result.output, metadata: { contentType: result.contentType } }
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
}),
{ codemode: false },
),
)
.pipe(Effect.orDie)

View file

@ -1,12 +1,12 @@
export * as WebSearchTool from "./websearch"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Form } from "../form"
import { KV } from "../kv"
import { PermissionV2 } from "../permission"
import { WebSearch } from "../websearch"
import { Form } from "../../form"
import { KV } from "../../kv"
import { Permission } from "../../permission"
import { WebSearch } from "../../websearch"
export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query."
@ -26,15 +26,16 @@ const Output = Schema.Struct({
export const Plugin = {
id: "opencode.tool.websearch",
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
const permission = yield* PermissionV2.Service
const permission = yield* Permission.Service
const forms = yield* Form.Service
const kv = yield* KV.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
name,
{
name,
options: { codemode: false },
description,
input: Input,
output: Output,
@ -109,7 +110,6 @@ export const Plugin = {
),
),
},
{ codemode: false },
),
)
.pipe(Effect.orDie)

View file

@ -1,18 +1,17 @@
/**
* Model-facing V2 file-write leaf. Relative paths resolve within the active
* Model-facing file-write leaf. Relative paths resolve within the active
* Location. Absolute paths inside that Location are accepted, while explicit
* absolute external paths retain mutation capability through a separate
* external_directory approval before edit approval.
*/
export * as WriteTool from "./write"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { FileMutation } from "../../file-mutation"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
export const name = "write"
@ -36,24 +35,25 @@ export type Output = typeof Output.Type
export const toModelOutput = (output: Output) =>
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
/** Deferred V2 write UX integrations remain visible at the model-facing seam. */
// TODO: Add formatter integration after V2 formatter runtime exists.
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
/** Deferred write UX integrations remain visible at the model-facing seam. */
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after design exists.
// TODO: Add LSP notification and diagnostics after V2 LSP runtime exists.
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
export const Plugin = {
id: "opencode.tool.write",
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const permission = yield* PermissionV2.Service
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add(
name,
Tool.make({
({
name,
options: { codemode: false, permission: "edit" },
description:
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
@ -88,7 +88,6 @@ export const Plugin = {
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}),
{ codemode: false, permission: "edit" },
),
)
.pipe(Effect.orDie)

View file

@ -1,377 +0,0 @@
export * as ToolRegistry from "./registry"
import { type ToolCall, type ToolContent, type ToolDefinition } from "@opencode-ai/ai"
import { Context, Effect, Layer, Schema, Scope, Semaphore } from "effect"
import type { AgentV2 } from "../agent"
import { CodeModeCatalog } from "../codemode/catalog"
import { Image } from "../image"
import { PermissionV2 } from "../permission"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { CodeMode } from "../codemode"
import { Tool, nonEmpty, registrationEntries, toLLMDefinition, validateName, validateNamespace } from "./tool"
import { Tools } from "./tools"
import { ToolHooks } from "./hooks"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { toSessionError } from "../session/to-session-error"
export type ExecuteInput = {
readonly sessionID: SessionSchema.ID
readonly agent: AgentV2.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly progress?: (update: Progress) => Effect.Effect<void>
}
/** Live replacement metadata for a running tool. */
export type Progress = Tool.Metadata
export interface Interface {
readonly snapshot: (permissions?: PermissionV2.Ruleset) => Effect.Effect<ToolSet>
/** Internal registration capability exposed publicly only through Tools.Service. */
readonly register: (
tools: Readonly<Record<string, Tool.Any>>,
options?: Tools.RegisterOptions,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
/** Internal atomic registration capability used by plugin transforms. */
readonly registerBatch: (
registrations: ReadonlyArray<{
readonly tools: Readonly<Record<string, Tool.Any>>
readonly options?: Tools.RegisterOptions
}>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
}
/**
* One request-scoped snapshot pairing the Code Mode catalog and advertised
* definitions with captured tools. A model request executes exactly the tool
* values it advertised even if registration changes while it is in flight.
*/
export interface ToolSet {
readonly definitions: ReadonlyArray<ToolDefinition>
readonly codeModeCatalog?: ReadonlyArray<CodeModeCatalog.Entry>
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolOutcome, ToolOutputStore.Error>
}
/**
* The canonical outcome of one local tool execution. `output` is the validated
* machine value for Code Mode and remains ephemeral; durable publication drops it.
*/
export type ToolOutcome =
| (Extract<Tool.Outcome, { readonly status: "completed" }> & { readonly output?: unknown })
| Extract<Tool.Outcome, { readonly status: "error" }>
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
const registryLayer = Layer.effect(
Service,
Effect.gen(function* () {
const resources = yield* ToolOutputStore.Service
const toolHooks = yield* ToolHooks.Service
const image = yield* Image.Service
const codeMode = yield* CodeMode.Service
type NormalizedItem = ToolContent | "decode" | "size"
const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ReadonlyArray<ToolContent>) {
const normalized = yield* Effect.forEach(content, (item): Effect.Effect<NormalizedItem> => {
if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item)
// RFC 2397 permits parameters between the mime and ";base64".
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."),
]
})
// Invalid or oversized metadata is dropped with a warning; it never fails a
// successful side-effecting tool.
const validMetadata = Effect.fnUntraced(function* (tool: string, metadata: Tool.Metadata | undefined) {
if (metadata === undefined) return undefined
const limits = yield* resources.limits()
const valid = Tool.jsonMetadata(metadata, limits.maxBytes)
if (valid === undefined)
yield* Effect.logWarning("dropping invalid or oversized tool metadata").pipe(Effect.annotateLogs({ tool }))
return valid
})
type Registration = Tool.Registration
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
const registrationLock = Semaphore.makeUnsafe(1)
const executeTool = Effect.fn("ToolRegistry.executeTool")(function* (input: ExecuteInput, tool: Tool.Any) {
// Hooks fire only for hosted/local tools; provider-executed calls never reach executeTool.
const beforeEvent: ToolHooks.BeforeEvent = {
tool: input.call.name,
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
callID: input.call.id,
input: input.call.input,
}
yield* toolHooks.runBefore(beforeEvent)
const execution = yield* Tool.execute(tool, beforeEvent.input, {
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
callID: input.call.id,
progress: (metadata) => {
const progress = input.progress
if (!progress) return Effect.void
return validMetadata(input.call.name, metadata).pipe(
Effect.flatMap((valid) => (valid === undefined ? Effect.void : progress(valid))),
)
},
}).pipe(
Effect.map((value) => ({ value })),
Effect.catchTag("LLM.ToolFailure", (failure) => Effect.succeed({ failure: toSessionError(failure) })),
)
const outcome: ToolOutcome = yield* Effect.gen(function* () {
if ("failure" in execution) return { status: "error" as const, error: execution.failure }
const bounded = yield* resources.bound({
sessionID: input.sessionID,
callID: input.call.id,
content: yield* normalizeImages(execution.value.content),
})
const metadata = yield* validMetadata(input.call.name, execution.value.metadata)
return {
status: "completed" as const,
...(execution.value.output === undefined ? {} : { output: execution.value.output }),
content: nonEmpty(bounded.content) ?? execution.value.content,
...(metadata === undefined ? {} : { metadata }),
...(bounded.outputPaths.length > 0 ? { outputPaths: bounded.outputPaths } : {}),
}
})
const base = {
tool: input.call.name,
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
callID: input.call.id,
input: beforeEvent.input,
}
const afterEvent: ToolHooks.AfterEvent =
outcome.status === "completed"
? {
...base,
status: "completed",
content: outcome.content,
...(outcome.metadata === undefined ? {} : { metadata: outcome.metadata }),
...(outcome.outputPaths === undefined ? {} : { outputPaths: outcome.outputPaths }),
}
: {
...base,
status: "error",
error: outcome.error,
...(outcome.content === undefined ? {} : { content: outcome.content }),
...(outcome.metadata === undefined ? {} : { metadata: outcome.metadata }),
...(outcome.outputPaths === undefined ? {} : { outputPaths: outcome.outputPaths }),
}
yield* toolHooks.runAfter(afterEvent)
const afterMetadata = yield* validMetadata(input.call.name, afterEvent.metadata)
const afterContent = yield* Effect.gen(function* () {
if (
afterEvent.content === undefined ||
(outcome.status === "completed" && afterEvent.content === outcome.content)
)
return { content: afterEvent.content, outputPaths: afterEvent.outputPaths }
const bounded = yield* resources.bound({
sessionID: input.sessionID,
callID: input.call.id,
content: yield* normalizeImages(afterEvent.content),
})
return {
content: nonEmpty(bounded.content),
outputPaths:
bounded.outputPaths.length === 0
? afterEvent.outputPaths
: Array.from(new Set([...(afterEvent.outputPaths ?? []), ...bounded.outputPaths])),
}
})
if (afterEvent.status === "completed")
return {
status: "completed" as const,
...(outcome.status === "completed" && outcome.output !== undefined ? { output: outcome.output } : {}),
content: afterContent.content ?? afterEvent.content,
...(afterMetadata === undefined ? {} : { metadata: afterMetadata }),
...(afterContent.outputPaths === undefined ? {} : { outputPaths: afterContent.outputPaths }),
}
return {
status: "error" as const,
error: afterEvent.error,
...(afterContent.content === undefined ? {} : { content: afterContent.content }),
...(afterMetadata === undefined ? {} : { metadata: afterMetadata }),
...(afterContent.outputPaths === undefined ? {} : { outputPaths: afterContent.outputPaths }),
}
})
const registerBatch: Interface["registerBatch"] = Effect.fn("ToolRegistry.registerBatch")(
function* (registrations) {
const planned = yield* Effect.forEach(registrations, ({ tools, options }) =>
Effect.gen(function* () {
if (options?.namespace !== undefined) yield* validateNamespace(options.namespace)
const entries = registrationEntries(tools, options)
yield* Effect.forEach(entries, (entry) => validateName(entry.name), { discard: true })
const collision = entries.find(
(entry, index) => entries.findIndex((candidate) => candidate.key === entry.key) !== index,
)
if (collision)
return yield* Effect.fail(
new Tool.RegistrationError({
name: collision.key,
message: `Duplicate normalized tool name: ${collision.key}`,
}),
)
const codemode = options?.codemode ?? true
const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute")
if (reserved)
return yield* Effect.fail(
new Tool.RegistrationError({
name: reserved.key,
message: 'Tool name "execute" is reserved for CodeMode',
}),
)
return { tools, options, entries, codemode }
}),
)
// CodeMode registrations live in the CodeMode service; the registry keeps only direct tools.
yield* Effect.forEach(
planned.filter((plan) => plan.codemode && plan.entries.length > 0),
(plan) => codeMode.register(plan.entries),
{ discard: true },
)
const direct = planned.filter((plan) => !plan.codemode)
if (direct.every((plan) => plan.entries.length === 0)) return
yield* Effect.uninterruptible(
registrationLock.withPermit(
Effect.gen(function* () {
const token = {}
for (const { entries } of direct)
for (const entry of entries)
local.set(entry.key, [
...(local.get(entry.key) ?? []),
{
token,
registration: {
tool: entry.tool,
name: entry.name,
namespace: entry.namespace,
permission: entry.permission,
},
},
])
yield* Effect.addFinalizer(() =>
registrationLock.withPermit(
Effect.sync(() => {
for (const { entries } of direct)
for (const entry of entries) {
const registrations =
local.get(entry.key)?.filter((registration) => registration.token !== token) ?? []
if (registrations.length > 0) local.set(entry.key, registrations)
else local.delete(entry.key)
}
}),
),
)
}),
),
)
},
)
return Service.of({
register: Effect.fn("ToolRegistry.register")((tools, options) =>
registerBatch([
{
tools,
...(options === undefined ? {} : { options }),
},
]),
),
registerBatch,
snapshot: Effect.fn("ToolRegistry.snapshot")((permissions) =>
registrationLock.withPermit(
Effect.gen(function* () {
const direct = new Map<string, Registration>()
const rules = permissions ?? []
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (!registration) continue
if (whollyDisabled(registration.permission, rules)) continue
direct.set(name, registration)
}
const codeModeMaterialization = yield* codeMode.materialize(permissions)
const codemodeTool = codeModeMaterialization.tool
return {
...(codeModeMaterialization.catalog === undefined
? {}
: { codeModeCatalog: codeModeMaterialization.catalog }),
definitions: [
// Definitions are prompt-cache prefix bytes, so order only after effective registrations settle.
...Array.from(direct)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([name, registration]) => toLLMDefinition(name, registration.tool)),
...(codemodeTool ? [toLLMDefinition("execute", codemodeTool)] : []),
],
execute: (input: ExecuteInput) => {
if (input.call.name === "execute" && codemodeTool) return executeTool(input, codemodeTool)
const registration = direct.get(input.call.name)
if (registration) return executeTool(input, registration.tool)
return Effect.succeed<ToolOutcome>({
status: "error",
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
})
},
}
}),
),
),
})
}),
)
const layer = Layer.effect(
Tools.Service,
Service.use((registry) =>
Effect.succeed(Tools.Service.of({ register: registry.register, registerBatch: registry.registerBatch })),
),
).pipe(Layer.provideMerge(registryLayer))
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
return rule?.resource === "*" && rule.effect === "deny"
}
export const node = makeLocationNode({
service: Service,
layer,
deps: [CodeMode.node, ToolOutputStore.node, ToolHooks.node, Image.node],
})
export const toolsNode = makeLocationNode({
service: Tools.Service,
layer,
deps: [CodeMode.node, ToolOutputStore.node, ToolHooks.node, Image.node],
})

View file

@ -0,0 +1,125 @@
import type { ToolDefinition } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Effect, JsonSchema, Schema } from "effect"
export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
name: effectiveName(tool),
description: tool.description,
inputSchema: inputJsonSchema(tool.input),
...(tool.output === undefined ? {} : { outputSchema: outputJsonSchema(tool.output) }),
})
export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool.Context) =>
Effect.gen(function* () {
const decoded = yield* decodeInput(tool.input, input)
const result = yield* tool.execute(decoded, context)
if (tool.output === undefined) {
if ("output" in result) return yield* Effect.die("Tool result declared output without an output schema")
return {
output: undefined,
content: normalizeContent(result.content),
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
}
}
if (!("output" in result)) return yield* new Tool.Error({ message: "Tool did not return its declared output" })
const output = yield* encodeOutput(tool.output, result.output)
return {
output,
content: normalizeContent(result.content, output),
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
}
})
const decodeInput = (schema: Tool.ValueSchema<any>, value: unknown) => {
if (Schema.isSchema(schema))
return Schema.decodeUnknownEffect(schema)(value).pipe(
Effect.mapError((error) => new Tool.Error({ message: `Invalid tool input: ${error.message}` })),
)
if (isStandardSchema(schema)) return validateStandard(schema, value, "Invalid tool input")
return Effect.succeed(value)
}
const encodeOutput = (schema: Tool.ValueSchema<any>, value: unknown) => {
if (Schema.isSchema(schema))
return Schema.encodeEffect(schema)(value).pipe(
Effect.mapError(
(error) => new Tool.Error({ message: `Tool returned an invalid value for its output schema: ${error.message}` }),
),
)
if (isStandardSchema(schema))
return validateStandard(schema, value, "Tool returned an invalid value for its output schema")
return Schema.decodeUnknownEffect(Schema.Json)(value).pipe(
Effect.mapError(
(error) => new Tool.Error({ message: `Tool returned a non-JSON value for its output schema: ${error.message}` }),
),
)
}
const isStandardSchema = (
schema: Tool.ValueSchema<any>,
): schema is StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any> => "~standard" in schema
const validateStandard = (
schema: StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any>,
value: unknown,
prefix: string,
) =>
Effect.gen(function* () {
const pending = yield* Effect.try({
try: () => schema["~standard"].validate(value),
catch: (error) => standardFailure(prefix, error),
})
const result =
pending instanceof Promise
? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) })
: pending
if (result.issues)
return yield* new Tool.Error({
message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}`,
})
return result.value
})
const standardFailure = (prefix: string, error: unknown) =>
new Tool.Error({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` })
const inputJsonSchema = (schema: Tool.ValueSchema<any>): JsonSchema.JsonSchema => {
if (isStandardSchema(schema))
return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
return Schema.isSchema(schema) ? toJsonSchema(schema) : (schema as JsonSchema.JsonSchema)
}
const outputJsonSchema = (schema: Tool.ValueSchema<any>): JsonSchema.JsonSchema => {
if (isStandardSchema(schema))
return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" }) as JsonSchema.JsonSchema
return Schema.isSchema(schema) ? toJsonSchema(schema) : (schema as JsonSchema.JsonSchema)
}
const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
const document = Schema.toJsonSchemaDocument(schema)
if (Object.keys(document.definitions).length === 0) return document.schema
return { ...document.schema, $defs: document.definitions }
}
export const normalizeContent = (value: string | ReadonlyArray<Tool.Content> | undefined, output?: unknown) => {
if (typeof value === "string") return [{ type: "text" as const, text: value }]
if (value !== undefined && value.length > 0) return [...value]
return [{ type: "text" as const, text: stringify(output) }]
}
const stringify = (value: unknown) => {
if (typeof value === "string") return value
try {
return JSON.stringify(value) ?? String(value)
} catch {
return String(value)
}
}
const normalizedName = (tool: Tool.Info) => tool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
const effectiveName = (tool: Tool.Info) =>
tool.options?.namespace === undefined
? normalizedName(tool)
: `${tool.options.namespace.replaceAll(".", "_")}_${normalizedName(tool)}`

View file

@ -1,90 +0,0 @@
export * as Tool from "./tool"
export * from "@opencode-ai/plugin/v2/effect/tool"
import type { ToolContent } from "@opencode-ai/ai"
import {
decodeInput,
encodeOutput,
type Any,
type Content,
type Context,
Failure,
type Metadata,
} from "@opencode-ai/plugin/v2/effect/tool"
import { Effect, Schema } from "effect"
/** Non-empty canonical model content. */
export type NonEmptyContent = readonly [ToolContent, ...ToolContent[]]
/**
* The execution-local result of one tool call: the machine output for
* Code Mode, canonical model content, and optional UI metadata. The typed
* domain output never leaves this function.
*/
export type Execution = {
readonly output?: unknown
readonly content: NonEmptyContent
readonly metadata?: Metadata
}
export const execute = (tool: Any, input: unknown, context: Context): Effect.Effect<Execution, Failure> =>
Effect.gen(function* () {
const decoded = yield* decodeInput(tool.input, input)
const result = yield* tool.execute(decoded, context)
if (tool.output === undefined) {
if ("output" in result) return yield* Effect.die("Tool result declared output without an output schema")
return {
content: contentFrom(result.content),
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
}
}
if (!("output" in result))
return yield* Effect.fail(new Failure({ message: "Tool did not return its declared output" }))
const encoded = yield* encodeOutput(tool.output, result.output)
return {
output: encoded,
content: contentFrom(result.content, encoded),
...(result.metadata === undefined ? {} : { metadata: result.metadata }),
}
})
/** Model content from the tool's projection, falling back to the stringified encoded output. */
const contentFrom = (projected: string | ReadonlyArray<Content> | undefined, encoded?: unknown): NonEmptyContent => {
if (typeof projected === "string") return [textContent(projected)]
if (projected !== undefined) {
const mapped = nonEmpty(projected.map(toModelContent))
if (mapped !== undefined) return mapped
}
return [textContent(stringify(encoded))]
}
export const toModelContent = (part: Content): ToolContent =>
part.type === "text"
? { type: "text", text: part.text }
: { type: "file", uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name }
export const nonEmpty = (content: ReadonlyArray<ToolContent>): NonEmptyContent | undefined =>
content.length > 0 ? (content as NonEmptyContent) : undefined
const textContent = (text: string): ToolContent => ({ type: "text", text })
/** Human-readable text for an arbitrary value; strings pass through unchanged. */
export const stringify = (value: unknown) => {
if (typeof value === "string") return value
try {
return JSON.stringify(value) ?? String(value)
} catch {
return String(value)
}
}
const MetadataSchema = Schema.Record(Schema.String, Schema.Json)
/** Defensive boundary: non-JSON or oversized metadata is dropped, never failing the producing call. */
export const jsonMetadata = (value: unknown, maxBytes?: number): Metadata | undefined => {
if (value === undefined) return undefined
const decoded = Schema.decodeUnknownOption(MetadataSchema)(value)
if (decoded._tag === "None") return undefined
if (maxBytes !== undefined && Buffer.byteLength(JSON.stringify(decoded.value), "utf-8") > maxBytes) return undefined
return decoded.value
}

View file

@ -1,23 +0,0 @@
export * as Tools from "./tools"
import { Context, Effect, Scope } from "effect"
import { Tool } from "./tool"
export type RegisterOptions = Tool.RegisterOptions
export interface Interface {
readonly register: (
tools: Readonly<Record<string, Tool.Any>>,
options?: Tool.RegisterOptions,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
/** Internal atomic registration capability used by plugin transforms. */
readonly registerBatch: (
registrations: ReadonlyArray<{
readonly tools: Readonly<Record<string, Tool.Any>>
readonly options?: Tool.RegisterOptions
}>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
}
/** Narrow registration-only Location capability. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Tools") {}