refactor(tools): unify tool APIs and result handling (#38367)

This commit is contained in:
Kit Langton 2026-07-23 17:13:31 -04:00 committed by GitHub
commit 79c1544072
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
133 changed files with 3602 additions and 2770 deletions

View file

@ -4,19 +4,17 @@ import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { PermissionV2 } from "./permission"
import { ExecuteTool } from "./tool/execute"
import { permission, registrationEntries, type AnyTool } from "./tool/tool"
import { Tools } from "./tool/tools"
import type { Any, Registration } from "./tool/tool"
import { Wildcard } from "./util/wildcard"
export interface Materialization {
readonly tool?: AnyTool
readonly tool?: Any
readonly instructions?: string
}
export interface Interface {
readonly register: (
tools: Readonly<Record<string, AnyTool>>,
options?: Tools.RegisterOptions,
registrations: ReadonlyArray<Registration & { readonly key: string }>,
) => Effect.Effect<void, never, Scope.Scope>
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
}
@ -26,29 +24,28 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const local = new Map<
string,
Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>
>()
const local = new Map<string, Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>>()
return Service.of({
register: Effect.fn("CodeMode.register")(function* (tools, options) {
const entries = registrationEntries(tools, options?.namespace)
if (entries.length === 0) return
register: Effect.fn("CodeMode.register")(function* (registrations) {
if (registrations.length === 0) return
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
for (const entry of entries)
local.set(entry.key, [
...(local.get(entry.key) ?? []),
{ token, registration: { tool: entry.tool, name: entry.name, namespace: entry.namespace } },
for (const registration of registrations)
local.set(registration.key, [
...(local.get(registration.key) ?? []),
{
token,
registration,
},
])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const entry of entries) {
const registrations = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
if (registrations.length > 0) local.set(entry.key, registrations)
else local.delete(entry.key)
for (const registration of registrations) {
const remaining = local.get(registration.key)?.filter((item) => item.token !== token) ?? []
if (remaining.length > 0) local.set(registration.key, remaining)
else local.delete(registration.key)
}
}),
)
@ -61,7 +58,7 @@ const layer = Layer.effect(
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (!registration) continue
const rule = rules.findLast((rule) => Wildcard.match(permission(registration.tool, name), rule.action))
const rule = rules.findLast((rule) => Wildcard.match(registration.permission, rule.action))
if (rule?.resource === "*" && rule.effect === "deny") continue
registrations.set(name, registration)
}

View file

@ -56,5 +56,6 @@ export const migrations = (
import("./migration/20260710025429_instruction_sync"),
import("./migration/20260716020354_kv"),
import("./migration/20260722011141_delete_tool_progress_events"),
import("./migration/20260722170000_canonical_tool_results"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,123 @@
import { sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import type { DatabaseMigration } from "../migration"
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown))
const isJsonObject = Schema.is(Schema.Record(Schema.String, Schema.Json))
const object = (value: unknown): Record<string, unknown> => (isObject(value) ? value : {})
const stringify = (value: unknown) => {
try {
return JSON.stringify(value, null, 2) ?? String(value)
} catch {
return String(value)
}
}
const contentOf = (state: Record<string, unknown>) => (Array.isArray(state.content) ? state.content : [])
const resultOf = (state: Record<string, unknown>) =>
isObject(state.result) && "value" in state.result ? state.result.value : state.result
const metadataOf = (state: Record<string, unknown>) => {
if (isJsonObject(state.structured) && Object.keys(state.structured).length > 0)
return { metadata: state.structured }
return isJsonObject(state.metadata) ? { metadata: state.metadata } : {}
}
const completedContent = (state: Record<string, unknown>) => {
const preserved = contentOf(state)
if (preserved.length > 0) return preserved
return [{ type: "text", text: stringify(Object.keys(object(state.structured)).length ? state.structured : resultOf(state)) }]
}
/**
* One-time rewrite of projected tool rows into the canonical result shape:
* terminal states store model content plus optional metadata; the generic
* `structured` and `result` fields disappear. Provider-hosted result payloads
* move into provider-owned result state so hosted continuation survives.
* Pre-release durable event versions are intentionally left untouched.
*/
export default {
id: "20260722170000_canonical_tool_results",
up(tx) {
return Effect.gen(function* () {
// Keyset-paginated batches keep memory bounded: production databases hold
// gigabytes of assistant rows, and materializing them all at once was
// measured at a ~5GB RSS spike.
let cursor = ""
while (true) {
const messages = yield* tx.all<{ id: string; data: string }>(
sql`SELECT id, data FROM session_message WHERE type = 'assistant' AND id > ${cursor} ORDER BY id LIMIT 1000`,
)
if (messages.length === 0) break
cursor = messages[messages.length - 1].id
yield* rewrite(tx, messages)
}
})
},
} satisfies DatabaseMigration.Migration
function rewrite(tx: Parameters<DatabaseMigration.Migration["up"]>[0], messages: { id: string; data: string }[]) {
return Effect.gen(function* () {
for (const row of messages) {
// A row that never decoded is skipped rather than failing the whole
// migration on every startup; it was equally unreadable before.
const decoded = decodeJson(row.data)
if (decoded._tag === "None") {
yield* Effect.logWarning("skipping undecodable session_message row").pipe(Effect.annotateLogs({ id: row.id }))
continue
}
const data = object(decoded.value)
if (!Array.isArray(data.content)) continue
let changed = false
const content = data.content.map((part) => {
const tool = object(part)
if (tool.type !== "tool" || !isObject(tool.state)) return part
const state = tool.state
if (state.status !== "completed" && state.status !== "error" && state.status !== "running") return part
if (!("structured" in state) && !("result" in state)) return part
changed = true
if (state.status === "running")
return {
...tool,
state: {
status: "running",
input: object(state.input),
metadata: object(state.structured),
},
}
// Hosted payloads are irreducible provider replay state; keep them under
// the provider-owned result state instead of a generic result field.
const hosted =
tool.executed === true && isObject(state.result) && "value" in state.result
? { providerResultState: { ...object(tool.providerResultState), result: state.result.value } }
: {}
const preserved = contentOf(state)
if (state.status === "completed")
return {
...tool,
...hosted,
state: {
status: "completed",
input: object(state.input),
content: completedContent(state),
...metadataOf(state),
},
}
return {
...tool,
...hosted,
state: {
status: "error",
input: object(state.input),
error: state.error,
...(preserved.length > 0 ? { content: preserved } : {}),
...metadataOf(state),
},
}
})
if (!changed) continue
yield* tx.run(sql`UPDATE session_message SET data = ${JSON.stringify({ ...data, content })} WHERE id = ${row.id}`)
}
})
}

View file

@ -127,8 +127,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
),
},
model: {
get: (providerID, modelID) =>
catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
list: () => response(catalog.model.available()),
default: () => response(catalog.model.default()),
},
@ -358,7 +357,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
Effect.gen(function* () {
const registrations: Array<{
readonly name: string
readonly tool: Tool.AnyTool
readonly tool: Tool.Any
readonly options?: Tool.RegisterOptions
}> = []
yield* Effect.sync(() =>
@ -395,25 +394,42 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
})
}
return toolHooks.hook.after((event) => {
const output = {
// JS plugin boundary: marshal the canonical outcome out, copy mutations back.
const output: Record<string, unknown> = {
tool: event.tool,
sessionID: event.sessionID,
agent: event.agent,
messageID: event.messageID,
callID: event.callID,
input: event.input,
result: event.result,
output: event.output,
status: event.status,
content: event.content,
metadata: event.metadata,
outputPaths: event.outputPaths,
...(event.status === "error" ? { error: event.error } : {}),
}
return Reflect.apply(callback, undefined, [output]).pipe(
Effect.tap(() =>
Effect.sync(() => {
event.result = output.result
event.output = output.output
event.outputPaths = output.outputPaths
}),
),
Effect.tap(() => {
const decoded = Schema.decodeUnknownOption(Tool.ExecuteAfterOutcome)(output)
if (decoded._tag === "None")
return Effect.logWarning("ignoring invalid execute.after tool outcome", { tool: event.tool })
if (decoded.value.status !== event.status)
return Effect.logWarning("ignoring execute.after tool status change", { tool: event.tool })
return Effect.sync(() => {
if (event.status === "completed" && decoded.value.status === "completed") {
if (output.content !== event.content) event.content = decoded.value.content
if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
return
}
if (event.status === "error" && decoded.value.status === "error") {
if (output.error !== event.error) event.error = decoded.value.error
if (output.content !== event.content) event.content = decoded.value.content
if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
}
})
}),
)
})
},

View file

@ -2,7 +2,7 @@ export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Context, Plugin } from "@opencode-ai/plugin/v2/plugin"
import type { AnyTool } from "@opencode-ai/plugin/v2/tool"
import type { Any, RegisterOptions } from "@opencode-ai/plugin/v2/tool"
import { Agent } from "@opencode-ai/schema/agent"
import { Integration } from "@opencode-ai/schema/integration"
import { Location } from "@opencode-ai/schema/location"
@ -189,7 +189,8 @@ export function fromPromise(plugin: Plugin) {
register(
host.tool.transform((draft) =>
callback({
add: (tool: AnyTool) => draft.add(tool.name, fromPromiseTool(tool), tool.options),
add: (name: string, tool: Any, options?: RegisterOptions) =>
draft.add(name, fromPromiseTool(tool), options),
}),
),
),
@ -302,19 +303,8 @@ function wireEvent(value: unknown): unknown {
return wire(value)
}
function fromPromiseTool(tool: AnyTool) {
if ("jsonSchema" in tool)
return Tool.make({
...tool,
execute: (input, context) =>
Effect.promise(() =>
tool.execute(input, {
...context,
progress: (update) => Effect.runPromise(context.progress(update)),
}),
),
})
return Tool.make({
function fromPromiseTool(tool: Any): Tool.Any {
return {
...tool,
execute: (input, context) =>
Effect.promise(() =>
@ -323,5 +313,5 @@ function fromPromiseTool(tool: AnyTool) {
progress: (update) => Effect.runPromise(context.progress(update)),
}),
),
})
}
}

View file

@ -36,8 +36,8 @@ export const layer = Layer.effect(
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id)
? selection.session.id.slice(4)
: selection.session.id
const executableTools = yield* registry.materialize(selection.agent.info.permissions)
const toolDefinitions = executableTools.definitions
const toolSet = yield* registry.snapshot(selection.agent.info.permissions)
const toolDefinitions = toolSet.definitions
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
const contextEvent = yield* hooks.trigger("session", "context", {
sessionID: selection.session.id,
@ -52,7 +52,10 @@ export const layer = Layer.effect(
Message.user(input.prompt),
],
tools: Object.fromEntries(
toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
toolDefinitions.map((tool) => [
tool.name,
{ description: tool.description, input: { ...tool.inputSchema } },
]),
),
})
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {

View file

@ -355,8 +355,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
SessionMessage.ToolStateRunning.make({
status: "running",
input: event.data.input,
structured: {},
content: [],
metadata: {},
}),
)
}
@ -366,11 +365,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
match.state.structured = event.data.structured
match.state.content = [...event.data.content]
match.state.metadata = event.data.metadata
}
})
},
// Terminal tool events are self-contained; projection is a direct copy and
// never reaches into ephemeral progress history.
"session.tool.success": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
@ -382,9 +382,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
SessionMessage.ToolStateCompleted.make({
status: "completed",
input: match.state.input,
structured: event.data.structured,
content: [...event.data.content],
result: event.data.result,
content: event.data.content,
...(event.data.metadata === undefined ? {} : { metadata: event.data.metadata }),
}),
)
}
@ -402,9 +401,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
status: "error",
error: event.data.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
structured: event.data.metadata ?? (match.state.status === "running" ? match.state.structured : {}),
content: event.data.content ?? (match.state.status === "running" ? match.state.content : []),
result: event.data.result,
...(event.data.content === undefined ? {} : { content: event.data.content }),
...(event.data.metadata === undefined ? {} : { metadata: event.data.metadata }),
}),
)
}

