diff --git a/CHANGELOG.md b/CHANGELOG.md index adf0fa4fb2c..e5661780690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Slack/DM typing feedback: add `channels.slack.typingReaction` so Socket Mode DMs can show reaction-based processing status even when Slack native assistant typing is unavailable. (#19816) Thanks @dalefrieswthat. - Cron/job snapshot persistence: skip backup during normalization persistence in `ensureLoaded` so `jobs.json.bak` keeps the pre-edit snapshot for recovery, while preserving backup creation on explicit user-driven writes. (#35234) Thanks @0xsline. - TTS/OpenAI-compatible endpoints: add `messages.tts.openai.baseUrl` config support with config-over-env precedence, endpoint-aware directive validation, and OpenAI TTS request routing to the resolved base URL. (#34321) thanks @RealKai42. +- Plugins/before_prompt_build system-context fields: add `prependSystemContext` and `appendSystemContext` so static plugin guidance can be placed in system prompt space for provider caching and lower repeated prompt token cost. (#35177) thanks @maweibin. ### Fixes diff --git a/docs/concepts/agent-loop.md b/docs/concepts/agent-loop.md index 8699535aa6b..32c4c149b20 100644 --- a/docs/concepts/agent-loop.md +++ b/docs/concepts/agent-loop.md @@ -82,7 +82,7 @@ See [Hooks](/automation/hooks) for setup and examples. 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` before prompt submission. +- **`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. - **`agent_end`**: inspect the final message list and run metadata after completion. - **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index f0335da0e7a..d55d7e43742 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -431,6 +431,54 @@ Notes: - Plugin-managed hooks show up in `openclaw hooks list` with `plugin:`. - You cannot enable/disable plugin-managed hooks via `openclaw hooks`; enable/disable the plugin instead. +### Agent lifecycle hooks (`api.on`) + +For typed runtime lifecycle hooks, use `api.on(...)`: + +```ts +export default function register(api) { + api.on( + "before_prompt_build", + (event, ctx) => { + return { + prependSystemContext: "Follow company style guide.", + }; + }, + { priority: 10 }, + ); +} +``` + +Important hooks for prompt construction: + +- `before_model_resolve`: runs before session load (`messages` are not available). Use this to deterministically override `modelOverride` or `providerOverride`. +- `before_prompt_build`: runs after session load (`messages` are available). Use this to shape prompt input. +- `before_agent_start`: legacy compatibility hook. Prefer the two explicit hooks above. + +`before_prompt_build` result fields: + +- `prependContext`: prepends text to the user prompt for this run. Best for turn-specific or dynamic content. +- `systemPrompt`: full system prompt override. +- `prependSystemContext`: prepends text to the current system prompt. +- `appendSystemContext`: appends text to the current system prompt. + +Prompt build order in embedded runtime: + +1. Apply `prependContext` to the user prompt. +2. Apply `systemPrompt` override when provided. +3. Apply `prependSystemContext + current system prompt + appendSystemContext`. + +Merge and precedence notes: + +- Hook handlers run by priority (higher first). +- For merged context fields, values are concatenated in execution order. +- `before_prompt_build` values are applied before legacy `before_agent_start` fallback values. + +Migration guidance: + +- Move static guidance from `prependContext` to `prependSystemContext` (or `appendSystemContext`) so providers can cache stable system-prefix content. +- Keep `prependContext` for per-turn dynamic context that should stay tied to the user message. + ## Provider plugins (model auth) Plugins can register **model provider auth** flows so users can run OAuth or diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 27982edcf05..4f637a464c2 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; import { + composeSystemPromptWithHookContext, isOllamaCompatProvider, resolveAttemptFsWorkspaceOnly, resolveOllamaBaseUrlForRun, @@ -54,6 +55,8 @@ describe("resolvePromptBuildHookResult", () => { expect(result).toEqual({ prependContext: "from-cache", systemPrompt: "legacy-system", + prependSystemContext: undefined, + appendSystemContext: undefined, }); }); @@ -71,6 +74,58 @@ describe("resolvePromptBuildHookResult", () => { expect(hookRunner.runBeforeAgentStart).toHaveBeenCalledWith({ prompt: "hello", messages }, {}); expect(result.prependContext).toBe("from-hook"); }); + + it("merges prompt-build and legacy context fields in deterministic order", async () => { + const hookRunner = { + hasHooks: vi.fn(() => true), + runBeforePromptBuild: vi.fn(async () => ({ + prependContext: "prompt context", + prependSystemContext: "prompt prepend", + appendSystemContext: "prompt append", + })), + runBeforeAgentStart: vi.fn(async () => ({ + prependContext: "legacy context", + prependSystemContext: "legacy prepend", + appendSystemContext: "legacy append", + })), + }; + + const result = await resolvePromptBuildHookResult({ + prompt: "hello", + messages: [], + hookCtx: {}, + hookRunner, + }); + + expect(result.prependContext).toBe("prompt context\n\nlegacy context"); + expect(result.prependSystemContext).toBe("prompt prepend\n\nlegacy prepend"); + expect(result.appendSystemContext).toBe("prompt append\n\nlegacy append"); + }); +}); + +describe("composeSystemPromptWithHookContext", () => { + it("returns undefined when no hook system context is provided", () => { + expect(composeSystemPromptWithHookContext({ baseSystemPrompt: "base" })).toBeUndefined(); + }); + + it("builds prepend/base/append system prompt order", () => { + expect( + composeSystemPromptWithHookContext({ + baseSystemPrompt: " base system ", + prependSystemContext: " prepend ", + appendSystemContext: " append ", + }), + ).toBe("prepend\n\nbase system\n\nappend"); + }); + + it("avoids blank separators when base system prompt is empty", () => { + expect( + composeSystemPromptWithHookContext({ + baseSystemPrompt: " ", + appendSystemContext: " append only ", + }), + ).toBe("append only"); + }); }); describe("resolvePromptModeForSession", () => { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 1e4357b4a63..54ac8b13489 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -19,6 +19,7 @@ import type { PluginHookBeforePromptBuildResult, } from "../../../plugins/types.js"; import { isSubagentSessionKey } from "../../../routing/session-key.js"; +import { joinPresentTextSegments } from "../../../shared/text/join-segments.js"; import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js"; import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js"; import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js"; @@ -567,12 +568,37 @@ export async function resolvePromptBuildHookResult(params: { : undefined); return { systemPrompt: promptBuildResult?.systemPrompt ?? legacyResult?.systemPrompt, - prependContext: [promptBuildResult?.prependContext, legacyResult?.prependContext] - .filter((value): value is string => Boolean(value)) - .join("\n\n"), + prependContext: joinPresentTextSegments([ + promptBuildResult?.prependContext, + legacyResult?.prependContext, + ]), + prependSystemContext: joinPresentTextSegments([ + promptBuildResult?.prependSystemContext, + legacyResult?.prependSystemContext, + ]), + appendSystemContext: joinPresentTextSegments([ + promptBuildResult?.appendSystemContext, + legacyResult?.appendSystemContext, + ]), }; } +export function composeSystemPromptWithHookContext(params: { + baseSystemPrompt?: string; + prependSystemContext?: string; + appendSystemContext?: string; +}): string | undefined { + const prependSystem = params.prependSystemContext?.trim(); + const appendSystem = params.appendSystemContext?.trim(); + if (!prependSystem && !appendSystem) { + return undefined; + } + return joinPresentTextSegments( + [params.prependSystemContext, params.baseSystemPrompt, params.appendSystemContext], + { trim: true }, + ); +} + export function resolvePromptModeForSession(sessionKey?: string): "minimal" | "full" { if (!sessionKey) { return "full"; @@ -1522,6 +1548,20 @@ export async function runEmbeddedAttempt( systemPromptText = legacySystemPrompt; log.debug(`hooks: applied systemPrompt override (${legacySystemPrompt.length} chars)`); } + const prependedOrAppendedSystemPrompt = composeSystemPromptWithHookContext({ + baseSystemPrompt: systemPromptText, + prependSystemContext: hookResult?.prependSystemContext, + appendSystemContext: hookResult?.appendSystemContext, + }); + if (prependedOrAppendedSystemPrompt) { + const prependSystemLen = hookResult?.prependSystemContext?.trim().length ?? 0; + const appendSystemLen = hookResult?.appendSystemContext?.trim().length ?? 0; + applySystemPromptOverrideToSession(activeSession, prependedOrAppendedSystemPrompt); + systemPromptText = prependedOrAppendedSystemPrompt; + log.debug( + `hooks: applied prependSystemContext/appendSystemContext (${prependSystemLen}+${appendSystemLen} chars)`, + ); + } } log.debug(`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`); diff --git a/src/plugins/hooks.model-override-wiring.test.ts b/src/plugins/hooks.model-override-wiring.test.ts index 74ca09fe39d..6caf4050089 100644 --- a/src/plugins/hooks.model-override-wiring.test.ts +++ b/src/plugins/hooks.model-override-wiring.test.ts @@ -7,6 +7,7 @@ * 3. before_agent_start remains a legacy compatibility fallback */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { joinPresentTextSegments } from "../shared/text/join-segments.js"; import { createHookRunner } from "./hooks.js"; import { addTestHook, TEST_PLUGIN_AGENT_CTX } from "./hooks.test-helpers.js"; import { createEmptyPluginRegistry, type PluginRegistry } from "./registry.js"; @@ -154,9 +155,10 @@ describe("model override pipeline wiring", () => { { prompt: "test", messages: [{ role: "user", content: "x" }] as unknown[] }, stubCtx, ); - const prependContext = [promptBuild?.prependContext, legacy?.prependContext] - .filter((value): value is string => Boolean(value)) - .join("\n\n"); + const prependContext = joinPresentTextSegments([ + promptBuild?.prependContext, + legacy?.prependContext, + ]); expect(prependContext).toBe("new context\n\nlegacy context"); }); diff --git a/src/plugins/hooks.phase-hooks.test.ts b/src/plugins/hooks.phase-hooks.test.ts index 859285a77ff..70a43645f57 100644 --- a/src/plugins/hooks.phase-hooks.test.ts +++ b/src/plugins/hooks.phase-hooks.test.ts @@ -72,4 +72,33 @@ describe("phase hooks merger", () => { expect(result?.prependContext).toBe("context A\n\ncontext B"); expect(result?.systemPrompt).toBe("system A"); }); + + it("before_prompt_build concatenates prependSystemContext and appendSystemContext", async () => { + addTypedHook( + registry, + "before_prompt_build", + "first", + () => ({ + prependSystemContext: "prepend A", + appendSystemContext: "append A", + }), + 10, + ); + addTypedHook( + registry, + "before_prompt_build", + "second", + () => ({ + prependSystemContext: "prepend B", + appendSystemContext: "append B", + }), + 1, + ); + + const runner = createHookRunner(registry); + const result = await runner.runBeforePromptBuild({ prompt: "test", messages: [] }, {}); + + expect(result?.prependSystemContext).toBe("prepend A\n\nprepend B"); + expect(result?.appendSystemContext).toBe("append A\n\nappend B"); + }); }); diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 3a30a4c30d0..4d74267d4ca 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -5,6 +5,7 @@ * error handling, priority ordering, and async support. */ +import { concatOptionalTextSegments } from "../shared/text/join-segments.js"; import type { PluginRegistry } from "./registry.js"; import type { PluginHookAfterCompactionEvent, @@ -140,10 +141,18 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp next: PluginHookBeforePromptBuildResult, ): PluginHookBeforePromptBuildResult => ({ systemPrompt: next.systemPrompt ?? acc?.systemPrompt, - prependContext: - acc?.prependContext && next.prependContext - ? `${acc.prependContext}\n\n${next.prependContext}` - : (next.prependContext ?? acc?.prependContext), + prependContext: concatOptionalTextSegments({ + left: acc?.prependContext, + right: next.prependContext, + }), + prependSystemContext: concatOptionalTextSegments({ + left: acc?.prependSystemContext, + right: next.prependSystemContext, + }), + appendSystemContext: concatOptionalTextSegments({ + left: acc?.appendSystemContext, + right: next.appendSystemContext, + }), }); const mergeSubagentSpawningResult = ( diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 28d10e6206c..4d79f338d84 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -369,6 +369,16 @@ export type PluginHookBeforePromptBuildEvent = { export type PluginHookBeforePromptBuildResult = { systemPrompt?: string; prependContext?: string; + /** + * Prepended to the agent system prompt so providers can cache it (e.g. prompt caching). + * Use for static plugin guidance instead of prependContext to avoid per-turn token cost. + */ + prependSystemContext?: string; + /** + * Appended to the agent system prompt so providers can cache it (e.g. prompt caching). + * Use for static plugin guidance instead of prependContext to avoid per-turn token cost. + */ + appendSystemContext?: string; }; // before_agent_start hook (legacy compatibility: combines both phases) diff --git a/src/shared/text/join-segments.test.ts b/src/shared/text/join-segments.test.ts new file mode 100644 index 00000000000..279516e4269 --- /dev/null +++ b/src/shared/text/join-segments.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { concatOptionalTextSegments, joinPresentTextSegments } from "./join-segments.js"; + +describe("concatOptionalTextSegments", () => { + it("concatenates left and right with default separator", () => { + expect(concatOptionalTextSegments({ left: "A", right: "B" })).toBe("A\n\nB"); + }); + + it("keeps explicit empty-string right value", () => { + expect(concatOptionalTextSegments({ left: "A", right: "" })).toBe(""); + }); +}); + +describe("joinPresentTextSegments", () => { + it("joins non-empty segments", () => { + expect(joinPresentTextSegments(["A", undefined, "B"])).toBe("A\n\nB"); + }); + + it("returns undefined when all segments are empty", () => { + expect(joinPresentTextSegments(["", undefined, null])).toBeUndefined(); + }); + + it("trims segments when requested", () => { + expect(joinPresentTextSegments([" A ", " B "], { trim: true })).toBe("A\n\nB"); + }); +}); diff --git a/src/shared/text/join-segments.ts b/src/shared/text/join-segments.ts new file mode 100644 index 00000000000..e6215d7caf3 --- /dev/null +++ b/src/shared/text/join-segments.ts @@ -0,0 +1,34 @@ +export function concatOptionalTextSegments(params: { + left?: string; + right?: string; + separator?: string; +}): string | undefined { + const separator = params.separator ?? "\n\n"; + if (params.left && params.right) { + return `${params.left}${separator}${params.right}`; + } + return params.right ?? params.left; +} + +export function joinPresentTextSegments( + segments: ReadonlyArray, + options?: { + separator?: string; + trim?: boolean; + }, +): string | undefined { + const separator = options?.separator ?? "\n\n"; + const trim = options?.trim ?? false; + const values: string[] = []; + for (const segment of segments) { + if (typeof segment !== "string") { + continue; + } + const normalized = trim ? segment.trim() : segment; + if (!normalized) { + continue; + } + values.push(normalized); + } + return values.length > 0 ? values.join(separator) : undefined; +}