From 47c8d85904441c2846705678ca6ef75cb2ae69cc Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:36:59 -0500 Subject: [PATCH] feat(plugin): allow tool hooks to fail with tool errors (#41668) --- packages/core/src/plugin/hooks.ts | 26 ++++++++---- packages/core/test/plugin.test.ts | 48 ++++++++++++++++++++++ packages/plugin/src/effect/registration.ts | 6 ++- packages/plugin/src/effect/tool.ts | 8 +++- 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/packages/core/src/plugin/hooks.ts b/packages/core/src/plugin/hooks.ts index 1f02bbed97a..166b1414113 100644 --- a/packages/core/src/plugin/hooks.ts +++ b/packages/core/src/plugin/hooks.ts @@ -3,7 +3,7 @@ export * as PluginHooks from "./hooks" import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk" import type { SessionHooks } from "@opencode-ai/plugin/effect/session" import type { ShellHooks } from "@opencode-ai/plugin/effect/shell" -import type { ToolHooks } from "@opencode-ai/plugin/effect/tool" +import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool" import { Context, Effect, Layer, Scope } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { State } from "../state" @@ -15,19 +15,29 @@ export interface Domains { readonly tool: ToolHooks } -type Callback = (event: Event) => Effect.Effect +type NoFailures = { readonly [Name in keyof Spec]: never } + +// Failure channel for each hook event. Only tool execute.before may fail: a Tool.Error rejects the call before it runs. +interface Failures extends Record { + readonly aisdk: NoFailures + readonly session: NoFailures + readonly shell: NoFailures + readonly tool: ToolFailures +} + +type Callback = (event: Event) => Effect.Effect export interface Interface { - readonly register: ( + readonly register: ( domain: Domain, name: Name, - callback: Callback, + callback: Callback, ) => Effect.Effect - readonly trigger: ( + readonly trigger: ( domain: Domain, name: Name, event: Domains[Domain][Name], - ) => Effect.Effect + ) => Effect.Effect } export class Service extends Context.Service()("@opencode/PluginHooks") {} @@ -56,7 +66,9 @@ const layer = Layer.effect( const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) { for (const callback of callbacks.get(key(domain, name)) ?? []) { - const result: Effect.Effect = Reflect.apply(callback, undefined, [event]) + const result: Effect.Effect = Reflect.apply(callback, undefined, [ + event, + ]) yield* result } return event diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index e91908c541d..83a8dbc01cf 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { ToolFailure } from "@opencode-ai/ai" import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect" import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect" import { Config as ConfigSchema } from "@opencode-ai/schema/config" @@ -395,4 +396,51 @@ describe("Plugin", () => { }) }), ) + + it.effect("rejects tool execution when an execute.before hook fails", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const registry = yield* Tool.Service + const executed: unknown[] = [] + + const plugin = EffectPlugin.define({ + id: "tool-hook-reject", + effect: (ctx) => + Effect.gen(function* () { + yield* ctx.tool + .transform((draft) => + draft.add({ + name: "echo", + options: { codemode: false }, + description: "Echo", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }) => + Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })), + }), + ) + .pipe(Effect.orDie) + + yield* ctx.tool + .hook("execute.before", () => new ToolFailure({ message: "write disabled" })) + .pipe(Effect.asVoid) + }), + }) + + yield* plugins.activate([versioned(plugin)]) + + const toolSet = yield* registry.snapshot() + const failure = yield* toolSet + .execute({ + sessionID: Session.ID.make("ses_hook_reject"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_hook_reject"), + call: { type: "tool-call", id: "call-hook-reject", name: "echo", input: { text: "original" } }, + }) + .pipe(Effect.flip) + + expect(failure).toMatchObject({ _tag: "Tool.Error", message: "write disabled" }) + expect(executed).toEqual([]) + }), + ) }) diff --git a/packages/plugin/src/effect/registration.ts b/packages/plugin/src/effect/registration.ts index 916b034a73b..b7cc13328ab 100644 --- a/packages/plugin/src/effect/registration.ts +++ b/packages/plugin/src/effect/registration.ts @@ -4,9 +4,11 @@ export interface Registration { readonly dispose: Effect.Effect } -export type Hooks = ( +export type Hooks = Record> = < + Name extends keyof Spec, +>( name: Name, - callback: (input: Spec[Name]) => Effect.Effect, + callback: (input: Spec[Name]) => Effect.Effect, ) => Effect.Effect export type Transform = (callback: (input: Input) => void) => Effect.Effect diff --git a/packages/plugin/src/effect/tool.ts b/packages/plugin/src/effect/tool.ts index d8f88c5d629..ff0b4728444 100644 --- a/packages/plugin/src/effect/tool.ts +++ b/packages/plugin/src/effect/tool.ts @@ -38,7 +38,13 @@ export interface ToolHooks { ) } +// Only execute.before may fail: a Tool.Error rejects the call before the tool runs. +export interface ToolFailures extends Record { + readonly "execute.before": Tool.Error + readonly "execute.after": never +} + export interface ToolDomain { readonly transform: Transform - readonly hook: Hooks + readonly hook: Hooks }