feat(plugin): restore ai request hook

This commit is contained in:
Dax Raad 2026-07-15 15:58:14 -04:00
commit f2f5eb6f16
19 changed files with 261 additions and 10 deletions

View file

@ -720,6 +720,7 @@
"version": "1.17.20", "version": "1.17.20",
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",

View file

@ -1,5 +1,6 @@
export * as PluginHooks from "./hooks" export * as PluginHooks from "./hooks"
import type { AIHooks } from "@opencode-ai/plugin/v2/effect/ai"
import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk" import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk"
import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool" import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool"
import { Context, Effect, Layer, Scope } from "effect" import { Context, Effect, Layer, Scope } from "effect"
@ -7,6 +8,7 @@ import { makeLocationNode } from "../effect/app-node"
import { State } from "../state" import { State } from "../state"
export interface Domains { export interface Domains {
readonly ai: AIHooks
readonly aisdk: AISDKHooks readonly aisdk: AISDKHooks
readonly tool: ToolHooks readonly tool: ToolHooks
} }

View file

@ -80,6 +80,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}) })
}), }),
}, },
ai: {
hook: (name, callback) => hooks.register("ai", name, callback),
},
aisdk: { aisdk: {
hook: (name, callback) => { hook: (name, callback) => {
if (name === "sdk") { if (name === "sdk") {

View file

@ -63,6 +63,10 @@ export function fromPromise(plugin: Plugin) {
transform: transform(host.agent), transform: transform(host.agent),
reload: () => run(host.agent.reload()), reload: () => run(host.agent.reload()),
}, },
ai: {
hook: (name, callback) =>
register(host.ai.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
aisdk: { aisdk: {
hook: (name, callback) => hook: (name, callback) =>
register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),

View file

@ -10,6 +10,7 @@ import {
isContextOverflowFailure, isContextOverflowFailure,
type ProviderErrorEvent, type ProviderErrorEvent,
} from "@opencode-ai/ai" } from "@opencode-ai/ai"
import type { AIHooks } from "@opencode-ai/plugin/v2/effect/ai"
import { SessionError } from "@opencode-ai/schema/session-error" import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
@ -51,6 +52,7 @@ import { AgentNotFoundError, StepFailedError } from "../error"
import { toSessionError } from "../to-session-error" import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry" import { SessionRunnerRetry } from "./retry"
import { PluginSupervisor } from "../../plugin/supervisor" import { PluginSupervisor } from "../../plugin/supervisor"
import { PluginHooks } from "../../plugin/hooks"
import { SessionModelHeaders } from "../model-headers" import { SessionModelHeaders } from "../model-headers"
type StepTokens = { type StepTokens = {
@ -89,6 +91,7 @@ const layer = Layer.effect(
const llm = yield* LLMClient.Service const llm = yield* LLMClient.Service
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service
const tools = yield* ToolRegistry.Service const tools = yield* ToolRegistry.Service
const hooks = yield* PluginHooks.Service
const models = yield* SessionRunnerModel.Service const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const location = yield* Location.Service const location = yield* Location.Service
@ -212,6 +215,30 @@ const layer = Layer.effect(
tools: toolMaterialization?.definitions ?? [], tools: toolMaterialization?.definitions ?? [],
toolChoice: isLastStep ? "none" : undefined, toolChoice: isLastStep ? "none" : undefined,
}) })
const availableTools = new Map(request.tools.map((tool) => [tool.name, tool]))
const requestEvent: AIHooks["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 by
// permissions, registration state, or the selected agent's step limit.
yield* hooks.trigger("ai", "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 toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>() const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = [] const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
let needsContinuation = false let needsContinuation = false
@ -232,7 +259,7 @@ const layer = Layer.effect(
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect) const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error)) const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
let overflowFailure: ProviderErrorEvent | undefined let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(request).pipe( const providerStream = llm.stream(hookedRequest).pipe(
Stream.runForEach((event) => Stream.runForEach((event) =>
Effect.gen(function* () { Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return if (overflowFailure || publisher.hasProviderError()) return
@ -244,11 +271,13 @@ const layer = Layer.effect(
} }
yield* publish(event) yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) { if (!toolMaterialization || (availableTools.has(event.name) && !advertisedTools.has(event.name))) {
yield* serialized( yield* serialized(
publisher.failUnsettledTools({ publisher.failUnsettledTools({
type: "tool.execution", type: "tool.execution",
message: "Tools are disabled after the maximum agent steps", message: toolMaterialization
? `Tool is not available for this request: ${event.name}`
: "Tools are disabled after the maximum agent steps",
}), }),
) )
return return
@ -585,6 +614,7 @@ export const node = makeLocationNode({
llmClient, llmClient,
AgentV2.node, AgentV2.node,
ToolRegistry.node, ToolRegistry.node,
PluginHooks.node,
SessionRunnerModel.node, SessionRunnerModel.node,
SessionStore.node, SessionStore.node,
Location.node, Location.node,

View file

@ -195,6 +195,19 @@ export const Plugin = {
), ),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* ctx.ai.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
}),
)
}), }),
} }

View file

@ -215,5 +215,32 @@ export const Plugin = {
), ),
) )
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* ctx.ai.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")
}),
)
}), }),
} }

