Переглянути джерело

feat(plugin): allow tool hooks to fail with tool errors (#41668)

Aiden Cline 3 днів тому
батько
коміт
47c8d85904

+ 19 - 7
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: Event) => Effect.Effect<void>
+type NoFailures<Spec> = { 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<keyof Domains, unknown> {
+  readonly aisdk: NoFailures<AISDKHooks>
+  readonly session: NoFailures<SessionHooks>
+  readonly shell: NoFailures<ShellHooks>
+  readonly tool: ToolFailures
+}
+
+type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
 
 export interface Interface {
-  readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
+  readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
     domain: Domain,
     name: Name,
-    callback: Callback<Domains[Domain][Name]>,
+    callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
   ) => Effect.Effect<State.Registration, never, Scope.Scope>
-  readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
+  readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
     domain: Domain,
     name: Name,
     event: Domains[Domain][Name],
-  ) => Effect.Effect<Domains[Domain][Name]>
+  ) => Effect.Effect<Domains[Domain][Name], Failures[Domain][Name]>
 }
 
 export class Service extends Context.Service<Service, Interface>()("@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<void> = Reflect.apply(callback, undefined, [event])
+        const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
+          event,
+        ])
         yield* result
       }
       return event

+ 48 - 0
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([])
+    }),
+  )
 })

+ 4 - 2
packages/plugin/src/effect/registration.ts

@@ -4,9 +4,11 @@ export interface Registration {
   readonly dispose: Effect.Effect<void>
 }
 
-export type Hooks<Spec> = <Name extends keyof Spec>(
+export type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <
+  Name extends keyof Spec,
+>(
   name: Name,
-  callback: (input: Spec[Name]) => Effect.Effect<void>,
+  callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
 ) => Effect.Effect<Registration, never, Scope.Scope>
 
 export type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>

+ 7 - 1
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<keyof ToolHooks, unknown> {
+  readonly "execute.before": Tool.Error
+  readonly "execute.after": never
+}
+
 export interface ToolDomain {
   readonly transform: Transform<ToolDraft>
-  readonly hook: Hooks<ToolHooks>
+  readonly hook: Hooks<ToolHooks, ToolFailures>
 }