View file

@ -14,13 +14,15 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
type ToolCallResolution =
| { readonly type: "reject"; readonly error: SessionError.Error }
| { readonly type: "settle"; readonly settle: ToolRegistry.Materialization["settle"] }
interface Prepared {
readonly request: LLMRequest
readonly resolveToolCall: (name: string) => ToolCallResolution
/**
* One request-scoped execution operation. Unknown, hook-removed, and
* step-limit-violating calls fail individually through the same seam.
*/
readonly executeTool: ToolRegistry.ToolSet["execute"]
/** True when this request is the final Step; violating calls are rejected and no continuation follows. */
readonly stepLimitReached: boolean
}
interface PrepareInput {
@ -94,14 +96,16 @@ export const layer = Layer.effect(
const model = resolved.model
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps
const executableTools = stepLimitReached ? undefined : yield* registry.materialize(agent.info.permissions)
// The final Step keeps definitions available to protocols with native "none",
// preserving their prompt cache prefix. Calls are still rejected at execution.
const toolSet = yield* registry.snapshot(agent.info.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial]
.filter((part) => part.length > 0)
.map(SystemPart.make)
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
const toolDefinitions = executableTools?.definitions ?? []
const toolDefinitions = toolSet.definitions
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
// Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit.
const contextEvent = yield* hooks.trigger("session", "context", {
@ -131,22 +135,23 @@ export const layer = Layer.effect(
tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined,
})
const resolveToolCall = (name: string): ToolCallResolution => {
if (!executableTools)
return {
type: "reject",
const executeTool: ToolRegistry.ToolSet["execute"] = (executeInput) => {
if (stepLimitReached)
return Effect.succeed({
status: "error",
error: { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" },
}
if (toolsByName.has(name) && !Object.hasOwn(contextEvent.tools, name))
return {
type: "reject",
error: { type: "tool.execution", message: `Tool is not available for this request: ${name}` },
}
return { type: "settle", settle: executableTools.settle }
})
if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name))
return Effect.succeed({
status: "error",
error: { type: "tool.unknown", message: `Tool is not available for this request: ${executeInput.call.name}` },
})
return toolSet.execute(executeInput)
}
return {
request,
resolveToolCall,
executeTool,
stepLimitReached,
}
})

View file

@ -144,21 +144,18 @@ const layer = Layer.effect(
}
yield* publish(event)
if (LLMEvent.is.toolInputError(event)) {
if (prepared.resolveToolCall(event.name).type === "settle") needsContinuation = true
if (!prepared.stepLimitReached) needsContinuation = true
return
}
if (event.type !== "tool-call" || event.providerExecuted) return
const tool = prepared.resolveToolCall(event.name)
if (tool.type === "reject") {
yield* serialized(publisher.failUnsettledTools(tool.error))
return
}
needsContinuation = true
// Unavailable calls fail individually through the same execution seam;
// continuation depends only on remaining Step allowance.
if (!prepared.stepLimitReached) needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
ownedToolFibers.push(
yield* Effect.uninterruptibleMask((restore) =>
restore(
tool.settle({
prepared.executeTool({
sessionID: session.id,
agent: agent.id,
messageID: assistantMessageID,
@ -166,17 +163,7 @@ const layer = Layer.effect(
progress: (update) => serialized(publisher.progress(event.id, update)),
}),
).pipe(
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
output: settlement.output,
}),
settlement.error,
),
),
Effect.flatMap((execution) => serialized(publisher.toolExecution(event.id, event.name, execution))),
),
).pipe(FiberSet.run(toolFibers)),
)

