From 1ce607c2308e7b9f4d76421e3e5249184112c63f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 30 Jun 2026 23:30:56 -0400 Subject: [PATCH] refactor(plugin): move tool implementation to plugin (#34665) --- bun.lock | 3 + packages/core/src/plugin/host.ts | 3 +- packages/core/src/tool/tool.ts | 216 +----------------------- packages/plugin/package.json | 3 + packages/plugin/src/v2/effect/index.ts | 1 + packages/plugin/src/v2/effect/tool.ts | 217 ++++++++++++++++++++++++- packages/plugin/tsconfig.json | 3 +- packages/sdk-next/package.json | 1 + packages/sdk-next/src/tool.ts | 4 +- 9 files changed, 230 insertions(+), 221 deletions(-) diff --git a/bun.lock b/bun.lock index 69cb554417..855847c8f3 100644 --- a/bun.lock +++ b/bun.lock @@ -680,7 +680,9 @@ "version": "1.17.11", "dependencies": { "@ai-sdk/provider": "3.0.8", + "@opencode-ai/llm": "workspace:*", "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", "zod": "catalog:", @@ -744,6 +746,7 @@ "dependencies": { "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", + "@opencode-ai/plugin": "workspace:*", "@opencode-ai/server": "workspace:*", "effect": "catalog:", }, diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 6aab6a12d9..6c1d6a67ae 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -16,7 +16,6 @@ import { ProviderV2 } from "../provider" import { Reference } from "../reference" import { AbsolutePath, type DeepMutable } from "../schema" import { SkillV2 } from "../skill" -import { Tool } from "../tool/tool" import { Tools } from "../tool/tools" import { WorkspaceV2 } from "../workspace" @@ -247,7 +246,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int ), }, tool: { - register: (input) => tools.register(input as Readonly>), + register: (input) => tools.register(input), }, session: { create: (input) => diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 1968fc338b..7525c9c0dd 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -1,214 +1,2 @@ -export * as Tool from "./tool" - -import { ToolDefinition, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm" -import { Effect, JsonSchema, Schema } from "effect" -import type { AgentV2 } from "../agent" -import type { SessionMessage } from "../session/message" -import type { SessionSchema } from "../session/schema" - -export interface Context { - readonly sessionID: SessionSchema.ID - readonly agent: AgentV2.ID - readonly assistantMessageID: SessionMessage.ID - readonly toolCallID: string -} - -export type SchemaType = Schema.Codec - -declare const TypeId: unique symbol - -export interface Definition, Output extends SchemaType> { - readonly [TypeId]: { - readonly _Input: Input - readonly _Output: Output - } -} - -export type AnyTool = Definition -export const Failure = ToolFailure -export type Failure = ToolFailure - -export class RegistrationError extends Schema.TaggedErrorClass()("Tool.RegistrationError", { - name: Schema.String, - message: Schema.String, -}) {} - -export type Content = - | { readonly type: "text"; readonly text: string } - | { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string } - -type Config< - Input extends SchemaType, - Output extends SchemaType, - Structured extends SchemaType = Output, -> = { - readonly description: string - readonly input: Input - readonly output: Output - readonly structured?: Structured - readonly toStructuredOutput?: (input: { - readonly input: Schema.Schema.Type - readonly output: Output["Encoded"] - }) => Schema.Schema.Type - readonly execute: ( - input: Schema.Schema.Type, - context: Context, - ) => Effect.Effect, ToolFailure> - readonly toModelOutput?: (input: { - readonly input: Schema.Schema.Type - readonly output: Output["Encoded"] - }) => ReadonlyArray -} - -export type DynamicOutput = { - readonly structured: unknown - readonly content: ReadonlyArray -} - -/** - * Config for a tool whose input shape is a raw JSON Schema not known at compile - * time (MCP servers, plugin manifests). Input is passed through as `unknown`; - * `execute` returns the already-projected structured value and model content. - */ -type DynamicConfig = { - readonly description: string - readonly jsonSchema: JsonSchema.JsonSchema - readonly outputSchema?: JsonSchema.JsonSchema - readonly execute: (input: unknown, context: Context) => Effect.Effect -} - -type Runtime = { - readonly permission?: string - readonly definition: (name: string) => ToolDefinition - readonly settle: (call: ToolCall, context: Context) => Effect.Effect -} - -const runtimes = new WeakMap() - -export function make< - Input extends SchemaType, - Output extends SchemaType, - Structured extends SchemaType = Output, ->(config: Config): Definition -export function make(config: DynamicConfig): AnyTool -export function make(config: Config | DynamicConfig): AnyTool { - if ("jsonSchema" in config) return makeDynamic(config) - return makeTyped(config) -} - -function makeTyped< - Input extends SchemaType, - Output extends SchemaType, - Structured extends SchemaType = Output, ->(config: Config): Definition { - const tool = Object.freeze({}) as Definition - const definitions = new Map() - runtimes.set(tool, { - definition: (name) => { - const cached = definitions.get(name) - if (cached) return cached - const definition = new ToolDefinition({ - name, - description: config.description, - inputSchema: toJsonSchema(config.input), - outputSchema: toJsonSchema(config.structured ?? config.output), - }) - definitions.set(name, definition) - return definition - }, - settle: (call, context) => - Schema.decodeUnknownEffect(config.input)(call.input).pipe( - Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })), - Effect.flatMap((input) => - config.execute(input, context).pipe( - Effect.flatMap((output) => - Schema.encodeEffect(config.output)(output).pipe( - Effect.flatMap((output) => { - if (!config.structured || !config.toStructuredOutput) - return Effect.succeed({ output, structured: output }) - return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe( - Effect.map((structured) => ({ output, structured })), - ) - }), - Effect.mapError( - (error) => - new ToolFailure({ - message: `Tool returned an invalid value for its output schema: ${error.message}`, - }), - ), - ), - ), - Effect.map(({ output, structured }) => ({ - structured, - content: - config.toModelOutput?.({ input, output }).map(toModelContent) ?? - (typeof output === "string" ? [{ type: "text" as const, text: output }] : []), - })), - ), - ), - ), - }) - return tool -} - -function makeDynamic(config: DynamicConfig): AnyTool { - const tool = Object.freeze({}) as AnyTool - const definitions = new Map() - runtimes.set(tool, { - definition: (name) => { - const cached = definitions.get(name) - if (cached) return cached - const definition = new ToolDefinition({ - name, - description: config.description, - inputSchema: config.jsonSchema, - outputSchema: config.outputSchema, - }) - definitions.set(name, definition) - return definition - }, - settle: (call, context) => - config - .execute(call.input, context) - .pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))), - }) - return tool -} - -function toModelContent(part: Content) { - if (part.type === "text") return { type: "text" as const, text: part.text } - return { type: "file" as const, uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name } -} - -export const validateName = (name: string) => - /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name) - ? Effect.void - : Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` })) - -export const registrationEntries = (tools: Readonly>) => - Object.entries(tools).map(([name, tool]) => [name.replace(/[^a-zA-Z0-9_-]/g, "_"), tool] as const) - -export const withPermission = , Output extends SchemaType>( - tool: Definition, - permission: string, -) => { - const decorated = Object.freeze({}) as Definition - runtimes.set(decorated, { ...runtimeOf(tool), permission }) - return decorated -} - -export const permission = (tool: AnyTool, name: string) => runtimeOf(tool).permission ?? name -export const definition = (name: string, tool: AnyTool) => runtimeOf(tool).definition(name) -export const settle = (tool: AnyTool, call: ToolCall, context: Context) => runtimeOf(tool).settle(call, context) - -function runtimeOf(tool: AnyTool) { - const runtime = runtimes.get(tool) - if (!runtime) throw new TypeError("Invalid Core Tool value") - return runtime -} - -function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema { - const document = Schema.toJsonSchemaDocument(schema) - if (Object.keys(document.definitions).length === 0) return document.schema - return { ...document.schema, $defs: document.definitions } -} +export * as Tool from "@opencode-ai/plugin/v2/effect/tool" +export * from "@opencode-ai/plugin/v2/effect/tool" diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 46f658eebc..4bb969bad9 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -15,6 +15,7 @@ "./v2/effect": "./src/v2/effect/index.ts", "./v2/effect/integration": "./src/v2/effect/integration.ts", "./v2/effect/plugin": "./src/v2/effect/plugin.ts", + "./v2/effect/tool": "./src/v2/effect/tool.ts", "./v2/promise": "./src/v2/promise/index.ts" }, "files": [ @@ -22,7 +23,9 @@ ], "dependencies": { "@ai-sdk/provider": "3.0.8", + "@opencode-ai/llm": "workspace:*", "@opencode-ai/protocol": "workspace:*", + "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", "zod": "catalog:" diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index d55346d308..c1694e533e 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -1,5 +1,6 @@ export type { PluginContext } from "./context.js" export { define } from "./plugin.js" export type { Plugin } from "./plugin.js" +export * as Tool from "./tool.js" export type { ToolDomain } from "./tool.js" export type { SessionDomain } from "./runtime.js" diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index f62d005a98..223de7a8b8 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -1,5 +1,218 @@ -import type { Effect, Scope } from "effect" +export * as Tool from "./tool.js" + +import { ToolDefinition, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm" +import { Agent } from "@opencode-ai/schema/agent" +import { Session } from "@opencode-ai/schema/session" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import { Effect, JsonSchema, Schema, type Scope } from "effect" + +export interface Context { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly assistantMessageID: SessionMessage.ID + readonly toolCallID: string +} + +export type SchemaType = Schema.Codec + +declare const TypeId: unique symbol + +export interface Definition, Output extends SchemaType> { + readonly [TypeId]: { + readonly _Input: Input + readonly _Output: Output + } +} + +export type AnyTool = Definition +export const Failure = ToolFailure +export type Failure = ToolFailure + +export class RegistrationError extends Schema.TaggedErrorClass()("Tool.RegistrationError", { + name: Schema.String, + message: Schema.String, +}) {} + +export type Content = + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string } + +type Config< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +> = { + readonly description: string + readonly input: Input + readonly output: Output + readonly structured?: Structured + readonly toStructuredOutput?: (input: { + readonly input: Schema.Schema.Type + readonly output: Output["Encoded"] + }) => Schema.Schema.Type + readonly execute: ( + input: Schema.Schema.Type, + context: Context, + ) => Effect.Effect, ToolFailure> + readonly toModelOutput?: (input: { + readonly input: Schema.Schema.Type + readonly output: Output["Encoded"] + }) => ReadonlyArray +} + +export type DynamicOutput = { + readonly structured: unknown + readonly content: ReadonlyArray +} + +/** + * Config for a tool whose input shape is a raw JSON Schema not known at compile + * time (MCP servers, plugin manifests). Input is passed through as `unknown`; + * `execute` returns the already-projected structured value and model content. + */ +type DynamicConfig = { + readonly description: string + readonly jsonSchema: JsonSchema.JsonSchema + readonly outputSchema?: JsonSchema.JsonSchema + readonly execute: (input: unknown, context: Context) => Effect.Effect +} + +type Runtime = { + readonly permission?: string + readonly definition: (name: string) => ToolDefinition + readonly settle: (call: ToolCall, context: Context) => Effect.Effect +} + +const runtimes = new WeakMap() + +export function make< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +>(config: Config): Definition +export function make(config: DynamicConfig): AnyTool +export function make(config: Config | DynamicConfig): AnyTool { + if ("jsonSchema" in config) return makeDynamic(config) + return makeTyped(config) +} + +function makeTyped< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +>(config: Config): Definition { + const tool = Object.freeze({}) as Definition + const definitions = new Map() + runtimes.set(tool, { + definition: (name) => { + const cached = definitions.get(name) + if (cached) return cached + const definition = new ToolDefinition({ + name, + description: config.description, + inputSchema: toJsonSchema(config.input), + outputSchema: toJsonSchema(config.structured ?? config.output), + }) + definitions.set(name, definition) + return definition + }, + settle: (call, context) => + Schema.decodeUnknownEffect(config.input)(call.input).pipe( + Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })), + Effect.flatMap((input) => + config.execute(input, context).pipe( + Effect.flatMap((output) => + Schema.encodeEffect(config.output)(output).pipe( + Effect.flatMap((output) => { + if (!config.structured || !config.toStructuredOutput) + return Effect.succeed({ output, structured: output }) + return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe( + Effect.map((structured) => ({ output, structured })), + ) + }), + Effect.mapError( + (error) => + new ToolFailure({ + message: `Tool returned an invalid value for its output schema: ${error.message}`, + }), + ), + ), + ), + Effect.map(({ output, structured }) => ({ + structured, + content: + config.toModelOutput?.({ input, output }).map(toModelContent) ?? + (typeof output === "string" ? [{ type: "text" as const, text: output }] : []), + })), + ), + ), + ), + }) + return tool +} + +function makeDynamic(config: DynamicConfig): AnyTool { + const tool = Object.freeze({}) as AnyTool + const definitions = new Map() + runtimes.set(tool, { + definition: (name) => { + const cached = definitions.get(name) + if (cached) return cached + const definition = new ToolDefinition({ + name, + description: config.description, + inputSchema: config.jsonSchema, + outputSchema: config.outputSchema, + }) + definitions.set(name, definition) + return definition + }, + settle: (call, context) => + config + .execute(call.input, context) + .pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))), + }) + return tool +} + +function toModelContent(part: Content) { + if (part.type === "text") return { type: "text" as const, text: part.text } + return { type: "file" as const, uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name } +} + +export const validateName = (name: string) => + /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name) + ? Effect.void + : Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` })) + +export const registrationEntries = (tools: Readonly>) => + Object.entries(tools).map(([name, tool]) => [name.replace(/[^a-zA-Z0-9_-]/g, "_"), tool] as const) + +export const withPermission = , Output extends SchemaType>( + tool: Definition, + permission: string, +) => { + const decorated = Object.freeze({}) as Definition + runtimes.set(decorated, { ...runtimeOf(tool), permission }) + return decorated +} + +export const permission = (tool: AnyTool, name: string) => runtimeOf(tool).permission ?? name +export const definition = (name: string, tool: AnyTool) => runtimeOf(tool).definition(name) +export const settle = (tool: AnyTool, call: ToolCall, context: Context) => runtimeOf(tool).settle(call, context) + +function runtimeOf(tool: AnyTool) { + const runtime = runtimes.get(tool) + if (!runtime) throw new TypeError("Invalid Tool value") + return runtime +} + +function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema { + const document = Schema.toJsonSchemaDocument(schema) + if (Object.keys(document.definitions).length === 0) return document.schema + return { ...document.schema, $defs: document.definitions } +} export interface ToolDomain { - readonly register: (tools: Readonly>) => Effect.Effect + readonly register: (tools: Readonly>) => Effect.Effect } diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json index 8ee56d236e..dfd9a832c7 100644 --- a/packages/plugin/tsconfig.json +++ b/packages/plugin/tsconfig.json @@ -5,7 +5,8 @@ "rootDir": "src", "outDir": "dist", "declaration": true, - "lib": ["es2022", "dom", "dom.iterable"] + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false }, "include": ["src"] } diff --git a/packages/sdk-next/package.json b/packages/sdk-next/package.json index 55a891a10f..2200251099 100644 --- a/packages/sdk-next/package.json +++ b/packages/sdk-next/package.json @@ -14,6 +14,7 @@ "dependencies": { "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", + "@opencode-ai/plugin": "workspace:*", "@opencode-ai/server": "workspace:*", "effect": "catalog:" }, diff --git a/packages/sdk-next/src/tool.ts b/packages/sdk-next/src/tool.ts index 4b572a6260..eff4b809aa 100644 --- a/packages/sdk-next/src/tool.ts +++ b/packages/sdk-next/src/tool.ts @@ -1,2 +1,2 @@ -export { Failure, RegistrationError, make } from "@opencode-ai/core/tool/tool" -export type { AnyTool, Content, Context, Definition } from "@opencode-ai/core/tool/tool" +export { Failure, RegistrationError, make } from "@opencode-ai/plugin/v2/effect/tool" +export type { AnyTool, Content, Context, Definition } from "@opencode-ai/plugin/v2/effect/tool"