add prependSystemContext and appendSystemContext to before_prompt_build (fixes #35131) (#35177)

Merged via squash.

Prepared head SHA: d9a2869ad69db9449336a2e2846bd9de0e647ac6
Co-authored-by: maweibin <18023423+maweibin@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
maweibin
2026-03-06 02:06:59 +08:00
committed by GitHub
parent 174eeea76c
commit 09c68f8f0e
11 changed files with 265 additions and 11 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -431,6 +431,54 @@ Notes:
- Plugin-managed hooks show up in `openclaw hooks list` with `plugin:<id>`.
- 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

View File

@@ -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", () => {

View File

@@ -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}`);

View File

@@ -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");
});

View File

@@ -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");
});
});

View File

@@ -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 = (

View File

@@ -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)

View File

@@ -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");
});
});

View File

@@ -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<string | null | undefined>,
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;
}