View file

@ -1,5 +1,5 @@
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Effect } from "effect"
import { type LLMEvent, type ProviderMetadata, type ToolContent, type ToolResultValue } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { SessionEvent } from "../event"
@ -11,6 +11,8 @@ import { AgentV2 } from "../../agent"
import { Snapshot } from "../../snapshot"
import { RelativePath } from "../../schema"
import { SessionUsage } from "../usage"
import { Tool } from "../../tool/tool"
import { MAX_BYTES } from "../../tool-output-store"
import type { ToolRegistry } from "../../tool/registry"
type Input = {
@ -25,24 +27,11 @@ type Input = {
const record = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
const message = (value: unknown) => {
if (typeof value === "string") return value
try {
return JSON.stringify(value) ?? String(value)
} catch {
return String(value)
}
}
type SettledOutput =
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
| { readonly error: SessionError.Error }
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
const settled = value ?? ToolOutput.fromResultValue(result)
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
return { structured: record(settled.structured), content: settled.content }
/** Derives canonical model content from a provider-hosted tool result. */
const hostedContent = (result: ToolResultValue): readonly [ToolContent, ...ToolContent[]] => {
if (result.type === "content" && result.value.length > 0)
return result.value as unknown as readonly [ToolContent, ...ToolContent[]]
return [{ type: "text", text: Tool.stringify(result.value) }]
}
/** Persist one step without executing tools or starting a continuation step. */
@ -60,11 +49,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
>()
const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => {
if (!tool.progress) return {}
const first = tool.progress.content[0]
return {
...(first === undefined ? {} : { content: [first, ...tool.progress.content.slice(1)] as const }),
metadata: tool.progress.structured,
}
const metadata = Tool.jsonMetadata(tool.progress, MAX_BYTES)
return metadata === undefined ? {} : { metadata }
}
let assistantMessageID = input.assistantMessageID
let stepStarted = false
@ -254,11 +240,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
let failed = false
for (const [callID, tool] of tools) {
if (
tool.settled ||
(mode === "hosted" && !tool.providerExecuted) ||
(mode === "uncalled" && tool.called)
)
if (tool.settled || (mode === "hosted" && !tool.providerExecuted) || (mode === "uncalled" && tool.called))
continue
tool.settled = true
failed = true
@ -409,26 +391,27 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
return
}
case "tool-result": {
// Provider-hosted results only; local executions publish through `toolExecution`.
const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`))
if (tool.name !== event.name)
return yield* Effect.die(new Error(`Tool result name changed for ${event.id}: ${tool.name} -> ${event.name}`))
if (tool.settled) {
// A late error result is a benign straggler (e.g. after an abort
// sweep); a late success would mean double execution, so it dies.
if (event.result.type === "error") return
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
}
tool.settled = true
const result = error ? { error } : settledOutput(event.output, event.result)
const executed = event.providerExecuted === true || tool.providerExecuted
const resultState = providerState(event.providerMetadata)
if ("error" in result) {
if (error !== undefined || event.result.type === "error") {
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
error: result.error,
error: error ?? { type: "tool.execution", message: Tool.stringify(event.result.value) },
...failureSnapshot(tool),
result: event.result,
executed,
resultState,
})
@ -438,8 +421,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
...result,
...(executed ? { result: event.result } : {}),
content: hostedContent(event.result),
executed,
resultState,
})
@ -489,19 +471,64 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const tool = tools.get(callID)
if (!tool?.called || tool.settled)
return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
const current = { structured: { ...update.structured }, content: [...update.content] }
const current = { ...update }
tool.progress = current
yield* events.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
...current,
metadata: current,
})
})
/** Publishes one canonical terminal event for a locally executed tool call. */
const toolExecution = Effect.fnUntraced(function* (
callID: string,
name: string,
execution: ToolRegistry.ToolOutcome,
) {
const tool = tools.get(callID)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${callID}`))
if (tool.name !== name)
return yield* Effect.die(new Error(`Tool execution name changed for ${callID}: ${tool.name} -> ${name}`))
if (tool.settled) {
if (execution.status === "error") return
return yield* Effect.die(new Error(`Duplicate tool execution: ${callID}`))
}
tool.settled = true
if (execution.status === "completed") {
yield* events.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
content: execution.content,
...(execution.metadata === undefined ? {} : { metadata: execution.metadata }),
executed: tool.providerExecuted,
})
return
}
// An execution-provided snapshot wins; otherwise fall back to retained progress.
const snapshot =
execution.content !== undefined || execution.metadata !== undefined
? {
...(execution.content === undefined ? {} : { content: execution.content }),
...(execution.metadata === undefined ? {} : { metadata: execution.metadata }),
}
: failureSnapshot(tool)
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID,
error: execution.error,
...snapshot,
executed: tool.providerExecuted,
})
})
return {
publish,
progress,
toolExecution,
flush,
failAssistant,
publishStepFailure,

View file

@ -1,11 +1,4 @@
import {
Message,
ToolCallPart,
ToolOutput,
ToolResultPart,
type ContentPart,
type ProviderMetadata,
} from "@opencode-ai/ai"
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
import { Option, Schema } from "effect"
import type { ModelV2 } from "../../model"
import { SessionMessage } from "../message"
@ -90,15 +83,15 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => {
if (tool.state.status === "completed") {
// TODO: Materialize remote and managed URIs before provider-history lowering.
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
const result =
tool.executed === true && tool.state.result !== undefined
? tool.state.result
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
const content = tool.state.content
const single = content.length === 1 ? content[0] : undefined
return ToolResultPart.make({
id: tool.id,
name: tool.name,
result,
result:
single?.type === "text"
? { type: "text" as const, value: single.text }
: { type: "content" as const, value: content },
providerExecuted: tool.executed,
providerMetadata,
})
@ -107,10 +100,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
return ToolResultPart.make({
id: tool.id,
name: tool.name,
result:
tool.executed === true && tool.state.result !== undefined
? tool.state.result
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
result: { error: tool.state.error, content: tool.state.content ?? [] },
resultType: "error",
providerExecuted: tool.executed,
providerMetadata,
@ -119,8 +109,8 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
}
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, providerMetadataKey: string) => {
const sameModel =
String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id)
const sameProvider = String(message.model.providerID) === String(model.providerID)
const sameModel = sameProvider && String(message.model.id) === String(model.id)
const reuseProviderMetadata = sameModel && message.error === undefined
const content = message.content.flatMap((item): ContentPart[] => {
if (item.type === "text") return [{ type: "text", text: item.text }]
@ -138,19 +128,21 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
: []
const reuseToolProviderMetadata =
reuseProviderMetadata ||
(sameModel &&
item.executed === true &&
(item.state.status === "completed" || (item.state.status === "error" && item.state.result !== undefined)))
(sameModel && item.executed === true && (item.state.status === "completed" || item.state.status === "error"))
const call = toolCall(
item,
reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
)
if (item.executed !== true) return [call]
// Hosted result payloads are provider-format state, not model state:
// replay must survive a model switch within the same provider.
const result = toolResult(
item,
reuseToolProviderMetadata
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
: undefined,
: sameProvider && item.executed === true && item.providerResultState !== undefined
? providerMetadata(providerMetadataKey, item.providerResultState)
: undefined,
)
return result ? [call, result] : [call]
})

View file

@ -39,8 +39,13 @@ export function toSessionError(cause: unknown): SessionError.Error {
}
if (cause instanceof PermissionV2.BlockedError) return { type: "permission.rejected", message: cause.message }
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message }
if (cause instanceof ToolFailure || cause instanceof Tool.Failure)
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
if (cause instanceof ToolFailure || cause instanceof Tool.Failure) {
if (cause.error === undefined) return { type: "tool.execution", message: cause.message }
// The canonical error is the sole model-visible representation, so a cause
// with no message must not erase the tool's curated failure message.
const unwrapped = toSessionError(cause.error)
return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped
}
if (cause instanceof StepFailedError) return cause.error
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }

View file

@ -8,7 +8,7 @@ import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionSchema } from "./session/schema"
import { Identifier } from "./util/identifier"
import type { ToolOutput } from "@opencode-ai/ai"
import type { ToolContent } from "@opencode-ai/ai"
export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024
@ -19,11 +19,11 @@ export const MANAGED_DIRECTORY = "tool-output"
export interface BoundInput {
readonly sessionID: SessionSchema.ID
readonly callID: string
readonly output: ToolOutput
readonly content: ReadonlyArray<ToolContent>
}
export interface BoundResult {
readonly output: ToolOutput
readonly content: ReadonlyArray<ToolContent>
readonly outputPaths: ReadonlyArray<string>
}
@ -137,21 +137,14 @@ const layer = Layer.effect(
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
const outputLimits = yield* limits()
const media = input.output.content.filter((item) => item.type === "file")
const text = input.output.content.filter((item) => item.type === "text")
const contextual =
input.output.content.length === 0
? yield* Effect.try({
try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured),
catch: (cause) => new StorageError({ operation: "encode", cause }),
})
: text.map((item) => item.text).join("")
const media = input.content.filter((item) => item.type === "file")
const contextual = input.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("")
if (
lineCount(contextual) <= outputLimits.maxLines &&
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
)
return {
output: input.output,
content: input.content,
outputPaths: [],
}
@ -159,16 +152,13 @@ const layer = Layer.effect(
const marker = `... output truncated; full content saved to ${outputPath} ...`
return {
output: {
structured: input.output.structured,
content: [
{
type: "text" as const,
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
},
...media,
],
},
content: [
{
type: "text" as const,
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
},
...media,
],
outputPaths: [outputPath],
}
})

