refactor(tools): unify tool APIs and result handling (#38367)
This commit is contained in:
parent
8cac010bac
commit
79c1544072
133 changed files with 3602 additions and 2770 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]) => {
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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}` },
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue