diff --git a/packages/core/src/plugin/hooks.ts b/packages/core/src/plugin/hooks.ts index 4ac437d3926..0023e62f6cb 100644 --- a/packages/core/src/plugin/hooks.ts +++ b/packages/core/src/plugin/hooks.ts @@ -1,7 +1,6 @@ export * as PluginHooks from "./hooks" import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk" -import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool" import { Context, Effect, Layer, Scope } from "effect" import { makeLocationNode } from "../effect/app-node" @@ -9,7 +8,6 @@ import { State } from "../state" export interface Domains { readonly aisdk: AISDKHooks - readonly session: SessionHooks readonly tool: ToolHooks } diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index a109dd02e70..007a53ed70e 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -367,7 +367,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }, }, session: { - hook: (name, callback) => hooks.register("session", name, callback), create: (input) => runtime.session.create({ id: input?.id, diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 8eafe52734f..2e175fa85d9 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -118,8 +118,6 @@ export function fromPromise(plugin: PromisePlugin) { prompt: (input) => run(host.session.prompt(input)), command: (input) => run(host.session.command(input)), interrupt: (input) => run(host.session.interrupt(input)), - hook: (name, callback) => - register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), }, } diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 33d3fc27667..83c6be2de3a 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -51,8 +51,6 @@ import { llmClient } from "../../effect/app-node-platform" import { AgentNotFoundError, StepFailedError } from "../error" import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" -import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" -import { PluginHooks } from "../../plugin/hooks" import { PluginSupervisor } from "../../plugin/supervisor" type StepTokens = { @@ -91,7 +89,6 @@ const layer = Layer.effect( const llm = yield* LLMClient.Service const agents = yield* AgentV2.Service const tools = yield* ToolRegistry.Service - const hooks = yield* PluginHooks.Service const models = yield* SessionRunnerModel.Service const store = yield* SessionStore.Service const location = yield* Location.Service @@ -212,30 +209,7 @@ const layer = Layer.effect( const toolFibers = yield* FiberSet.make() const ownedToolFibers: Array> = [] let needsContinuation = false - const availableTools = new Map(request.tools.map((tool) => [tool.name, tool])) - const requestEvent: SessionHooks["request"] = { - sessionID: session.id, - agent: agent.id, - model: resolved.ref, - system: [...request.system], - messages: [...request.messages], - tools: Object.fromEntries( - request.tools.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]), - ), - } - // Plugins may reshape the draft, but cannot advertise tools excluded earlier - // by permissions or registration state. - yield* hooks.trigger("session", "request", requestEvent) - const hookedRequest = LLM.updateRequest(request, { - system: requestEvent.system, - messages: requestEvent.messages, - tools: Object.entries(requestEvent.tools).flatMap(([name, tool]) => { - const registered = availableTools.get(name) - if (!registered) return [] - return [{ ...registered, description: tool.description, inputSchema: tool.input }] - }), - }) - const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name)) + const hookedRequest = request // Automatic compaction completed; rebuild the request from compacted history. if ( !(yield* SessionPending.compaction(db, session.id)) && @@ -280,21 +254,6 @@ const layer = Layer.effect( ) return } - // A request hook hid this registered tool from the current request. Fail only - // this call durably and continue so the model can react, instead of executing - // a tool that was not advertised. Unregistered tools flow through settle, which - // durably fails them as unknown. - if (!advertisedTools.has(event.name) && availableTools.has(event.name)) { - needsContinuation = true - yield* publish( - LLMEvent.toolError({ - id: event.id, - name: event.name, - message: `Tool is not available for this request: ${event.name}`, - }), - ) - return - } needsContinuation = true const assistantMessageID = yield* publisher.assistantMessageID(event.id) ownedToolFibers.push( @@ -624,7 +583,6 @@ export const node = makeLocationNode({ llmClient, AgentV2.node, ToolRegistry.node, - PluginHooks.node, SessionRunnerModel.node, SessionStore.node, Location.node, diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index 28a015dd7cc..6d3d521fb7c 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -1,4 +1,5 @@ import { LLMError, ToolFailure } from "@opencode-ai/llm" +import { Tool } from "@opencode-ai/plugin/v2/effect/tool" import { SessionError } from "@opencode-ai/schema/session-error" import { PermissionV2 } from "../permission" import { QuestionV2 } from "../question" @@ -38,7 +39,7 @@ 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) + if (cause instanceof ToolFailure || cause instanceof Tool.Failure) return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error) if (cause instanceof StepFailedError) return cause.error if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message } diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index e6a043a0dd4..843f4df8c3a 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -194,19 +194,6 @@ export const Plugin = { ), ) .pipe(Effect.orDie) - - yield* ctx.session.hook("request", (event) => - Effect.sync(() => { - const usePatch = - event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt") - if (usePatch) { - delete event.tools.edit - delete event.tools.write - return - } - delete event.tools.patch - }), - ) }), } diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index b287d742343..84131b18a6e 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -192,32 +192,5 @@ export const Plugin = { ), ) .pipe(Effect.orDie) - - yield* ctx.session.hook("request", (event) => - Effect.gen(function* () { - const tool = event.tools[name] - if (!tool) return - const selected = yield* agents.resolve(event.agent) - if (!selected) return - const available = (yield* agents.list()) - .filter( - (agent) => - agent.mode !== "primary" && - !agent.hidden && - PermissionV2.evaluate(name, agent.id, selected.permissions).effect !== "deny", - ) - .toSorted((a, b) => a.id.localeCompare(b.id)) - if (available.length === 0) return - tool.description = [ - tool.description, - "", - "Available subagents:", - ...available.map( - (agent) => - `- ${agent.id}: ${agent.description ?? "This subagent should only be called when explicitly requested."}`, - ), - ].join("\n") - }), - ) }), } diff --git a/packages/core/test/plugin-hooks.test.ts b/packages/core/test/plugin-hooks.test.ts deleted file mode 100644 index 8927df9a05f..00000000000 --- a/packages/core/test/plugin-hooks.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { Message, SystemPart } from "@opencode-ai/llm" -import { Agent } from "@opencode-ai/schema/agent" -import { Model } from "@opencode-ai/schema/model" -import { Provider } from "@opencode-ai/schema/provider" -import { Session } from "@opencode-ai/schema/session" -import { Effect, Layer } from "effect" -import { PluginHooks } from "../src/plugin/hooks" - -describe("PluginHooks", () => { - it("registers scoped domain hooks and triggers them sequentially", async () => { - const seen: string[] = [] - const program = Effect.gen(function* () { - const hooks = yield* PluginHooks.Service - yield* hooks.register("session", "request", (event) => - Effect.sync(() => { - seen.push("first") - event.system.push(SystemPart.make("second")) - }), - ) - yield* hooks.register("session", "request", (event) => - Effect.sync(() => { - seen.push(event.system[1]?.text ?? "missing") - event.messages = [Message.user("changed")] - }), - ) - const event = { - sessionID: Session.ID.make("ses_hooks"), - agent: Agent.ID.make("build"), - model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), - system: [SystemPart.make("first")], - messages: [Message.user("original")], - tools: {}, - } - - expect(yield* hooks.trigger("session", "request", event)).toBe(event) - expect(seen).toEqual(["first", "second"]) - expect(event.messages).toEqual([Message.user("changed")]) - }) - - await Effect.runPromise( - Effect.scoped(program).pipe( - Effect.provide(PluginHooks.node.implementation as Layer.Layer), - ), - ) - }) -}) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 659ec40f86f..679624ee579 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -83,9 +83,6 @@ export function host(overrides: Overrides = {}): PluginContext { prompt: () => Effect.die("unused session.prompt"), command: () => Effect.die("unused session.command"), interrupt: () => Effect.die("unused session.interrupt"), - // Plugins register session hooks during setup, so a bare host accepts the - // registration; the callback only runs when a test triggers the request pipeline. - hook: () => Effect.succeed({ dispose: Effect.void }), }, } } diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 4ae6196272e..46bdb09e704 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -16,6 +16,7 @@ import { ToolFailure, } from "@opencode-ai/llm" import { PermissionV2 } from "@opencode-ai/core/permission" +import { Tool } from "@opencode-ai/plugin/v2/effect/tool" import { toSessionError } from "@opencode-ai/core/session/to-session-error" import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry" @@ -64,6 +65,10 @@ describe("toSessionError", () => { type: "permission.rejected", message: "Permission denied: external_directory", }) + expect(toSessionError(new Tool.Failure({ message: "failed" }))).toEqual({ + type: "tool.execution", + message: "failed", + }) }) test("retries only rate limits, provider-internal failures, and transport failures", () => { diff --git a/packages/plugin/package.json b/packages/plugin/package.json index f09776bd405..c990cac7681 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -7,7 +7,7 @@ "scripts": { "test": "bun test --timeout 5000", "typecheck": "tsgo --noEmit", - "build": "tsc" + "build": "tsc -p tsconfig.build.json" }, "exports": { ".": "./src/index.ts", @@ -24,7 +24,6 @@ "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/client": "workspace:*", - "@opencode-ai/llm": "workspace:*", "@opencode-ai/protocol": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/plugin/script/publish.ts b/packages/plugin/script/publish.ts index fea8c7230cf..f03dd084b02 100755 --- a/packages/plugin/script/publish.ts +++ b/packages/plugin/script/publish.ts @@ -10,7 +10,7 @@ async function published(name: string, version: string) { return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 } -await $`bun tsc` +await $`bun run build` const originalText = await Bun.file("package.json").text() const pkg = JSON.parse(originalText) as { name: string diff --git a/packages/plugin/src/v2/effect/session.ts b/packages/plugin/src/v2/effect/session.ts index 00ee8b91255..40da735cbea 100644 --- a/packages/plugin/src/v2/effect/session.ts +++ b/packages/plugin/src/v2/effect/session.ts @@ -1,25 +1,3 @@ import type { SessionApi } from "@opencode-ai/client/effect/api" -import type { Message, SystemPart } from "@opencode-ai/llm" -import type { Agent } from "@opencode-ai/schema/agent" -import type { Model } from "@opencode-ai/schema/model" -import type { Session } from "@opencode-ai/schema/session" -import type { JsonSchema } from "effect" -import type { Hooks } from "./registration.js" -export interface SessionRequestBeforeEvent { - readonly sessionID: Session.ID - readonly agent: Agent.ID - readonly model: Model.Ref - system: Array - messages: Array - tools: Record -} - -export interface SessionHooks { - readonly request: SessionRequestBeforeEvent -} - -export interface SessionDomain - extends Pick, "create" | "get" | "prompt" | "command" | "interrupt"> { - readonly hook: Hooks -} +export type SessionDomain = Pick, "create" | "get" | "prompt" | "command" | "interrupt"> diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index db22b18b4f6..7ba420d7a2a 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -1,7 +1,7 @@ export * as Tool from "./tool.js" -import { ToolDefinition, ToolFailure, ToolOutput, type ToolCall, type ToolResultValue } from "@opencode-ai/llm" import { Agent } from "@opencode-ai/schema/agent" +import type { LLM } from "@opencode-ai/schema/llm" import { Session } from "@opencode-ai/schema/session" import { SessionMessage } from "@opencode-ai/schema/session-message" import { Effect, JsonSchema, Schema, type Scope } from "effect" @@ -16,6 +16,29 @@ export interface Context { export type SchemaType = Schema.Codec +type ToolDefinition = { + readonly name: string + readonly description: string + readonly inputSchema: JsonSchema.JsonSchema + readonly outputSchema?: JsonSchema.JsonSchema +} + +type ToolCall = { + readonly input: unknown + readonly [key: string]: unknown +} + +type ToolResultValue = + | { readonly type: "json"; readonly value: unknown } + | { readonly type: "text"; readonly value: unknown } + | { readonly type: "error"; readonly value: unknown } + | { readonly type: "content"; readonly value: ReadonlyArray } + +type ToolOutput = { + readonly structured: unknown + readonly content: ReadonlyArray +} + declare const TypeId: unique symbol export interface Definition, Output extends SchemaType> { @@ -26,8 +49,11 @@ export interface Definition, Output extends Schema } export type AnyTool = Definition -export const Failure = ToolFailure -export type Failure = ToolFailure +export class Failure extends Schema.TaggedErrorClass()("LLM.ToolFailure", { + message: Schema.String, + error: Schema.optional(Schema.Defect()), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} export class RegistrationError extends Schema.TaggedErrorClass()("Tool.RegistrationError", { name: Schema.String, @@ -54,7 +80,7 @@ type Config< readonly execute: ( input: Schema.Schema.Type, context: Context, - ) => Effect.Effect, ToolFailure> + ) => Effect.Effect, Failure> readonly toModelOutput?: (input: { readonly input: Schema.Schema.Type readonly output: Output["Encoded"] @@ -75,13 +101,13 @@ type DynamicConfig = { readonly description: string readonly jsonSchema: JsonSchema.JsonSchema readonly outputSchema?: JsonSchema.JsonSchema - readonly execute: (input: unknown, context: Context) => Effect.Effect + 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 + readonly settle: (call: ToolCall, context: Context) => Effect.Effect } const runtimes = new WeakMap() @@ -108,18 +134,18 @@ function makeTyped< definition: (name) => { const cached = definitions.get(name) if (cached) return cached - const definition = new ToolDefinition({ + const definition: 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.mapError((error) => new Failure({ message: `Invalid tool input: ${error.message}` })), Effect.flatMap((input) => config.execute(input, context).pipe( Effect.flatMap((output) => @@ -133,7 +159,7 @@ function makeTyped< }), Effect.mapError( (error) => - new ToolFailure({ + new Failure({ message: `Tool returned an invalid value for its output schema: ${error.message}`, }), ), @@ -159,12 +185,12 @@ function makeDynamic(config: DynamicConfig): AnyTool { definition: (name) => { const cached = definitions.get(name) if (cached) return cached - const definition = new ToolDefinition({ + const definition: ToolDefinition = { name, description: config.description, inputSchema: config.jsonSchema, outputSchema: config.outputSchema, - }) + } definitions.set(name, definition) return definition }, diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index ae8e6d18a19..17ee7e37bf4 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -1,6 +1,8 @@ export type { PluginOptions } from "../options.js" export * as Plugin from "./plugin.js" +export { Tool } from "./tool.js" + export { Agent } from "@opencode-ai/schema/agent" export { Command } from "@opencode-ai/schema/command" export { Connection } from "@opencode-ai/schema/connection" diff --git a/packages/plugin/src/v2/promise/session.ts b/packages/plugin/src/v2/promise/session.ts index 41bf598f46f..c521c8fddd6 100644 --- a/packages/plugin/src/v2/promise/session.ts +++ b/packages/plugin/src/v2/promise/session.ts @@ -1,24 +1,3 @@ import type { SessionApi } from "@opencode-ai/client/promise/api" -import type { Message, SystemPart } from "@opencode-ai/llm" -import type { Agent } from "@opencode-ai/schema/agent" -import type { Model } from "@opencode-ai/schema/model" -import type { Session } from "@opencode-ai/schema/session" -import type { JsonSchema } from "effect" -import type { Hooks } from "./registration.js" -export interface SessionRequestBeforeEvent { - readonly sessionID: Session.ID - readonly agent: Agent.ID - readonly model: Model.Ref - system: Array - messages: Array - tools: Record -} - -export interface SessionHooks { - readonly request: SessionRequestBeforeEvent -} - -export interface SessionDomain extends Pick { - readonly hook: Hooks -} +export type SessionDomain = Pick diff --git a/packages/plugin/src/v2/promise/tool.ts b/packages/plugin/src/v2/promise/tool.ts index da4ee1b2008..8ee9ce9439a 100644 --- a/packages/plugin/src/v2/promise/tool.ts +++ b/packages/plugin/src/v2/promise/tool.ts @@ -1,7 +1,6 @@ export * as Tool from "./tool.js" import { Tool } from "../effect/tool.js" -import type { ToolOutput, ToolResultValue } from "@opencode-ai/llm" import type { Agent } from "@opencode-ai/schema/agent" import type { Session } from "@opencode-ai/schema/session" import type { SessionMessage } from "@opencode-ai/schema/session-message" @@ -85,8 +84,8 @@ export interface ToolExecuteAfterEvent { readonly assistantMessageID: SessionMessage.ID readonly toolCallID: string readonly input: unknown - result: ToolResultValue - output?: ToolOutput + result: Tool.ToolExecuteAfterEvent["result"] + output?: Tool.ToolExecuteAfterEvent["output"] outputPaths?: ReadonlyArray } diff --git a/packages/plugin/test/contract-identity.test.ts b/packages/plugin/test/contract-identity.test.ts index 00efbd2affe..7c430c7b090 100644 --- a/packages/plugin/test/contract-identity.test.ts +++ b/packages/plugin/test/contract-identity.test.ts @@ -32,10 +32,10 @@ test.each([ "Credential", "Integration", "Model", + "Plugin", "Provider", "Reference", "Skill", - ...(name === "effect" ? ["Tool"] : []), - "define", + ...(name === "promise" ? ["Tool"] : []), ]) }) diff --git a/packages/plugin/tsconfig.build.json b/packages/plugin/tsconfig.build.json new file mode 100644 index 00000000000..2e9770e8d3a --- /dev/null +++ b/packages/plugin/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "noEmit": false + } +} diff --git a/script/publish.ts b/script/publish.ts index 4fcf0890ba9..33ffef65323 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -50,6 +50,9 @@ await $`bun ./packages/cli/script/publish.ts` console.log("\n=== sdk ===\n") await $`bun ./packages/sdk/js/script/publish.ts` +console.log("\n=== plugin ===\n") +await $`bun ./packages/plugin/script/publish.ts` + console.log("\n=== ui ===\n") await $`bun ./packages/ui/script/publish.ts`