View file

@ -1,26 +1,26 @@
# Core Tool Architecture
This folder owns Core's one local tool representation, process and Location registration, effective lookup, and settlement.
This folder owns Core's local tools, Location-scoped registrations, effective lookup, execution, and terminal outcomes.
## Representations
- `tool.ts` defines the structural canonical `Tool.make({ description, input, output, execute, toModelOutput })` declaration. Shipped built-ins and plugin tools use the same type.
- `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 definitions, invokes tools, and applies generic output bounding.
- `registry.ts` stores only canonical Location registrations, derives LLM definitions, executes 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.
## Construction
Tool schemas and projection use `input` and `output` terminology. A tool value carries its schemas, executor, projection, and optional catalog permission directly so separately loaded plugin package instances can exchange it structurally.
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.
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:
```ts
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
}
```
@ -42,13 +42,13 @@ Registrations are scoped:
## Permissions
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `patch` declare the shared `edit` action.
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.
Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement.
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 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 tool responses. `ToolRegistry.ToolSet.execute` is the only local execution and generic model-output bounding boundary and owns managed retention paths.
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`.

View file

@ -97,15 +97,11 @@ export const Plugin = {
.transform((draft) =>
draft.add(
name,
Tool.withPermission(
Tool.make({
Tool.make({
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,
output: Output,
toModelOutput: ({ input, output }) => [
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
],
execute: (input, context) => {
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
@ -207,12 +203,16 @@ export const Plugin = {
],
replacements,
} satisfies Output
})
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(output, input.oldString, input.newString),
metadata: { files: output.files },
})),
)
},
}),
"edit",
),
{ codemode: false },
{ codemode: false, permission: "edit" },
),
)
.pipe(Effect.orDie)

