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 }); + }); +});