diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 9cee46cc2c9..52e6e28e346 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -11,6 +11,7 @@ import { resolveChannelModelOverride } from "../../channels/model-overrides.js"; import { type OpenClawConfig, loadConfig } from "../../config/config.js"; import { applyLinkUnderstanding } from "../../link-understanding/apply.js"; import { applyMediaUnderstanding } from "../../media-understanding/apply.js"; +import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { defaultRuntime } from "../../runtime.js"; import { normalizeStringEntries } from "../../shared/string-normalization.js"; import { resolveCommandAuthorization } from "../command-auth.js"; @@ -24,6 +25,7 @@ import { handleInlineActions } from "./get-reply-inline-actions.js"; import { runPreparedReply } from "./get-reply-run.js"; import { finalizeInboundContext } from "./inbound-context.js"; import { emitPreAgentMessageHooks } from "./message-preprocess-hooks.js"; +import { resolveOriginMessageProvider } from "./origin-routing.js"; import { applyResetModelOverride } from "./session-reset-model.js"; import { initSessionState } from "./session.js"; import { stageSandboxMedia } from "./stage-sandbox-media.js"; @@ -350,6 +352,30 @@ 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 hookRunner = getGlobalHookRunner(); + if (hookRunner?.hasHooks("before_agent_reply")) { + 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 }; + } + } + await stageSandboxMedia({ ctx, sessionCtx, 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 e8e1e2aa163..8fe7b8fa88c 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -12,6 +12,8 @@ import type { PluginHookAfterToolCallEvent, PluginHookAgentContext, PluginHookAgentEndEvent, + PluginHookBeforeAgentReplyEvent, + PluginHookBeforeAgentReplyResult, PluginHookBeforeAgentStartEvent, PluginHookBeforeAgentStartResult, PluginHookBeforeModelResolveEvent, @@ -58,6 +60,8 @@ import type { // Re-export types for consumers export type { PluginHookAgentContext, + PluginHookBeforeAgentReplyEvent, + PluginHookBeforeAgentReplyResult, PluginHookBeforeAgentStartEvent, PluginHookBeforeAgentStartResult, PluginHookBeforeModelResolveEvent, @@ -473,6 +477,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. @@ -923,6 +943,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 343a338c4f8..67bf69cf627 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1371,6 +1371,7 @@ export type PluginHookName = | "before_model_resolve" | "before_prompt_build" | "before_agent_start" + | "before_agent_reply" | "llm_input" | "llm_output" | "agent_end" @@ -1398,6 +1399,7 @@ export const PLUGIN_HOOK_NAMES = [ "before_model_resolve", "before_prompt_build", "before_agent_start", + "before_agent_reply", "llm_input", "llm_output", "agent_end", @@ -1538,6 +1540,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; @@ -1882,6 +1899,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,