View file

@ -1,9 +1,10 @@
export * as ExecuteTool from "./execute"
export type { Registration } from "./tool"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import { ToolOutput } from "@opencode-ai/ai"
import type { ToolContent } from "@opencode-ai/ai"
import { Effect, Ref, Schema } from "effect"
import { definition, make, settle, type AnyTool } from "./tool"
import { execute, make, toLLMDefinition, type Content, type Metadata, type Registration } from "./tool"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@ -14,16 +15,11 @@ const ExecuteFile = Schema.Struct({
const ExecuteCall = Schema.Struct({
tool: Schema.String,
status: Schema.Literals(["running", "completed", "error"]),
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
})
type ExecuteCall = typeof ExecuteCall.Type
const ExecuteMetadata = Schema.Struct({
toolCalls: Schema.Array(ExecuteCall),
error: Schema.optionalKey(Schema.Literal(true)),
})
const ExecuteOutput = Schema.Struct({
output: Schema.String,
toolCalls: Schema.Array(ExecuteCall),
@ -36,12 +32,6 @@ type CollectedFiles = {
readonly files: Array<typeof ExecuteFile.Type>
}
export interface Registration {
readonly tool: AnyTool
readonly name: string
readonly namespace?: string
}
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript in a confined Code Mode runtime through { code }.",
@ -55,20 +45,6 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
description,
input: CodeMode.Input,
output: ExecuteOutput,
structured: ExecuteMetadata,
toStructuredOutput: ({ output }) => ({
toolCalls: output.toolCalls,
...(output.error ? { error: true as const } : {}),
}),
toModelOutput: ({ output }) => [
{ type: "text" as const, text: output.output },
...output.files.map((file) => ({
type: "file" as const,
data: file.data,
mime: file.mime,
...(file.name === undefined ? {} : { name: file.name }),
})),
],
execute: ({ code }, context) =>
Effect.gen(function* () {
const callIndex = yield* Ref.make(0)
@ -85,21 +61,17 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
(name, registration, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const output = yield* settle(
registration.tool,
{ type: "tool-call", id: context.callID, name, input },
{
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
progress: context.progress,
},
).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
const outputFileParts = outputFiles(output)
const executed = yield* execute(registration.tool, input, {
sessionID: context.sessionID,
agent: context.agent,
messageID: context.messageID,
callID: context.callID,
progress: context.progress,
}).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 output.structured
return executed.output
}),
{
onToolCallStart: ({ index, name, input }) =>
@ -126,7 +98,30 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
.toSorted((left, right) => left.index - right.index)
.flatMap((item) => item.files)
const output = formatResult(result)
return { output, toolCalls, files: collected, ...(result.ok ? {} : { error: true as const }) }
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,
}
}),
})
}
@ -137,28 +132,30 @@ export const instructions = (registrations: ReadonlyMap<string, Registration>) =
function runtime(
registrations: ReadonlyMap<string, Registration>,
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
executeTool: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) {
const tools: Record<string, Tool.Definition<never>> = {}
const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) {
const child = definition(name, registration.tool)
const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
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,
run: (input) => invoke(name, registration, input),
execute: (input) => executeTool(name, registration, input),
})
}
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function displayInput(input: unknown): Record<string, unknown> | undefined {
// 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 }
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, unknown>
return input as Record<string, typeof Schema.Json.Type>
}
function formatResult(result: CodeMode.Result) {
@ -180,8 +177,8 @@ function formatValue(value: CodeMode.DataValue) {
return JSON.stringify(value, null, 2) ?? String(value)
}
function outputFiles(output: ToolOutput): Array<typeof ExecuteFile.Type> {
return output.content.flatMap((part) => {
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 []

View file

@ -8,7 +8,7 @@ import { FileSystem } from "../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { NonNegativeInt, RelativePath } from "../schema"
import { RelativePath } from "../schema"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
@ -25,9 +25,6 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Array(FileSystem.Entry)
const StructuredOutput = Schema.Struct({
count: NonNegativeInt,
})
type ModelOutput = typeof Output.Encoded
/** Format raw search results into the concise line-oriented output models expect. */
@ -54,16 +51,6 @@ export const Plugin = {
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ count: output.length }),
toModelOutput: ({ output }) => [
{
type: "text",
text: toModelOutput(
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
),
},
],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
@ -104,6 +91,13 @@ export const Plugin = {
),
)
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
),
metadata: { count: output.length },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error

View file

@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
import { Ripgrep } from "../ripgrep"
import { NonNegativeInt, RelativePath } from "../schema"
import { RelativePath } from "../schema"
import { Tool } from "./tool"
export const name = "grep"
@ -30,9 +30,6 @@ export const Input = Schema.Struct({
})
export const Output = Schema.Array(FileSystem.Match)
const StructuredOutput = Schema.Struct({
matches: NonNegativeInt,
})
type ModelOutput = typeof Output.Encoded
/** Format raw search matches into the familiar concise model output. */
@ -68,19 +65,6 @@ export const Plugin = {
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ matches: output.length }),
toModelOutput: ({ output }) => [
{
type: "text",
text: toModelOutput(
output.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
),
},
],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
@ -135,6 +119,16 @@ export const Plugin = {
),
)
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(
output.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
),
metadata: { matches: output.length },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error

