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

Merged via squash.

Prepared head SHA: d9a2869ad6
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

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