From 375c366fe0c5b356ed0eb649e3a9b31cfea815c6 Mon Sep 17 00:00:00 2001 From: Joshua Mitchell Date: Sun, 29 Mar 2026 22:34:00 -0500 Subject: [PATCH] feat(plugins): add before_agent_reply hook using claiming pattern Co-Authored-By: Claude Opus 4.6 --- src/auto-reply/reply/get-reply.ts | 40 ++++++ src/plugins/hooks.before-agent-reply.test.ts | 123 +++++++++++++++++++ src/plugins/hooks.ts | 21 ++++ src/plugins/types.ts | 21 ++++ 4 files changed, 205 insertions(+) create mode 100644 src/plugins/hooks.before-agent-reply.test.ts diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index adf1d9d9d5c..d1e16007b40 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -44,6 +44,20 @@ function loadStageSandboxMediaRuntime() { return stageSandboxMediaRuntimePromise; } +let hookRunnerGlobalPromise: Promise | null = + null; +let originRoutingPromise: Promise | null = null; + +function loadHookRunnerGlobal() { + hookRunnerGlobalPromise ??= import("../../plugins/hook-runner-global.js"); + return hookRunnerGlobalPromise; +} + +function loadOriginRouting() { + originRoutingPromise ??= import("./origin-routing.js"); + return originRoutingPromise; +} + function mergeSkillFilters(channelFilter?: string[], agentFilter?: string[]): string[] | undefined { const normalize = (list?: string[]) => { if (!Array.isArray(list)) { @@ -419,6 +433,32 @@ export async function getReplyFromConfig( directives = inlineActionResult.directives; abortedLastRun = inlineActionResult.abortedLastRun ?? abortedLastRun; + // Allow plugins to intercept and return a synthetic reply before the LLM runs. + const { getGlobalHookRunner } = await loadHookRunnerGlobal(); + const hookRunner = getGlobalHookRunner(); + if (hookRunner?.hasHooks("before_agent_reply")) { + const { resolveOriginMessageProvider } = await loadOriginRouting(); + const hookMessageProvider = resolveOriginMessageProvider({ + originatingChannel: sessionCtx.OriginatingChannel, + provider: sessionCtx.Provider, + }); + const hookResult = await hookRunner.runBeforeAgentReply( + { cleanedBody }, + { + agentId, + sessionKey: agentSessionKey, + sessionId, + workspaceDir, + messageProvider: hookMessageProvider, + trigger: opts?.isHeartbeat ? "heartbeat" : "user", + channelId: hookMessageProvider, + }, + ); + if (hookResult?.handled) { + return hookResult.reply ?? { text: SILENT_REPLY_TOKEN }; + } + } + if (sessionKey && hasInboundMedia(ctx)) { const { stageSandboxMedia } = await loadStageSandboxMediaRuntime(); await stageSandboxMedia({ diff --git a/src/plugins/hooks.before-agent-reply.test.ts b/src/plugins/hooks.before-agent-reply.test.ts new file mode 100644 index 00000000000..c82be373f43 --- /dev/null +++ b/src/plugins/hooks.before-agent-reply.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHookRunner } from "./hooks.js"; +import { createMockPluginRegistry, TEST_PLUGIN_AGENT_CTX } from "./hooks.test-helpers.js"; + +const EVENT = { cleanedBody: "hello world" }; + +describe("before_agent_reply hook runner (claiming pattern)", () => { + it("returns the result when a plugin claims with { handled: true }", async () => { + const handler = vi.fn().mockResolvedValue({ + handled: true, + reply: { text: "intercepted" }, + reason: "test-claim", + }); + const registry = createMockPluginRegistry([{ hookName: "before_agent_reply", handler }]); + const runner = createHookRunner(registry); + + const result = await runner.runBeforeAgentReply(EVENT, TEST_PLUGIN_AGENT_CTX); + + expect(result).toEqual({ + handled: true, + reply: { text: "intercepted" }, + reason: "test-claim", + }); + expect(handler).toHaveBeenCalledWith(EVENT, TEST_PLUGIN_AGENT_CTX); + }); + + it("returns undefined when no hooks are registered", async () => { + const registry = createMockPluginRegistry([]); + const runner = createHookRunner(registry); + + const result = await runner.runBeforeAgentReply(EVENT, TEST_PLUGIN_AGENT_CTX); + + expect(result).toBeUndefined(); + }); + + it("stops at first { handled: true } — second handler is not called", async () => { + const first = vi.fn().mockResolvedValue({ handled: true, reply: { text: "first" } }); + const second = vi.fn().mockResolvedValue({ handled: true, reply: { text: "second" } }); + const registry = createMockPluginRegistry([ + { hookName: "before_agent_reply", handler: first }, + { hookName: "before_agent_reply", handler: second }, + ]); + const runner = createHookRunner(registry); + + const result = await runner.runBeforeAgentReply(EVENT, TEST_PLUGIN_AGENT_CTX); + + expect(result).toEqual({ handled: true, reply: { text: "first" } }); + expect(first).toHaveBeenCalledTimes(1); + expect(second).not.toHaveBeenCalled(); + }); + + it("returns { handled: true } without reply (swallow pattern)", async () => { + const handler = vi.fn().mockResolvedValue({ handled: true }); + const registry = createMockPluginRegistry([{ hookName: "before_agent_reply", handler }]); + const runner = createHookRunner(registry); + + const result = await runner.runBeforeAgentReply(EVENT, TEST_PLUGIN_AGENT_CTX); + + expect(result).toEqual({ handled: true }); + expect(result?.reply).toBeUndefined(); + }); + + it("skips a declining plugin (returns void) and lets the next one claim", async () => { + const decliner = vi.fn().mockResolvedValue(undefined); + const claimer = vi.fn().mockResolvedValue({ + handled: true, + reply: { text: "claimed" }, + }); + const registry = createMockPluginRegistry([ + { hookName: "before_agent_reply", handler: decliner }, + { hookName: "before_agent_reply", handler: claimer }, + ]); + const runner = createHookRunner(registry); + + const result = await runner.runBeforeAgentReply(EVENT, TEST_PLUGIN_AGENT_CTX); + + expect(result).toEqual({ handled: true, reply: { text: "claimed" } }); + expect(decliner).toHaveBeenCalledTimes(1); + expect(claimer).toHaveBeenCalledTimes(1); + }); + + it("returns undefined when all plugins decline", async () => { + const first = vi.fn().mockResolvedValue(undefined); + const second = vi.fn().mockResolvedValue(undefined); + const registry = createMockPluginRegistry([ + { hookName: "before_agent_reply", handler: first }, + { hookName: "before_agent_reply", handler: second }, + ]); + const runner = createHookRunner(registry); + + const result = await runner.runBeforeAgentReply(EVENT, TEST_PLUGIN_AGENT_CTX); + + expect(result).toBeUndefined(); + }); + + it("catches errors with catchErrors: true and continues to next handler", async () => { + const logger = { warn: vi.fn(), error: vi.fn() }; + const failing = vi.fn().mockRejectedValue(new Error("boom")); + const claimer = vi.fn().mockResolvedValue({ handled: true, reply: { text: "ok" } }); + const registry = createMockPluginRegistry([ + { hookName: "before_agent_reply", handler: failing }, + { hookName: "before_agent_reply", handler: claimer }, + ]); + const runner = createHookRunner(registry, { logger }); + + const result = await runner.runBeforeAgentReply(EVENT, TEST_PLUGIN_AGENT_CTX); + + expect(result).toEqual({ handled: true, reply: { text: "ok" } }); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("before_agent_reply handler from test-plugin failed: Error: boom"), + ); + }); + + it("hasHooks reports correctly for before_agent_reply", () => { + const registry = createMockPluginRegistry([ + { hookName: "before_agent_reply", handler: vi.fn() }, + ]); + const runner = createHookRunner(registry); + + expect(runner.hasHooks("before_agent_reply")).toBe(true); + expect(runner.hasHooks("before_agent_start")).toBe(false); + }); +}); diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 0583b22ce52..c42133c492f 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -12,6 +12,8 @@ import type { PluginHookAfterToolCallEvent, PluginHookAgentContext, PluginHookAgentEndEvent, + PluginHookBeforeAgentReplyEvent, + PluginHookBeforeAgentReplyResult, PluginHookBeforeAgentStartEvent, PluginHookBeforeAgentStartResult, PluginHookBeforeDispatchContext, @@ -61,6 +63,8 @@ import type { // Re-export types for consumers export type { PluginHookAgentContext, + PluginHookBeforeAgentReplyEvent, + PluginHookBeforeAgentReplyResult, PluginHookBeforeAgentStartEvent, PluginHookBeforeAgentStartResult, PluginHookBeforeDispatchContext, @@ -507,6 +511,22 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp ); } + /** + * Run before_agent_reply hook. + * Allows plugins to intercept messages and return a synthetic reply, + * short-circuiting the LLM agent. First handler to return { handled: true } wins. + */ + async function runBeforeAgentReply( + event: PluginHookBeforeAgentReplyEvent, + ctx: PluginHookAgentContext, + ): Promise { + return runClaimingHook<"before_agent_reply", PluginHookBeforeAgentReplyResult>( + "before_agent_reply", + event, + ctx, + ); + } + /** * Run agent_end hook. * Allows plugins to analyze completed conversations. @@ -1001,6 +1021,7 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp runBeforeModelResolve, runBeforePromptBuild, runBeforeAgentStart, + runBeforeAgentReply, runLlmInput, runLlmOutput, runAgentEnd, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 3f525473c73..2f9c6ed1cb7 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1801,6 +1801,7 @@ export type PluginHookName = | "before_model_resolve" | "before_prompt_build" | "before_agent_start" + | "before_agent_reply" | "llm_input" | "llm_output" | "agent_end" @@ -1829,6 +1830,7 @@ export const PLUGIN_HOOK_NAMES = [ "before_model_resolve", "before_prompt_build", "before_agent_start", + "before_agent_reply", "llm_input", "llm_output", "agent_end", @@ -1972,6 +1974,21 @@ export const stripPromptMutationFieldsFromLegacyHookResult = ( : undefined; }; +// before_agent_reply hook +export type PluginHookBeforeAgentReplyEvent = { + /** The final user message text heading to the LLM (after commands/directives). */ + cleanedBody: string; +}; + +export type PluginHookBeforeAgentReplyResult = { + /** Whether the plugin is claiming this message (short-circuits the LLM agent). */ + handled: boolean; + /** Synthetic reply that short-circuits the LLM agent. */ + reply?: ReplyPayload; + /** Reason for interception (for logging/debugging). */ + reason?: string; +}; + // llm_input hook export type PluginHookLlmInputEvent = { runId: string; @@ -2381,6 +2398,10 @@ export type PluginHookHandlerMap = { event: PluginHookBeforeAgentStartEvent, ctx: PluginHookAgentContext, ) => Promise | PluginHookBeforeAgentStartResult | void; + before_agent_reply: ( + event: PluginHookBeforeAgentReplyEvent, + ctx: PluginHookAgentContext, + ) => Promise | PluginHookBeforeAgentReplyResult | void; llm_input: (event: PluginHookLlmInputEvent, ctx: PluginHookAgentContext) => Promise | void; llm_output: ( event: PluginHookLlmOutputEvent,