View file

@ -1,33 +1,14 @@
export * as ToolHooks from "./hooks"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "../session/message"
import { State } from "../state"
import { Context, Effect, Layer, Scope } from "effect"
import type { ToolOutput, ToolResultValue } from "@opencode-ai/ai"
import type { Tool } from "./tool"
export interface BeforeEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
input: unknown
}
export type BeforeEvent = Tool.ToolExecuteBeforeEvent
export interface AfterEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly callID: string
readonly input: unknown
result: ToolResultValue
output?: ToolOutput
outputPaths?: ReadonlyArray<string>
}
/** The canonical execution outcome. Hooks never observe the raw domain output. */
export type AfterEvent = Tool.ToolExecuteAfterEvent
export interface Interface {
readonly hook: {

View file

@ -32,86 +32,90 @@ 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.AnyTool>; codemode: boolean }>()
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.withPermission(
Tool.make({
description: tool.description ?? "",
jsonSchema: {
...schema,
type: "object",
properties: schema.properties ?? {},
additionalProperties: false,
},
outputSchema: tool.outputSchema as JsonSchema.JsonSchema | undefined,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: name(tool.server, tool.name),
resources: ["*"],
save: ["*"],
metadata: {},
sessionID: context.sessionID,
agent: context.agent,
source: {
type: "tool",
messageID: context.messageID,
callID: context.callID,
},
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* () {
yield* permission.assert({
action: name(tool.server, tool.name),
resources: ["*"],
save: ["*"],
metadata: {},
sessionID: context.sessionID,
agent: context.agent,
source: {
type: "tool",
messageID: context.messageID,
callID: context.callID,
},
})
const result = yield* mcp
.callTool({
server: tool.server,
name: tool.name,
args: (input ?? {}) as Record<string, unknown>,
})
const result = yield* mcp
.callTool({
server: tool.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:
result.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim() || "MCP tool returned an error",
})
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 },
.pipe(
Effect.catchTags({
"MCP.NotFoundError": (error) =>
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
}),
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return {
structured: result.structured ?? (text === "" ? null : text),
content,
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
if (result.isError)
return yield* new ToolFailure({
message:
result.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
.trim() || "MCP tool returned an error",
})
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 },
)
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[]] }),
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
),
}),
name(tool.server, tool.name),
)
),
})
groups.set(tool.server, group)
}
const next = yield* Scope.fork(scope)
yield* Effect.forEach(
groups,
([server, group]) => tools.register(group.tools, { namespace: namespace(server), codemode: group.codemode }),
{
discard: true,
},
).pipe(Scope.provide(next), Effect.orDie)
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
}),

View file

@ -75,12 +75,10 @@ export const Plugin = {
.transform((draft) =>
draft.add(
name,
Tool.withPermission(
Tool.make({
Tool.make({
description: DESCRIPTION,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (path: string, error?: unknown) => {
@ -278,12 +276,17 @@ export const Plugin = {
{ discard: true },
)
return { applied, files: patchFiles }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))))
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(output),
metadata: { files: output.files },
})),
Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))),
)
},
}),
"edit",
),
{ codemode: false },
{ codemode: false, permission: "edit" },
),
)
.pipe(Effect.orDie)

View file

