diff --git a/CHANGELOG.md b/CHANGELOG.md index db2de1c3ad8..5dd678bcdd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Agents/compaction: add `agents.defaults.compaction.notifyUser` so the `🧹 Compacting context...` start notice is opt-in instead of always being shown. (#54251) Thanks @oguricap0327. - +- Plugins/hooks: add `before_agent_reply` so plugins can short-circuit the LLM with synthetic replies after inline actions. (#20067) thanks @JoshuaLelon ### Fixes - Matrix/multi-account: keep room-level `account` scoping, inherited room overrides, and implicit account selection consistent across top-level default auth, named accounts, and cached-credential env setups. (#58449) thanks @Daanvdplas and @gumadeiras. diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index e9af1a6f11a..57dca031832 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -607,7 +607,7 @@ Compaction lifecycle hooks exposed through the plugin hook runner: ### Complete Plugin Hook Reference -All 27 hooks registered via the Plugin SDK. Hooks marked **sequential** run in priority order and can modify results; **parallel** hooks are fire-and-forget. +All 28 hooks registered via the Plugin SDK. Hooks marked **sequential** run in priority order and can modify results; **parallel** hooks are fire-and-forget. #### Model and prompt hooks @@ -616,6 +616,7 @@ All 27 hooks registered via the Plugin SDK. Hooks marked **sequential** run in p | `before_model_resolve` | Before model/provider lookup | Sequential | `{ modelOverride?, providerOverride? }` | | `before_prompt_build` | After model resolved, session messages ready | Sequential | `{ systemPrompt?, prependContext?, appendSystemContext? }` | | `before_agent_start` | Legacy combined hook (prefer the two above) | Sequential | Union of both result shapes | +| `before_agent_reply` | After inline actions, before the LLM runs | Sequential | `{ handled: boolean, reply?, reason? }` | | `llm_input` | Immediately before the LLM API call | Parallel | `void` | | `llm_output` | Immediately after LLM response received | Parallel | `void` | diff --git a/docs/concepts/agent-loop.md b/docs/concepts/agent-loop.md index 8047616c496..fc47d262c60 100644 --- a/docs/concepts/agent-loop.md +++ b/docs/concepts/agent-loop.md @@ -84,6 +84,7 @@ These run inside the agent loop or gateway pipeline: - **`before_model_resolve`**: runs pre-session (no `messages`) to deterministically override provider/model before model resolution. - **`before_prompt_build`**: runs after session load (with `messages`) to inject `prependContext`, `systemPrompt`, `prependSystemContext`, or `appendSystemContext` before prompt submission. Use `prependContext` for per-turn dynamic text and system-context fields for stable guidance that should sit in system prompt space. - **`before_agent_start`**: legacy compatibility hook that may run in either phase; prefer the explicit hooks above. +- **`before_agent_reply`**: runs after inline actions and before the LLM call, letting a plugin claim the turn and return a synthetic reply or silence the turn entirely. - **`agent_end`**: inspect the final message list and run metadata after completion. - **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles. - **`before_tool_call` / `after_tool_call`**: intercept tool params/results. diff --git a/src/auto-reply/reply/get-reply.before-agent-reply.test.ts b/src/auto-reply/reply/get-reply.before-agent-reply.test.ts new file mode 100644 index 00000000000..10f4c256eeb --- /dev/null +++ b/src/auto-reply/reply/get-reply.before-agent-reply.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { HookRunner } from "../../plugins/hooks.js"; +import type { MsgContext } from "../templating.js"; +import { SILENT_REPLY_TOKEN } from "../tokens.js"; +import "./get-reply.test-runtime-mocks.js"; + +const mocks = vi.hoisted(() => ({ + resolveReplyDirectives: vi.fn(), + handleInlineActions: vi.fn(), + initSessionState: vi.fn(), + hasHooks: vi.fn(), + runBeforeAgentReply: vi.fn(), +})); + +vi.mock("../../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => + ({ + hasHooks: mocks.hasHooks, + runBeforeAgentReply: mocks.runBeforeAgentReply, + }) as unknown as HookRunner, +})); +vi.mock("./get-reply-directives.js", () => ({ + resolveReplyDirectives: (...args: unknown[]) => mocks.resolveReplyDirectives(...args), +})); +vi.mock("./get-reply-inline-actions.js", () => ({ + handleInlineActions: (...args: unknown[]) => mocks.handleInlineActions(...args), +})); +vi.mock("./session.js", () => ({ + initSessionState: (...args: unknown[]) => mocks.initSessionState(...args), +})); + +let getReplyFromConfig: typeof import("./get-reply.js").getReplyFromConfig; + +async function loadFreshGetReplyModuleForTest() { + vi.resetModules(); + ({ getReplyFromConfig } = await import("./get-reply.js")); +} + +function buildCtx(overrides: Partial = {}): MsgContext { + return { + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + OriginatingTo: "telegram:-100123", + ChatType: "group", + Body: "hello world", + BodyForAgent: "hello world", + RawBody: "hello world", + CommandBody: "hello world", + BodyForCommands: "hello world", + SessionKey: "agent:main:telegram:-100123", + From: "telegram:user:42", + To: "telegram:-100123", + Timestamp: 1710000000000, + ...overrides, + }; +} + +function createContinueDirectivesResult() { + return { + kind: "continue" as const, + result: { + commandSource: "text", + command: { + surface: "telegram", + channel: "telegram", + channelId: "telegram", + ownerList: [], + senderIsOwner: false, + isAuthorizedSender: true, + senderId: "42", + abortKey: "agent:main:telegram:-100123", + rawBodyNormalized: "hello world", + commandBodyNormalized: "hello world", + from: "telegram:user:42", + to: "telegram:-100123", + resetHookTriggered: false, + }, + allowTextCommands: true, + skillCommands: [], + directives: {}, + cleanedBody: "hello world", + elevatedEnabled: false, + elevatedAllowed: false, + elevatedFailures: [], + defaultActivation: "always", + resolvedThinkLevel: undefined, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolvedElevatedLevel: "off", + execOverrides: undefined, + blockStreamingEnabled: false, + blockReplyChunking: undefined, + resolvedBlockStreamingBreak: undefined, + provider: "openai", + model: "gpt-4o-mini", + modelState: { + resolveDefaultThinkingLevel: async () => undefined, + }, + contextTokens: 0, + inlineStatusRequested: false, + directiveAck: undefined, + perMessageQueueMode: undefined, + perMessageQueueOptions: undefined, + }, + }; +} + +describe("getReplyFromConfig before_agent_reply wiring", () => { + beforeEach(async () => { + await loadFreshGetReplyModuleForTest(); + mocks.resolveReplyDirectives.mockReset(); + mocks.handleInlineActions.mockReset(); + mocks.initSessionState.mockReset(); + mocks.hasHooks.mockReset(); + mocks.runBeforeAgentReply.mockReset(); + + mocks.initSessionState.mockResolvedValue({ + sessionCtx: buildCtx({ + OriginatingChannel: "Telegram", + Provider: "telegram", + }), + sessionEntry: {}, + previousSessionEntry: {}, + sessionStore: {}, + sessionKey: "agent:main:telegram:-100123", + sessionId: "session-1", + isNewSession: false, + resetTriggered: false, + systemSent: false, + abortedLastRun: false, + storePath: "/tmp/sessions.json", + sessionScope: "per-chat", + groupResolution: undefined, + isGroup: true, + triggerBodyNormalized: "hello world", + bodyStripped: "hello world", + }); + mocks.resolveReplyDirectives.mockResolvedValue(createContinueDirectivesResult()); + mocks.handleInlineActions.mockResolvedValue({ + kind: "continue", + directives: {}, + abortedLastRun: false, + }); + mocks.hasHooks.mockImplementation((hookName) => hookName === "before_agent_reply"); + }); + + it("returns a plugin reply and invokes the hook after inline actions", async () => { + mocks.runBeforeAgentReply.mockResolvedValue({ + handled: true, + reply: { text: "plugin reply" }, + }); + + const result = await getReplyFromConfig(buildCtx(), undefined, {}); + + expect(result).toEqual({ text: "plugin reply" }); + expect(mocks.runBeforeAgentReply).toHaveBeenCalledWith( + { cleanedBody: "hello world" }, + expect.objectContaining({ + agentId: "main", + sessionKey: "agent:main:telegram:-100123", + sessionId: "session-1", + workspaceDir: "/tmp/workspace", + messageProvider: "telegram", + trigger: "user", + channelId: "telegram", + }), + ); + expect(mocks.handleInlineActions.mock.invocationCallOrder[0]).toBeLessThan( + mocks.runBeforeAgentReply.mock.invocationCallOrder[0] ?? 0, + ); + }); + + it("falls back to NO_REPLY when the hook claims without a reply payload", async () => { + mocks.runBeforeAgentReply.mockResolvedValue({ handled: true }); + + const result = await getReplyFromConfig(buildCtx(), undefined, {}); + + expect(result).toEqual({ text: SILENT_REPLY_TOKEN }); + }); +}); 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,