Agents: move bootstrap warnings out of system prompt (#48753)

Merged via squash.

Prepared head SHA: dc1d4d075a
Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com>
Reviewed-by: @scoootscooob
This commit is contained in:
scoootscooob 2026-03-16 23:25:04 -07:00 committed by GitHub
parent 57204b4fa9
commit 80a2af1d65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 299 additions and 34 deletions

View File

@ -115,6 +115,10 @@ Docs: https://docs.openclaw.ai
- macOS/exec approvals: harden exec-host request HMAC verification to use a timing-safe compare and keep malformed or truncated signatures fail-closed in focused IPC auth coverage.
- Gateway/exec approvals: surface requested env override keys in gateway-host approval prompts so operators can review surviving env context without inheriting noisy base host env.
### Fixes
- Agents/bootstrap warnings: move bootstrap truncation warnings out of the system prompt and into the per-turn prompt body so prompt-cache reuse stays stable when truncation warnings appear or disappear. (#48753) Thanks @scoootscooob and @obviyus.
## 2026.3.13
### Changes

View File

@ -6,8 +6,10 @@ import {
buildBootstrapTruncationReportMeta,
buildBootstrapTruncationSignature,
formatBootstrapTruncationWarningLines,
prependBootstrapPromptWarning,
resolveBootstrapWarningSignaturesSeen,
} from "./bootstrap-budget.js";
import { buildAgentSystemPrompt } from "./system-prompt.js";
import type { WorkspaceBootstrapFile } from "./workspace.js";
describe("buildBootstrapInjectionStats", () => {
@ -104,6 +106,34 @@ describe("analyzeBootstrapBudget", () => {
});
describe("bootstrap prompt warnings", () => {
it("prepends warning details to the turn prompt instead of mutating the system prompt", () => {
const prompt = prependBootstrapPromptWarning("Please continue.", [
"AGENTS.md: 200 raw -> 0 injected",
]);
expect(prompt).toContain("[Bootstrap truncation warning]");
expect(prompt).toContain("Treat Project Context as partial");
expect(prompt).toContain("- AGENTS.md: 200 raw -> 0 injected");
expect(prompt).toContain("Please continue.");
});
it("preserves raw prompt whitespace when prepending warning details", () => {
const prompt = prependBootstrapPromptWarning(" indented\nkeep tail ", [
"AGENTS.md: 200 raw -> 0 injected",
]);
expect(prompt.endsWith(" indented\nkeep tail ")).toBe(true);
});
it("preserves exact heartbeat prompts without warning prefixes", () => {
const heartbeatPrompt = "Read HEARTBEAT.md. Reply HEARTBEAT_OK.";
expect(
prependBootstrapPromptWarning(heartbeatPrompt, ["AGENTS.md: 200 raw -> 0 injected"], {
preserveExactPrompt: heartbeatPrompt,
}),
).toBe(heartbeatPrompt);
});
it("resolves seen signatures from report history or legacy single signature", () => {
expect(
resolveBootstrapWarningSignaturesSeen({
@ -394,4 +424,35 @@ describe("bootstrap prompt warnings", () => {
expect(meta.promptWarningSignature).toBeTruthy();
expect(meta.warningSignaturesSeen?.length).toBeGreaterThan(0);
});
it("improves cache-relevant system prompt stability versus legacy warning injection", () => {
const contextFiles = [{ path: "AGENTS.md", content: "Follow AGENTS guidance." }];
const warningLines = ["AGENTS.md: 200 raw -> 0 injected"];
const stableSystemPrompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
contextFiles,
});
const optimizedTurns = [stableSystemPrompt, stableSystemPrompt, stableSystemPrompt];
const injectLegacyWarning = (prompt: string, lines: string[]) => {
const warningBlock = [
"⚠ Bootstrap truncation warning:",
...lines.map((line) => `- ${line}`),
"",
].join("\n");
return prompt.replace("## AGENTS.md", `${warningBlock}## AGENTS.md`);
};
const legacyTurns = [
injectLegacyWarning(optimizedTurns[0] ?? "", warningLines),
optimizedTurns[1] ?? "",
injectLegacyWarning(optimizedTurns[2] ?? "", warningLines),
];
const cacheHitRate = (turns: string[]) => {
const hits = turns.slice(1).filter((turn, index) => turn === turns[index]).length;
return hits / Math.max(1, turns.length - 1);
};
expect(cacheHitRate(legacyTurns)).toBe(0);
expect(cacheHitRate(optimizedTurns)).toBe(1);
expect(optimizedTurns[0]).not.toContain("⚠ Bootstrap truncation warning:");
});
});

View File

@ -330,6 +330,29 @@ export function buildBootstrapPromptWarning(params: {
};
}
export function prependBootstrapPromptWarning(
prompt: string,
warningLines?: string[],
options?: {
preserveExactPrompt?: string;
},
): string {
const normalizedLines = (warningLines ?? []).map((line) => line.trim()).filter(Boolean);
if (normalizedLines.length === 0) {
return prompt;
}
if (options?.preserveExactPrompt && prompt === options.preserveExactPrompt) {
return prompt;
}
const warningBlock = [
"[Bootstrap truncation warning]",
"Some workspace bootstrap files were truncated before injection.",
"Treat Project Context as partial and read the relevant files directly if details seem missing.",
...normalizedLines.map((line) => `- ${line}`),
].join("\n");
return prompt ? `${warningBlock}\n\n${prompt}` : warningBlock;
}
export function buildBootstrapTruncationReportMeta(params: {
analysis: BootstrapBudgetAnalysis;
warningMode: BootstrapPromptWarningMode;

View File

@ -5,10 +5,25 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { runCliAgent } from "./cli-runner.js";
import { resolveCliNoOutputTimeoutMs } from "./cli-runner/helpers.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
import type { WorkspaceBootstrapFile } from "./workspace.js";
const supervisorSpawnMock = vi.fn();
const enqueueSystemEventMock = vi.fn();
const requestHeartbeatNowMock = vi.fn();
const hoisted = vi.hoisted(() => {
type BootstrapContext = {
bootstrapFiles: WorkspaceBootstrapFile[];
contextFiles: EmbeddedContextFile[];
};
return {
resolveBootstrapContextForRunMock: vi.fn<() => Promise<BootstrapContext>>(async () => ({
bootstrapFiles: [],
contextFiles: [],
})),
};
});
vi.mock("../process/supervisor/index.js", () => ({
getProcessSupervisor: () => ({
@ -28,6 +43,11 @@ vi.mock("../infra/heartbeat-wake.js", () => ({
requestHeartbeatNow: (...args: unknown[]) => requestHeartbeatNowMock(...args),
}));
vi.mock("./bootstrap-files.js", () => ({
makeBootstrapWarn: () => () => {},
resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock,
}));
type MockRunExit = {
reason:
| "manual-cancel"
@ -61,6 +81,10 @@ describe("runCliAgent with process supervisor", () => {
supervisorSpawnMock.mockClear();
enqueueSystemEventMock.mockClear();
requestHeartbeatNowMock.mockClear();
hoisted.resolveBootstrapContextForRunMock.mockReset().mockResolvedValue({
bootstrapFiles: [],
contextFiles: [],
});
});
it("runs CLI through supervisor and returns payload", async () => {
@ -107,6 +131,62 @@ describe("runCliAgent with process supervisor", () => {
expect(input.scopeKey).toContain("thread-123");
});
it("prepends bootstrap warnings to the CLI prompt body", async () => {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "ok",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
);
hoisted.resolveBootstrapContextForRunMock.mockResolvedValueOnce({
bootstrapFiles: [
{
name: "AGENTS.md",
path: "/tmp/AGENTS.md",
content: "A".repeat(200),
missing: false,
},
],
contextFiles: [{ path: "AGENTS.md", content: "A".repeat(20) }],
});
await runCliAgent({
sessionId: "s1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {
agents: {
defaults: {
bootstrapMaxChars: 50,
bootstrapTotalMaxChars: 50,
},
},
} satisfies OpenClawConfig,
prompt: "hi",
provider: "codex-cli",
model: "gpt-5.2-codex",
timeoutMs: 1_000,
runId: "run-warning",
cliSessionId: "thread-123",
});
const input = supervisorSpawnMock.mock.calls[0]?.[0] as {
argv?: string[];
input?: string;
};
const promptCarrier = [input.input ?? "", ...(input.argv ?? [])].join("\n");
expect(promptCarrier).toContain("[Bootstrap truncation warning]");
expect(promptCarrier).toContain("- AGENTS.md: 200 raw -> 20 injected");
expect(promptCarrier).toContain("hi");
});
it("fails with timeout when no-output watchdog trips", async () => {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({

View File

@ -15,6 +15,7 @@ import {
buildBootstrapInjectionStats,
buildBootstrapPromptWarning,
buildBootstrapTruncationReportMeta,
prependBootstrapPromptWarning,
} from "./bootstrap-budget.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
@ -162,7 +163,6 @@ export async function runCliAgent(params: {
docsPath: docsPath ?? undefined,
tools: [],
contextFiles,
bootstrapTruncationWarningLines: bootstrapPromptWarning.lines,
modelDisplay,
agentId: sessionAgentId,
});
@ -218,7 +218,9 @@ export async function runCliAgent(params: {
let imagePaths: string[] | undefined;
let cleanupImages: (() => Promise<void>) | undefined;
let prompt = params.prompt;
let prompt = prependBootstrapPromptWarning(params.prompt, bootstrapPromptWarning.lines, {
preserveExactPrompt: heartbeatPrompt,
});
if (params.images && params.images.length > 0) {
const imagePayload = await writeCliImages(params.images);
imagePaths = imagePayload.paths;

View File

@ -48,7 +48,6 @@ export function buildSystemPrompt(params: {
docsPath?: string;
tools: AgentTool[];
contextFiles?: EmbeddedContextFile[];
bootstrapTruncationWarningLines?: string[];
modelDisplay: string;
agentId?: string;
}) {
@ -92,7 +91,6 @@ export function buildSystemPrompt(params: {
userTime,
userTimeFormat,
contextFiles: params.contextFiles,
bootstrapTruncationWarningLines: params.bootstrapTruncationWarningLines,
ttsHint,
memoryCitationsMode: params.config?.memory?.citations,
});

View File

@ -18,16 +18,27 @@ import type {
IngestBatchResult,
IngestResult,
} from "../../../context-engine/types.js";
import type { EmbeddedContextFile } from "../../pi-embedded-helpers.js";
import { createHostSandboxFsBridge } from "../../test-helpers/host-sandbox-fs-bridge.js";
import { createPiToolsSandboxContext } from "../../test-helpers/pi-tools-sandbox-context.js";
import type { WorkspaceBootstrapFile } from "../../workspace.js";
const hoisted = vi.hoisted(() => {
type BootstrapContext = {
bootstrapFiles: WorkspaceBootstrapFile[];
contextFiles: EmbeddedContextFile[];
};
const spawnSubagentDirectMock = vi.fn();
const createAgentSessionMock = vi.fn();
const sessionManagerOpenMock = vi.fn();
const resolveSandboxContextMock = vi.fn();
const subscribeEmbeddedPiSessionMock = vi.fn();
const acquireSessionWriteLockMock = vi.fn();
const resolveBootstrapContextForRunMock = vi.fn<() => Promise<BootstrapContext>>(async () => ({
bootstrapFiles: [],
contextFiles: [],
}));
const getGlobalHookRunnerMock = vi.fn<() => unknown>(() => undefined);
const sessionManager = {
getLeafEntry: vi.fn(() => null),
branch: vi.fn(),
@ -42,6 +53,8 @@ const hoisted = vi.hoisted(() => {
resolveSandboxContextMock,
subscribeEmbeddedPiSessionMock,
acquireSessionWriteLockMock,
resolveBootstrapContextForRunMock,
getGlobalHookRunnerMock,
sessionManager,
};
});
@ -80,7 +93,7 @@ vi.mock("../../pi-embedded-subscribe.js", () => ({
}));
vi.mock("../../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => undefined,
getGlobalHookRunner: hoisted.getGlobalHookRunnerMock,
}));
vi.mock("../../../infra/machine-name.js", () => ({
@ -94,7 +107,7 @@ vi.mock("../../../infra/net/undici-global-dispatcher.js", () => ({
vi.mock("../../bootstrap-files.js", () => ({
makeBootstrapWarn: () => () => {},
resolveBootstrapContextForRun: async () => ({ bootstrapFiles: [], contextFiles: [] }),
resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock,
}));
vi.mock("../../skills.js", () => ({
@ -269,6 +282,11 @@ function resetEmbeddedAttemptHarness(
hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({
release: async () => {},
});
hoisted.resolveBootstrapContextForRunMock.mockReset().mockResolvedValue({
bootstrapFiles: [],
contextFiles: [],
});
hoisted.getGlobalHookRunnerMock.mockReset().mockReturnValue(undefined);
hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null);
hoisted.sessionManager.branch.mockReset();
hoisted.sessionManager.resetLeaf.mockReset();
@ -291,7 +309,11 @@ async function cleanupTempPaths(tempPaths: string[]) {
}
function createDefaultEmbeddedSession(params?: {
prompt?: (session: MutableSession) => Promise<void>;
prompt?: (
session: MutableSession,
prompt: string,
options?: { images?: unknown[] },
) => Promise<void>;
}): MutableSession {
const session: MutableSession = {
sessionId: "embedded-session",
@ -303,9 +325,9 @@ function createDefaultEmbeddedSession(params?: {
session.messages = [...messages];
},
},
prompt: async () => {
prompt: async (prompt, options) => {
if (params?.prompt) {
await params.prompt(session);
await params.prompt(session, prompt, options);
return;
}
session.messages = [
@ -450,6 +472,90 @@ describe("runEmbeddedAttempt sessions_spawn workspace inheritance", () => {
});
});
describe("runEmbeddedAttempt bootstrap warning prompt assembly", () => {
const tempPaths: string[] = [];
beforeEach(() => {
resetEmbeddedAttemptHarness({
subscribeImpl: createSubscriptionMock,
});
});
afterEach(async () => {
await cleanupTempPaths(tempPaths);
});
it("keeps bootstrap warnings in the sent prompt after hook prepend context", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-warning-workspace-"));
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-warning-agent-dir-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
tempPaths.push(workspaceDir, agentDir);
await fs.writeFile(sessionFile, "", "utf8");
hoisted.resolveBootstrapContextForRunMock.mockResolvedValue({
bootstrapFiles: [
{
name: "AGENTS.md",
path: path.join(workspaceDir, "AGENTS.md"),
content: "A".repeat(200),
missing: false,
},
],
contextFiles: [{ path: "AGENTS.md", content: "A".repeat(20) }],
});
hoisted.getGlobalHookRunnerMock.mockReturnValue({
hasHooks: (hookName: string) => hookName === "before_prompt_build",
runBeforePromptBuild: async () => ({ prependContext: "hook context" }),
});
let seenPrompt = "";
hoisted.createAgentSessionMock.mockImplementation(async () => ({
session: createDefaultEmbeddedSession({
prompt: async (session, prompt) => {
seenPrompt = prompt;
session.messages = [
...session.messages,
{ role: "assistant", content: "done", timestamp: 2 },
];
},
}),
}));
const result = await runEmbeddedAttempt({
sessionId: "embedded-session",
sessionKey: "agent:main:main",
sessionFile,
workspaceDir,
agentDir,
config: {
agents: {
defaults: {
bootstrapMaxChars: 50,
bootstrapTotalMaxChars: 50,
},
},
},
prompt: "hello",
timeoutMs: 10_000,
runId: "run-warning",
provider: "openai",
modelId: "gpt-test",
model: testModel,
authStorage: {} as AuthStorage,
modelRegistry: {} as ModelRegistry,
thinkLevel: "off",
senderIsOwner: true,
disableMessageTool: true,
});
expect(result.promptError).toBeNull();
expect(seenPrompt).toContain("hook context");
expect(seenPrompt).toContain("[Bootstrap truncation warning]");
expect(seenPrompt).toContain("- AGENTS.md: 200 raw -> 20 injected");
expect(seenPrompt).toContain("hello");
});
});
describe("runEmbeddedAttempt cache-ttl tracking after compaction", () => {
const tempPaths: string[] = [];

View File

@ -41,6 +41,7 @@ import {
buildBootstrapPromptWarning,
buildBootstrapTruncationReportMeta,
buildBootstrapInjectionStats,
prependBootstrapPromptWarning,
} from "../../bootstrap-budget.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
import { createCacheTrace } from "../../cache-trace.js";
@ -1665,6 +1666,9 @@ export async function runEmbeddedAttempt(
});
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
const ownerDisplay = resolveOwnerDisplaySetting(params.config);
const heartbeatPrompt = isDefaultAgent
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined;
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
@ -1675,9 +1679,7 @@ export async function runEmbeddedAttempt(
ownerDisplay: ownerDisplay.ownerDisplay,
ownerDisplaySecret: ownerDisplay.ownerDisplaySecret,
reasoningTagHint,
heartbeatPrompt: isDefaultAgent
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined,
heartbeatPrompt,
skillsPrompt,
docsPath: docsPath ?? undefined,
ttsHint,
@ -1694,7 +1696,6 @@ export async function runEmbeddedAttempt(
userTime,
userTimeFormat,
contextFiles,
bootstrapTruncationWarningLines: bootstrapPromptWarning.lines,
memoryCitationsMode: params.config?.memory?.citations,
});
const systemPromptReport = buildSystemPromptReport({
@ -2378,7 +2379,13 @@ export async function runEmbeddedAttempt(
// Run before_prompt_build hooks to allow plugins to inject prompt context.
// Legacy compatibility: before_agent_start is also checked for context fields.
let effectivePrompt = params.prompt;
let effectivePrompt = prependBootstrapPromptWarning(
params.prompt,
bootstrapPromptWarning.lines,
{
preserveExactPrompt: heartbeatPrompt,
},
);
const hookCtx = {
agentId: hookAgentId,
sessionKey: params.sessionKey,
@ -2397,7 +2404,7 @@ export async function runEmbeddedAttempt(
});
{
if (hookResult?.prependContext) {
effectivePrompt = `${hookResult.prependContext}\n\n${params.prompt}`;
effectivePrompt = `${hookResult.prependContext}\n\n${effectivePrompt}`;
log.debug(
`hooks: prepended context to prompt (${hookResult.prependContext.length} chars)`,
);

View File

@ -51,7 +51,6 @@ export function buildEmbeddedSystemPrompt(params: {
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
contextFiles?: EmbeddedContextFile[];
bootstrapTruncationWarningLines?: string[];
memoryCitationsMode?: MemoryCitationsMode;
}): string {
return buildAgentSystemPrompt({
@ -81,7 +80,6 @@ export function buildEmbeddedSystemPrompt(params: {
userTime: params.userTime,
userTimeFormat: params.userTimeFormat,
contextFiles: params.contextFiles,
bootstrapTruncationWarningLines: params.bootstrapTruncationWarningLines,
memoryCitationsMode: params.memoryCitationsMode,
});
}

View File

@ -534,16 +534,13 @@ describe("buildAgentSystemPrompt", () => {
);
});
it("renders bootstrap truncation warning even when no context files are injected", () => {
it("omits project context when no context files are injected", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
bootstrapTruncationWarningLines: ["AGENTS.md: 200 raw -> 0 injected"],
contextFiles: [],
});
expect(prompt).toContain("# Project Context");
expect(prompt).toContain("⚠ Bootstrap truncation warning:");
expect(prompt).toContain("- AGENTS.md: 200 raw -> 0 injected");
expect(prompt).not.toContain("# Project Context");
});
it("summarizes the message tool when available", () => {

View File

@ -202,7 +202,6 @@ export function buildAgentSystemPrompt(params: {
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
contextFiles?: EmbeddedContextFile[];
bootstrapTruncationWarningLines?: string[];
skillsPrompt?: string;
heartbeatPrompt?: string;
docsPath?: string;
@ -614,13 +613,10 @@ export function buildAgentSystemPrompt(params: {
}
const contextFiles = params.contextFiles ?? [];
const bootstrapTruncationWarningLines = (params.bootstrapTruncationWarningLines ?? []).filter(
(line) => line.trim().length > 0,
);
const validContextFiles = contextFiles.filter(
(file) => typeof file.path === "string" && file.path.trim().length > 0,
);
if (validContextFiles.length > 0 || bootstrapTruncationWarningLines.length > 0) {
if (validContextFiles.length > 0) {
lines.push("# Project Context", "");
if (validContextFiles.length > 0) {
const hasSoulFile = validContextFiles.some((file) => {
@ -636,13 +632,6 @@ export function buildAgentSystemPrompt(params: {
}
lines.push("");
}
if (bootstrapTruncationWarningLines.length > 0) {
lines.push("⚠ Bootstrap truncation warning:");
for (const warningLine of bootstrapTruncationWarningLines) {
lines.push(`- ${warningLine}`);
}
lines.push("");
}
for (const file of validContextFiles) {
lines.push(`## ${file.path}`, "", file.content, "");
}