@ -63,9 +63,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
{ type: "text", text: toModelOutput(input.questions, output.answers) },
],
execute: (input, context) =>
permission
.assert({
@ -95,13 +92,18 @@ export const Plugin = {
),
Effect.flatMap((state) => {
if (state.status === "cancelled") return Effect.die(new CancelledError())
return Effect.succeed({
const output = {
answers: input.questions.map((_, index): QuestionV2.Answer => {
const value = state.answer[`q${index}`]
if (value === undefined) return []
if (typeof value === "object") return Array.from(value)
return [String(value)]
}),
}
return Effect.succeed({
output,
content: toModelOutput(input.questions, output.answers),
metadata: { answers: output.answers },
})
}),
),

View file

@ -48,20 +48,6 @@ export const Plugin = {
"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,
output: Output,
structured: Schema.toEncoded(Output),
// Image base64 reaches the model through content items (normalized generically
// at tool settlement); persisting a second copy in structured would store the
// original unresized bytes in the message row.
toStructuredOutput: ({ output }) =>
"encoding" in output && output.encoding === "base64" ? { ...output, content: "" } : output,
toModelOutput: ({ input, output }) => {
if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime))
return []
return [
{ type: "text", text: "Image read successfully" },
{ type: "file", data: output.content, mime: output.mime, name: input.path },
]
},
execute: (input, context) => {
return Effect.gen(function* () {
const source = {
@ -125,6 +111,20 @@ export const Plugin = {
return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource }))
return content
}).pipe(
Effect.map((output) => {
// Image base64 reaches the model through content items; avoid a second
// unresized copy in model text.
const content =
"encoding" in output && output.encoding === "base64"
? SUPPORTED_IMAGE_MIMES.has(output.mime)
? ([
{ type: "text", text: "Image read successfully" },
{ type: "file", data: output.content, mime: output.mime, name: input.path },
] as const)
: JSON.stringify({ ...output, content: "" }, null, 2)
: JSON.stringify(output, null, 2)
return { output, content }
}),
Effect.mapError((error) => {
const message =
error instanceof ReadToolFileSystem.BinaryFileError ||

View file

@ -1,7 +1,7 @@
export * as ToolRegistry from "./registry"
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/ai"
import { Context, Effect, Layer, Scope, Semaphore } from "effect"
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 { Image } from "../image"
import { PermissionV2 } from "../permission"
@ -10,19 +10,10 @@ import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { CodeMode } from "../codemode"
import {
definition,
permission,
registrationEntries,
RegistrationError,
settle,
validateNamespace,
type AnyTool,
} from "./tool"
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 { SessionError } from "@opencode-ai/schema/session-error"
import { toSessionError } from "../session/to-session-error"
export type ExecuteInput = {
@ -33,38 +24,42 @@ export type ExecuteInput = {
readonly progress?: (update: Progress) => Effect.Effect<void>
}
export interface Progress {
readonly structured: Readonly<Record<string, unknown>>
readonly content: ToolOutput["content"]
}
/** Live replacement metadata for a running tool. */
export type Progress = Tool.Metadata
export interface Interface {
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
readonly snapshot: (permissions?: PermissionV2.Ruleset) => Effect.Effect<ToolSet>
/** Internal registration capability exposed publicly only through Tools.Service. */
readonly register: (
tools: Readonly<Record<string, AnyTool>>,
tools: Readonly<Record<string, Tool.Any>>,
options?: Tools.RegisterOptions,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
/** Internal atomic registration capability used by plugin transforms. */
readonly registerBatch: (
registrations: ReadonlyArray<{
readonly tools: Readonly<Record<string, AnyTool>>
readonly tools: Readonly<Record<string, Tool.Any>>
readonly options?: Tools.RegisterOptions
}>,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
}
export interface Materialization {
/**
* One request-scoped snapshot pairing advertised definitions with captured
* tools. A model request executes exactly the tool values it advertised
* even if registration changes while the request is in flight.
*/
export interface ToolSet {
readonly definitions: ReadonlyArray<ToolDefinition>
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolOutcome, ToolOutputStore.Error>
}
export interface Settlement {
readonly result: ToolResultValue
readonly output?: ToolOutput
readonly outputPaths?: ReadonlyArray<string>
readonly error?: SessionError.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") {}
@ -76,26 +71,24 @@ const registryLayer = Layer.effect(
const image = yield* Image.Service
const codeMode = yield* CodeMode.Service
type NormalizedItem = ToolOutput["content"][number] | "decode" | "size"
const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ToolOutput["content"]) {
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)),
)
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
@ -108,16 +101,24 @@ const registryLayer = Layer.effect(
...note("size", "could not be resized below the image size limit."),
]
})
type Registration = {
readonly tool: AnyTool
readonly name: string
readonly namespace?: string
}
// 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 settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) {
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool.
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,
@ -127,76 +128,100 @@ const registryLayer = Layer.effect(
input: input.call.input,
}
yield* toolHooks.runBefore(beforeEvent)
const pending = yield* settle(
tool,
{ ...input.call, input: beforeEvent.input },
{
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
callID: input.call.id,
progress: (update) => {
const progress = input.progress
if (!progress) return Effect.void
return normalizeImages(
(update.content ?? []).map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
uri: `data:${part.mime};base64,${part.data}`,
mime: part.mime,
name: part.name,
},
),
).pipe(Effect.flatMap((content) => progress({ structured: update.structured, content })))
},
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((output) => ({ output })),
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({
result: { type: "error" as const, value: failure.message },
error: toSessionError(failure),
}),
),
}).pipe(
Effect.map((value) => ({ value })),
Effect.catchTag("LLM.ToolFailure", (failure) => Effect.succeed({ failure: toSessionError(failure) })),
)
let settlement: Settlement
if ("result" in pending) {
settlement = pending
} else {
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,
output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) },
content: yield* normalizeImages(execution.value.content),
})
const result = ToolOutput.toResultValue(bounded.output)
settlement =
result.type === "error"
? bounded.outputPaths.length > 0
? { result, outputPaths: bounded.outputPaths }
: { result }
: bounded.outputPaths.length > 0
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
: { result, output: bounded.output }
}
const afterEvent: ToolHooks.AfterEvent = {
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,
result: settlement.result,
output: settlement.output,
outputPaths: settlement.outputPaths,
}
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 {
result: afterEvent.result,
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
...(settlement.error !== undefined ? { error: settlement.error } : {}),
status: "error" as const,
error: afterEvent.error,
...(afterContent.content === undefined ? {} : { content: afterContent.content }),
...(afterMetadata === undefined ? {} : { metadata: afterMetadata }),
...(afterContent.outputPaths === undefined ? {} : { outputPaths: afterContent.outputPaths }),
}
})
@ -205,12 +230,26 @@ const registryLayer = Layer.effect(
const planned = yield* Effect.forEach(registrations, ({ tools, options }) =>
Effect.gen(function* () {
if (options?.namespace !== undefined) yield* validateNamespace(options.namespace)
const entries = registrationEntries(tools, 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 RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
new Tool.RegistrationError({
name: reserved.key,
message: 'Tool name "execute" is reserved for CodeMode',
}),
)
return { tools, options, entries, codemode }
}),
@ -218,7 +257,7 @@ const registryLayer = Layer.effect(
// 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.tools, plan.options),
(plan) => codeMode.register(plan.entries),
{ discard: true },
)
const direct = planned.filter((plan) => !plan.codemode)
@ -237,6 +276,7 @@ const registryLayer = Layer.effect(
tool: entry.tool,
name: entry.name,
namespace: entry.namespace,
permission: entry.permission,
},
},
])
@ -269,7 +309,7 @@ const registryLayer = Layer.effect(
]),
),
registerBatch,
materialize: Effect.fn("ToolRegistry.materialize")((permissions) =>
snapshot: Effect.fn("ToolRegistry.snapshot")((permissions) =>
registrationLock.withPermit(
Effect.gen(function* () {
const direct = new Map<string, Registration>()
@ -277,21 +317,21 @@ const registryLayer = Layer.effect(
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (!registration) continue
if (whollyDisabled(permission(registration.tool, name), rules)) continue
if (whollyDisabled(registration.permission, rules)) continue
direct.set(name, registration)
}
const execute = (yield* codeMode.materialize(permissions)).tool
const codemodeTool = (yield* codeMode.materialize(permissions)).tool
return {
definitions: [
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
...(execute ? [definition("execute", execute)] : []),
...Array.from(direct, ([name, registration]) => toLLMDefinition(name, registration.tool)),
...(codemodeTool ? [toLLMDefinition("execute", codemodeTool)] : []),
],
settle: (input: ExecuteInput) => {
if (input.call.name === "execute" && execute) return settleTool(input, execute)
execute: (input: ExecuteInput) => {
if (input.call.name === "execute" && codemodeTool) return executeTool(input, codemodeTool)
const registration = direct.get(input.call.name)
if (registration) return settleTool(input, registration.tool)
return Effect.succeed({
result: { type: "error", value: `Unknown tool: ${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}` },
})
},

View file

@ -3,7 +3,7 @@ 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 { Effect, Fiber, Schedule, Schema, Scope } from "effect"
import { Deferred, Effect, Schema, Scope } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission"
@ -147,19 +147,6 @@ export const Plugin = {
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,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({
truncated: output.truncated,
...(output.exit === undefined ? {} : { exit: output.exit }),
...(output.shellID === undefined ? {} : { shellID: output.shellID }),
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
}),
toModelOutput: ({ output }) => {
const parts: Content[] = [{ type: "text", text: output.output }]
const model = modelOutput(output)
if (model) parts.push({ type: "text", text: model })
return parts
},
execute: (input, context) =>
Effect.gen(function* () {
const source = {
@ -199,6 +186,7 @@ export const Plugin = {
timeout,
metadata: { sessionID: context.sessionID },
})
yield* context.progress({ shellID: info.id })
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
@ -232,7 +220,9 @@ export const Plugin = {
}
})
const settled = yield* Deferred.make<Output>()
const run = settleShell().pipe(
Effect.tap((output) => Deferred.succeed(settled, output)),
Effect.map((output) => output.output),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
@ -256,32 +246,8 @@ export const Plugin = {
}
}
let previousProgress: { readonly output: string; readonly truncated: boolean } | undefined
const progress = yield* Effect.sleep("1 second").pipe(
Effect.andThen(
captureShell().pipe(
Effect.flatMap((capture) =>
Effect.gen(function* () {
if (
previousProgress?.output === capture.output &&
previousProgress.truncated === capture.truncated
)
return
previousProgress = capture
yield* context.progress({
structured: { truncated: capture.truncated },
content: [{ type: "text", text: capture.output }],
})
}),
),
),
),
Effect.repeat(Schedule.forever),
Effect.forkIn(scope, { startImmediately: true }),
)
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
Effect.ensuring(Fiber.interrupt(progress)),
)
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
@ -298,11 +264,23 @@ export const Plugin = {
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
return {
...(yield* settleShell()),
...(warnings.length ? { warnings } : {}),
}
return { ...(yield* Deferred.await(settled)), ...(warnings.length ? { warnings } : {}) }
}).pipe(
Effect.map((output) => {
const content: [Content, ...Content[]] = [{ type: "text", text: output.output }]
const model = modelOutput(output)
if (model) content.push({ type: "text", text: model })
return {
output,
content,
metadata: {
truncated: output.truncated,
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
},
}
}),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),

View file

@ -21,11 +21,6 @@ export const Output = Schema.Struct({
directory: Schema.String,
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
name: Output.fields.name,
directory: Output.fields.directory,
})
export const description = [
"Load a specialized skill when the task at hand matches one of the available skills in the instructions.",
"",
@ -70,9 +65,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ name: output.name, directory: output.directory }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
const current = yield* skills.list()
@ -101,7 +93,13 @@ export const Plugin = {
output: toModelOutput(skill, files),
}
}).pipe(Effect.mapError((error) => unableToLoad(input.id, error)))
}),
}).pipe(
Effect.map((output) => ({
output,
content: output.output,
metadata: { name: output.name, directory: output.directory },
})),
),
}),
{ codemode: false },
),