View file

@ -44,6 +44,9 @@ export const registerToolPlugin = <R>(plugin: {
Effect.gen(function* () { Effect.gen(function* () {
const tools = yield* Tools.Service const tools = yield* Tools.Service
const context = host({ const context = host({
ai: {
hook: () => Effect.succeed({ dispose: Effect.void }),
},
tool: { tool: {
transform: (callback) => transform: (callback) =>
Effect.gen(function* () { Effect.gen(function* () {

View file

@ -0,0 +1,45 @@
import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
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"
import { testEffect } from "./lib/effect"
const layer = PluginHooks.node.implementation as Layer.Layer<PluginHooks.Service>
const it = testEffect(layer)
describe("PluginHooks", () => {
it.effect("registers scoped AI hooks and triggers them sequentially", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: string[] = []
yield* hooks.register("ai", "request", (event) =>
Effect.sync(() => {
seen.push("first")
event.system.push(SystemPart.make("second"))
}),
)
yield* hooks.register("ai", "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("ai", "request", event)).toBe(event)
expect(seen).toEqual(["first", "second"])
expect(event.messages).toEqual([Message.user("changed")])
}),
)
})

View file

@ -18,6 +18,9 @@ export function host(overrides: Overrides = {}): PluginContext {
transform: () => Effect.die("unused agent.transform"), transform: () => Effect.die("unused agent.transform"),
reload: () => Effect.die("unused agent.reload"), reload: () => Effect.die("unused agent.reload"),
}, },
ai: overrides.ai ?? {
hook: () => Effect.die("unused ai.hook"),
},
aisdk: overrides.aisdk ?? { aisdk: overrides.aisdk ?? {
hook: () => Effect.die("unused aisdk.hook"), hook: () => Effect.die("unused aisdk.hook"),
}, },

View file

@ -1,13 +1,18 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Message, SystemPart } from "@opencode-ai/ai"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise" import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Plugin } from "@opencode-ai/plugin/v2" import { Plugin } from "@opencode-ai/plugin/v2"
import type { AIHooks } from "@opencode-ai/plugin/v2/effect/ai"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture" import { PluginTestLayer } from "./fixture"
@ -72,6 +77,38 @@ describe("fromPromise", () => {
}), }),
) )
it.effect("forwards AI request hooks", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
Plugin.define({
id: "promise-ai-request",
setup: async (ctx) => {
await ctx.ai.hook("request", (event) => {
event.system.push(SystemPart.make("Promise hook"))
delete event.tools.echo
})
},
}),
).effect(host)
const event: AIHooks["request"] = {
sessionID: SessionV2.ID.make("ses_promise_ai_request"),
agent: AgentV2.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
system: [SystemPart.make("Initial")],
messages: [Message.user("Hello")],
tools: { echo: { description: "Echo", input: { type: "object" } } },
}
yield* hooks.trigger("ai", "request", event)
expect(event.system.map((part) => part.text)).toEqual(["Initial", "Promise hook"])
expect(event.tools).toEqual({})
}),
)
it.effect("disposes a hook registration on request", () => it.effect("disposes a hook registration on request", () =>
Effect.gen(function* () { Effect.gen(function* () {
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service

View file

@ -3,7 +3,9 @@ import {
LLMClient, LLMClient,
LLMError, LLMError,
LLMEvent, LLMEvent,
Message,
Model, Model,
SystemPart,
ToolFailure, ToolFailure,
TransportReason, TransportReason,
InvalidProviderOutputReason, InvalidProviderOutputReason,
@ -42,6 +44,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt" import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { QuestionTool } from "@opencode-ai/core/tool/question" import { QuestionTool } from "@opencode-ai/core/tool/question"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
@ -397,6 +400,7 @@ const it = testEffect(
AgentV2.node, AgentV2.node,
ToolRegistry.node, ToolRegistry.node,
ToolRegistry.toolsNode, ToolRegistry.toolsNode,
PluginHooks.node,
echoNode, echoNode,
SessionRunnerModel.node, SessionRunnerModel.node,
InstructionBuiltIns.node, InstructionBuiltIns.node,
@ -773,6 +777,32 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
}) })
describe("SessionRunnerLLM", () => { describe("SessionRunnerLLM", () => {
it.effect("applies AI request hooks without exposing unavailable tools", () =>
Effect.gen(function* () {
const session = yield* setup
const hooks = yield* PluginHooks.Service
yield* hooks.register("ai", "request", (event) =>
Effect.sync(() => {
event.system = [SystemPart.make("Hooked system")]
event.messages = [Message.user("Hooked message")]
delete event.tools.echo
event.tools.unregistered = { description: "Unavailable", input: { type: "object" } }
}),
)
yield* admit(session, "Original message")
responses = [reply.tool("call-removed", "echo", { text: "blocked" })]
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Hooked system"])
expect(requests[0]?.messages).toEqual([Message.user("Hooked message")])
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("unregistered")
expect(executions).toEqual([])
}),
)
it.effect("advertises and executes a location registered tool", () => it.effect("advertises and executes a location registered tool", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup

View file

@ -25,6 +25,7 @@
], ],
"dependencies": { "dependencies": {
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",

View file

@ -80,14 +80,16 @@ yield *
Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks. Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
Session request context is mutable immediately before provider dispatch: AI request context is mutable immediately before provider dispatch:
```ts ```ts
yield * yield *
ctx.session.hook("request", (event) => { ctx.ai.hook("request", (event) =>
event.tools.read.description = "Read a file using narrow line ranges." Effect.sync(() => {
delete event.tools.write event.tools.read.description = "Read a file using narrow line ranges."
}) delete event.tools.write
}),
)
``` ```
## Reloading A Domain ## Reloading A Domain

View file

@ -0,0 +1,23 @@
import type { Message, SystemPart } from "@opencode-ai/ai"
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 AIRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
system: Array<SystemPart>
messages: Array<Message>
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface AIHooks {
readonly request: AIRequest
}
export interface AIDomain {
readonly hook: Hooks<AIHooks>
}

View file

@ -2,6 +2,7 @@ import type { PluginApi } from "@opencode-ai/client/effect/api"
import type { Effect, Scope } from "effect" import type { Effect, Scope } from "effect"
import type { PluginOptions } from "../options.js" import type { PluginOptions } from "../options.js"
import type { AgentDomain } from "./agent.js" import type { AgentDomain } from "./agent.js"
import type { AIDomain } from "./ai.js"
import type { AISDKDomain } from "./aisdk.js" import type { AISDKDomain } from "./aisdk.js"
import type { CatalogDomain } from "./catalog.js" import type { CatalogDomain } from "./catalog.js"
import type { CommandDomain } from "./command.js" import type { CommandDomain } from "./command.js"
@ -15,6 +16,7 @@ import type { ToolDomain } from "./tool.js"
export interface Context { export interface Context {
readonly options: PluginOptions readonly options: PluginOptions
readonly agent: AgentDomain readonly agent: AgentDomain
readonly ai: AIDomain
readonly aisdk: AISDKDomain readonly aisdk: AISDKDomain
readonly catalog: CatalogDomain readonly catalog: CatalogDomain
readonly command: CommandDomain readonly command: CommandDomain

View file

@ -85,10 +85,10 @@ await ctx.aisdk.hook("language", (event) => {
}) })
``` ```
Session request context is mutable immediately before provider dispatch: AI request context is mutable immediately before provider dispatch:
```ts ```ts
await ctx.session.hook("request", (event) => { await ctx.ai.hook("request", (event) => {
event.tools.read.description = "Read a file using narrow line ranges." event.tools.read.description = "Read a file using narrow line ranges."
delete event.tools.write delete event.tools.write
}) })

View file

@ -0,0 +1,23 @@
import type { Message, SystemPart } from "@opencode-ai/ai"
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 AIRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
system: Array<SystemPart>
messages: Array<Message>
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface AIHooks {
readonly request: AIRequest
}
export interface AIDomain {
readonly hook: Hooks<AIHooks>
}

View file

@ -1,6 +1,7 @@
import type { PluginApi } from "@opencode-ai/client/promise/api" import type { PluginApi } from "@opencode-ai/client/promise/api"
import type { PluginOptions } from "../options.js" import type { PluginOptions } from "../options.js"
import type { AgentDomain } from "./agent.js" import type { AgentDomain } from "./agent.js"
import type { AIDomain } from "./ai.js"
import type { AISDKDomain } from "./aisdk.js" import type { AISDKDomain } from "./aisdk.js"
import type { CatalogDomain } from "./catalog.js" import type { CatalogDomain } from "./catalog.js"
import type { CommandDomain } from "./command.js" import type { CommandDomain } from "./command.js"
@ -14,6 +15,7 @@ import type { ToolDomain } from "./tool.js"
export interface Context { export interface Context {
readonly options: PluginOptions readonly options: PluginOptions
readonly agent: AgentDomain readonly agent: AgentDomain
readonly ai: AIDomain
readonly aisdk: AISDKDomain readonly aisdk: AISDKDomain
readonly catalog: CatalogDomain readonly catalog: CatalogDomain
readonly command: CommandDomain readonly command: CommandDomain