View file

@ -31,11 +31,6 @@ export const Output = Schema.Struct({
status: Schema.Literals(["completed", "running"]),
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
sessionID: Output.fields.sessionID,
status: Output.fields.status,
})
export const description = [
"Spawn a subagent: a child session running a configured agent with fresh context.",
"Foreground (default) runs the subagent to completion and returns its final response.",
@ -119,9 +114,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ sessionID: output.sessionID, status: output.status }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
const parent = yield* runtime.session
@ -186,7 +178,7 @@ export const Plugin = {
const background = input.background === true
yield* context.progress({
structured: { sessionID: child.id, status: "running" },
metadata: { sessionID: child.id, status: "running" },
})
const run = Effect.gen(function* () {
@ -238,7 +230,13 @@ export const Plugin = {
if (result?.info.status === "cancelled")
return yield* new ToolFailure({ message: "Subagent cancelled" })
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
}),
}).pipe(
Effect.map((output) => ({
output,
content: output.output,
metadata: { sessionID: output.sessionID, status: output.status },
})),
),
}),
{ codemode: false },
),

View file

@ -1,2 +1,90 @@
export * as Tool from "@opencode-ai/plugin/v2/effect/tool"
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

@ -7,13 +7,13 @@ export type RegisterOptions = Tool.RegisterOptions
export interface Interface {
readonly register: (
tools: Readonly<Record<string, Tool.AnyTool>>,
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.AnyTool>>
readonly tools: Readonly<Record<string, Tool.Any>>
readonly options?: Tool.RegisterOptions
}>,
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>

View file

@ -37,10 +37,6 @@ const Output = Schema.Struct({
format: Input.fields.format,
output: Schema.String,
})
const StructuredOutput = Schema.Struct({
contentType: Output.fields.contentType,
})
type Format = (typeof Input.Type)["format"]
const acceptHeader = (format: Format) => {
@ -129,9 +125,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ contentType: output.contentType }),
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
execute: (input, context) =>
Effect.gen(function* () {
yield* Effect.try({
@ -171,12 +164,13 @@ export const Plugin = {
try: () => convert(content, contentType, input.format),
catch: (error) => error,
})
return {
const result = {
url: input.url,
contentType,
format: input.format,
output,
}
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 },

View file

@ -190,10 +190,6 @@ const Output = Schema.Struct({
provider: Provider,
text: Schema.String,
})
const StructuredOutput = Schema.Struct({
provider: Output.fields.provider,
})
export const Plugin = {
id: "opencode.tool.websearch",
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
@ -209,9 +205,6 @@ export const Plugin = {
description,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({ provider: output.provider }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: (input, context) => {
const provider = selectProvider(context.sessionID, config, config.provider)
return Effect.gen(function* () {
@ -250,10 +243,11 @@ export const Plugin = {
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
},
)
return {
const output = {
provider,
text: text ?? NO_RESULTS,
}
return { output, content: output.text, metadata: { provider: output.provider } }
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),

View file

@ -53,13 +53,11 @@ export const Plugin = {
.transform((draft) =>
draft.add(
name,
Tool.withPermission(
Tool.make({
Tool.make({
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,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
const source = {
@ -86,12 +84,11 @@ export const Plugin = {
})
return yield* files.writeTextPreservingBom({ target, content: input.content })
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}),
"edit",
),
{ codemode: false },
{ codemode: false, permission: "edit" },
),
)
.pipe(Effect.orDie)