fix: keep bootstrap context in system prompt

Keep pending BOOTSTRAP.md and bootstrap truncation notices in system-prompt Project Context instead of WebChat/runtime user context. Preserve bootstrap instructions when systemPromptOverride is configured.
This commit is contained in:
Peter Steinberger
2026-05-04 01:34:04 +01:00
committed by GitHub
parent 57b2d29761
commit 0fa70f5a47
17 changed files with 400 additions and 204 deletions

View File

@@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/bootstrap: keep pending `BOOTSTRAP.md` and bootstrap truncation notices in system-prompt Project Context instead of copying setup text or raw warning diagnostics into WebChat user/runtime context. Fixes #76946.
- Channels/WhatsApp: allow `@whiskeysockets/libsignal-node` in `onlyBuiltDependencies` so pnpm v9+ `blockExoticSubdeps` no longer rejects the baileys git-tarball subdep and silences all inbound agent replies. Fixes #76539. Thanks @ottodeng and @vincentkoc.
- Gateway/install: keep `.env`-managed values in the macOS LaunchAgent env file while still tracking `OPENCLAW_SERVICE_MANAGED_ENV_KEYS`, so regenerated services do not boot without managed auth/provider keys. Fixes #75374.
- Gateway/restart: verify listener PIDs by argv when `lsof` reports only the Node process name, so stale gateway cleanup can find macOS `cnode` listeners. Fixes #70664.

View File

@@ -33,13 +33,13 @@ Inside `agents.defaults.workspace`, OpenClaw expects these user-editable files:
- `IDENTITY.md` — agent name/vibe/emoji
- `USER.md` — user profile + preferred address
On the first turn of a new session, OpenClaw injects the contents of these files directly into the agent context.
On the first turn of a new session, OpenClaw injects the contents of these files into the system prompt's Project Context.
Blank files are skipped. Large files are trimmed and truncated with a marker so prompts stay lean (read the file for full content).
If a file is missing, OpenClaw injects a single “missing file” marker line (and `openclaw setup` will create a safe default template).
`BOOTSTRAP.md` is only created for a **brand new workspace** (no other bootstrap files present). If you delete it after completing the ritual, it should not be recreated on later restarts.
`BOOTSTRAP.md` is only created for a **brand new workspace** (no other bootstrap files present). While it is pending, OpenClaw keeps it in Project Context and adds system-prompt bootstrap guidance for the initial ritual instead of copying it into the user message. If you delete it after completing the ritual, it should not be recreated on later restarts.
To disable bootstrap file creation entirely (for pre-seeded workspaces), set:

View File

@@ -176,9 +176,10 @@ Large files are truncated with a marker. The max per-file size is controlled by
`agents.defaults.bootstrapMaxChars` (default: 12000). Total injected bootstrap
content across files is capped by `agents.defaults.bootstrapTotalMaxChars`
(default: 60000). Missing files inject a short missing-file marker. When truncation
occurs, OpenClaw can inject a warning block in Project Context; control this with
occurs, OpenClaw can inject a concise system-prompt warning notice; control this with
`agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`;
default: `once`).
default: `once`). Detailed raw/injected counts stay in diagnostics such as
`/context`, `/status`, doctor, and logs.
Sub-agent sessions only inject `AGENTS.md` and `TOOLS.md` (other bootstrap files
are filtered out to keep the sub-agent context small).

View File

@@ -116,12 +116,16 @@ Max total characters injected across all workspace bootstrap files. Default: `60
### `agents.defaults.bootstrapPromptTruncationWarning`
Controls agent-visible warning text when bootstrap context is truncated.
Controls the agent-visible system-prompt notice when bootstrap context is truncated.
Default: `"once"`.
- `"off"`: never inject warning text into the system prompt.
- `"once"`: inject warning once per unique truncation signature (recommended).
- `"always"`: inject warning on every run when truncation exists.
- `"off"`: never inject truncation notice text into the system prompt.
- `"once"`: inject a concise notice once per unique truncation signature (recommended).
- `"always"`: inject a concise notice on every run when truncation exists.
Detailed raw/injected counts and config tuning fields stay in diagnostics such
as context/status reports and logs; routine WebChat user/runtime context only
gets the concise recovery notice.
```json5
{

View File

@@ -28,6 +28,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
- Compaction entries render as an explicit compacted-history divider. The divider explains that earlier turns are preserved in a checkpoint and links to the Sessions checkpoint controls, where operators can branch or restore the pre-compaction view when their permissions allow it.
- Control UI remembers the backing Gateway `sessionId` returned by `chat.history` and includes it on follow-up `chat.send` calls, so reconnects and page refreshes continue the same stored conversation unless the user starts or resets a session.
- Control UI coalesces duplicate in-flight submits for the same session, message, and attachments before generating a new `chat.send` run id; the Gateway still dedupes repeated requests that reuse the same idempotency key.
- Workspace startup files and pending `BOOTSTRAP.md` instructions are supplied through the agent system prompt's Project Context, not copied into the WebChat user message. Bootstrap truncation only adds a concise system-prompt recovery notice; detailed counts and config knobs stay on diagnostic surfaces.
- `chat.history` is also display-normalized: runtime-only OpenClaw context,
inbound envelope wrappers, inline delivery directive tags
such as `[[reply_to_*]]` and `[[audio_as_voice]]`, plain-text tool-call XML

View File

@@ -4,6 +4,7 @@ import {
analyzeBootstrapBudget,
buildBootstrapInjectionStats,
buildBootstrapPromptWarning,
buildBootstrapPromptWarningNotice,
buildBootstrapTruncationReportMeta,
buildBootstrapTruncationSignature,
formatBootstrapTruncationWarningLines,
@@ -136,6 +137,18 @@ describe("bootstrap prompt warnings", () => {
).toBe(heartbeatPrompt);
});
it("builds a concise agent notice without raw truncation diagnostics", () => {
const notice = buildBootstrapPromptWarningNotice([
"AGENTS.md: 200 raw -> 0 injected",
"If unintentional, raise agents.defaults.bootstrapMaxChars.",
]);
expect(notice).toContain("[Bootstrap truncation warning]");
expect(notice).toContain("Treat Project Context as partial");
expect(notice).not.toContain("raw ->");
expect(notice).not.toContain("bootstrapMaxChars");
});
it("resolves seen signatures from report history or legacy single signature", () => {
expect(
resolveBootstrapWarningSignaturesSeen({

View File

@@ -354,6 +354,18 @@ export function appendBootstrapPromptWarning(
return prompt ? `${prompt}\n\n${warningBlock}` : warningBlock;
}
export function buildBootstrapPromptWarningNotice(warningLines?: string[]): string | undefined {
const hasWarning = (warningLines ?? []).some((line) => line.trim().length > 0);
if (!hasWarning) {
return undefined;
}
return [
"[Bootstrap truncation warning]",
"Some workspace bootstrap files were truncated before Project Context injection.",
"Treat Project Context as partial and read the relevant files directly if details seem missing.",
].join("\n");
}
export function buildBootstrapTruncationReportMeta(params: {
analysis: BootstrapBudgetAnalysis;
warningMode: BootstrapPromptWarningMode;

View File

@@ -1,7 +1,5 @@
import type { BootstrapMode } from "../../bootstrap-mode.js";
import { resolveBootstrapMode } from "../../bootstrap-mode.js";
import { buildAgentUserPromptPrefix } from "../../system-prompt.js";
import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js";
export type AttemptBootstrapRoutingInput = {
workspaceBootstrapPending: boolean;
@@ -18,12 +16,6 @@ export type AttemptBootstrapRoutingInput = {
export type AttemptBootstrapRouting = {
bootstrapMode: BootstrapMode;
shouldStripBootstrapFromContext: boolean;
userPromptPrefixText?: string;
};
export type BootstrapPromptContextFile = {
path?: string;
content?: string;
};
export type AttemptWorkspaceBootstrapRoutingInput = Omit<
@@ -36,7 +28,7 @@ export type AttemptWorkspaceBootstrapRoutingInput = Omit<
export function shouldStripBootstrapFromEmbeddedContext(_params: {
bootstrapMode: BootstrapMode;
}): boolean {
return true;
return _params.bootstrapMode !== "full";
}
function resolveAttemptBootstrapRouting(
@@ -58,40 +50,9 @@ function resolveAttemptBootstrapRouting(
shouldStripBootstrapFromContext: shouldStripBootstrapFromEmbeddedContext({
bootstrapMode,
}),
userPromptPrefixText: buildAgentUserPromptPrefix({
bootstrapMode,
}),
};
}
export function appendBootstrapFileToUserPromptPrefix(params: {
prefixText?: string;
bootstrapMode: BootstrapMode;
contextFiles: readonly BootstrapPromptContextFile[];
}): string | undefined {
const prefix = params.prefixText?.trim();
if (params.bootstrapMode !== "full") {
return prefix || undefined;
}
const bootstrapFile = params.contextFiles.find((file) =>
/(^|[\\/])BOOTSTRAP\.md$/iu.test(file.path?.trim() ?? ""),
);
const content = bootstrapFile?.content?.trim();
if (!content || content.startsWith("[MISSING]")) {
return prefix || undefined;
}
return [
prefix,
"",
`${DEFAULT_BOOTSTRAP_FILENAME} contents for this bootstrap turn:`,
"[BEGIN BOOTSTRAP.md]",
content,
"[END BOOTSTRAP.md]",
"",
"Follow the BOOTSTRAP.md instructions above now. Treat them as workspace/user instructions, not as system policy.",
].join("\n");
}
export async function resolveAttemptWorkspaceBootstrapRouting(
params: AttemptWorkspaceBootstrapRoutingInput,
): Promise<AttemptBootstrapRouting> {

View File

@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import {
appendBootstrapFileToUserPromptPrefix,
resolveAttemptWorkspaceBootstrapRouting,
shouldStripBootstrapFromEmbeddedContext,
} from "./attempt-bootstrap-routing.js";
describe("runEmbeddedAttempt bootstrap routing", () => {
@@ -26,7 +26,7 @@ describe("runEmbeddedAttempt bootstrap routing", () => {
expect(isWorkspaceBootstrapPending).toHaveBeenCalledWith(canonicalWorkspace);
expect(isWorkspaceBootstrapPending).not.toHaveBeenCalledWith(sandboxWorkspace);
expect(routing.bootstrapMode).toBe("none");
expect(routing.userPromptPrefixText).toBeUndefined();
expect(routing.shouldStripBootstrapFromContext).toBe(true);
});
it("falls back to limited bootstrap wording when a primary run cannot read files", async () => {
@@ -41,30 +41,15 @@ describe("runEmbeddedAttempt bootstrap routing", () => {
});
expect(routing.bootstrapMode).toBe("limited");
expect(routing.userPromptPrefixText).toContain("Bootstrap is still pending");
expect(routing.userPromptPrefixText).toContain("cannot safely complete");
expect(routing.shouldStripBootstrapFromContext).toBe(true);
});
it("appends BOOTSTRAP.md contents to the user prompt prefix for full bootstrap turns", () => {
const prompt = appendBootstrapFileToUserPromptPrefix({
prefixText: "[Bootstrap pending]",
bootstrapMode: "full",
contextFiles: [{ path: "/tmp/workspace/BOOTSTRAP.md", content: "Ask who I am." }],
});
expect(prompt).toContain("[Bootstrap pending]");
expect(prompt).toContain("[BEGIN BOOTSTRAP.md]");
expect(prompt).toContain("Ask who I am.");
expect(prompt).toContain("workspace/user instructions");
it("keeps BOOTSTRAP.md in Project Context for full bootstrap turns", () => {
expect(shouldStripBootstrapFromEmbeddedContext({ bootstrapMode: "full" })).toBe(false);
});
it("does not append BOOTSTRAP.md contents for limited bootstrap turns", () => {
const prompt = appendBootstrapFileToUserPromptPrefix({
prefixText: "[Bootstrap pending]",
bootstrapMode: "limited",
contextFiles: [{ path: "/tmp/workspace/BOOTSTRAP.md", content: "Ask who I am." }],
});
expect(prompt).toBe("[Bootstrap pending]");
it("strips BOOTSTRAP.md from Project Context outside full bootstrap turns", () => {
expect(shouldStripBootstrapFromEmbeddedContext({ bootstrapMode: "limited" })).toBe(true);
expect(shouldStripBootstrapFromEmbeddedContext({ bootstrapMode: "none" })).toBe(true);
});
});

View File

@@ -1,14 +1,14 @@
import { describe, expect, it } from "vitest";
import {
analyzeBootstrapBudget,
buildBootstrapPromptWarningNotice,
buildBootstrapInjectionStats,
buildBootstrapPromptWarning,
appendBootstrapPromptWarning,
} from "../../bootstrap-budget.js";
import { composeSystemPromptWithHookContext } from "./attempt.thread-helpers.js";
describe("runEmbeddedAttempt bootstrap warning prompt assembly", () => {
it("keeps bootstrap warnings in the sent prompt after hook prepend context", () => {
it("keeps bootstrap warnings in system context without raw diagnostics", () => {
const analysis = analyzeBootstrapBudget({
files: buildBootstrapInjectionStats({
bootstrapFiles: [
@@ -28,15 +28,17 @@ describe("runEmbeddedAttempt bootstrap warning prompt assembly", () => {
analysis,
mode: "once",
});
const promptWithWarning = appendBootstrapPromptWarning("hello", warning.lines);
const notice = buildBootstrapPromptWarningNotice(warning.lines);
const systemPrompt = composeSystemPromptWithHookContext({
baseSystemPrompt: promptWithWarning,
baseSystemPrompt: "base system prompt",
prependSystemContext: "hook context",
appendSystemContext: notice,
});
expect(systemPrompt).toContain("hook context");
expect(systemPrompt).toContain("[Bootstrap truncation warning]");
expect(systemPrompt).toContain("- AGENTS.md: 200 raw -> 20 injected");
expect(systemPrompt).toContain("hello");
expect(systemPrompt).toContain("Treat Project Context as partial");
expect(systemPrompt).not.toContain("- AGENTS.md: 200 raw -> 20 injected");
expect(systemPrompt).toContain("base system prompt");
});
});

View File

@@ -207,6 +207,115 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
}
});
it("keeps bootstrap truncation warnings out of WebChat runtime context", async () => {
const seen: { prompt?: string; messages?: unknown[] } = {};
hoisted.resolveBootstrapContextForRunMock.mockResolvedValueOnce({
bootstrapFiles: [
{
name: "AGENTS.md",
path: "/tmp/openclaw-warning-workspace/AGENTS.md",
content: "A".repeat(200),
missing: false,
},
],
contextFiles: [
{ path: "/tmp/openclaw-warning-workspace/AGENTS.md", content: "A".repeat(20) },
],
});
await createContextEngineAttemptRunner({
contextEngine: createContextEngineBootstrapAndAssemble(),
sessionKey,
tempPaths,
attemptOverrides: {
config: {
agents: {
defaults: {
bootstrapMaxChars: 50,
bootstrapTotalMaxChars: 50,
},
},
} as OpenClawConfig,
prompt: "visible ask",
transcriptPrompt: "visible ask",
},
sessionPrompt: async (session, prompt) => {
seen.prompt = prompt;
seen.messages = [...session.messages];
session.messages = [
...session.messages,
{ role: "assistant", content: "done", timestamp: 2 },
];
},
});
expect(seen.prompt).toBe("visible ask");
expect(JSON.stringify(seen.messages)).not.toContain("[Bootstrap truncation warning]");
expect(JSON.stringify(seen.messages)).not.toContain("bootstrapMaxChars");
});
it("preserves bootstrap system context when system prompt override is configured", async () => {
const seen: { prompt?: string; messages?: unknown[] } = {};
hoisted.isWorkspaceBootstrapPendingMock.mockResolvedValueOnce(true);
hoisted.createOpenClawCodingToolsMock.mockImplementationOnce(() => [
{ name: "read", execute: async () => "" },
]);
hoisted.resolveBootstrapContextForRunMock.mockResolvedValueOnce({
bootstrapFiles: [
{
name: "BOOTSTRAP.md",
path: "/tmp/openclaw-override-workspace/BOOTSTRAP.md",
content: "Ask who I am.",
missing: false,
},
],
contextFiles: [
{
path: "/tmp/openclaw-override-workspace/BOOTSTRAP.md",
content: "Ask who I am.",
},
],
});
await createContextEngineAttemptRunner({
contextEngine: createContextEngineBootstrapAndAssemble(),
sessionKey,
tempPaths,
attemptOverrides: {
config: {
agents: {
defaults: {
systemPromptOverride: "Custom override prompt.",
},
},
} as OpenClawConfig,
prompt: "visible ask",
transcriptPrompt: "visible ask",
trigger: "user",
},
sessionPrompt: async (session, prompt) => {
seen.prompt = prompt;
seen.messages = [...session.messages];
session.messages = [
...session.messages,
{ role: "assistant", content: "done", timestamp: 2 },
];
},
});
expect(seen.prompt).toBe("visible ask");
expect(JSON.stringify(seen.messages)).not.toContain("Ask who I am.");
const systemPrompt =
hoisted.systemPromptOverrideTexts.find((text) => text.includes("Custom override prompt.")) ??
"";
expect(systemPrompt).toContain("Custom override prompt.");
expect(systemPrompt).toContain("## Bootstrap Pending");
expect(systemPrompt).toContain("BOOTSTRAP.md is included below in Project Context");
expect(systemPrompt).toContain("## /tmp/openclaw-override-workspace/BOOTSTRAP.md");
expect(systemPrompt).toContain("Ask who I am.");
});
it("adds explicit reply context to the current model input without exposing generic runtime context", async () => {
let seenPrompt: string | undefined;

View File

@@ -83,6 +83,7 @@ type AttemptSpawnWorkspaceHoisted = {
>;
limitHistoryTurnsMock: Mock<<T>(messages: T, limit: number | undefined) => T>;
preemptiveCompactionCalls: Parameters<ShouldPreemptivelyCompactBeforePromptFn>[0][];
systemPromptOverrideTexts: string[];
sessionManager: SessionManagerMocks;
};
@@ -162,6 +163,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
(messages) => messages,
);
const preemptiveCompactionCalls: Parameters<ShouldPreemptivelyCompactBeforePromptFn>[0][] = [];
const systemPromptOverrideTexts: string[] = [];
const sessionManager = {
getLeafEntry: vi.fn(() => null),
branch: vi.fn(),
@@ -198,6 +200,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
getHistoryLimitFromSessionKeyMock,
limitHistoryTurnsMock,
preemptiveCompactionCalls,
systemPromptOverrideTexts,
sessionManager,
};
});
@@ -255,7 +258,8 @@ vi.mock("../../../plugins/provider-runtime.js", () => ({
resolveProviderReasoningOutputModeWithPlugin: () => undefined,
resolveProviderSystemPromptContribution: () => undefined,
resolveProviderTextTransforms: () => undefined,
transformProviderSystemPrompt: ({ systemPrompt }: { systemPrompt: string }) => systemPrompt,
transformProviderSystemPrompt: ({ context }: { context: { systemPrompt?: string } }) =>
context.systemPrompt,
}));
vi.mock("../../../infra/machine-name.js", () => ({
@@ -411,11 +415,20 @@ vi.mock("../../system-prompt-report.js", () => ({
buildSystemPromptReport: () => undefined,
}));
vi.mock("../system-prompt.js", () => ({
applySystemPromptOverrideToSession: () => {},
buildEmbeddedSystemPrompt: () => "system prompt",
createSystemPromptOverride: (prompt: string) => () => prompt,
}));
vi.mock("../system-prompt.js", async () => {
const actual = await vi.importActual<typeof import("../system-prompt.js")>("../system-prompt.js");
return {
...actual,
applySystemPromptOverrideToSession: (session: MutableSession, systemPrompt: string) => {
session.agent.state.systemPrompt = systemPrompt;
},
buildEmbeddedSystemPrompt: () => "system prompt",
createSystemPromptOverride: (prompt: string) => {
hoisted.systemPromptOverrideTexts.push(prompt);
return () => prompt;
},
};
});
vi.mock("../extra-params.js", async () => {
const actual = await vi.importActual<typeof import("../extra-params.js")>("../extra-params.js");
@@ -817,6 +830,7 @@ export function resetEmbeddedAttemptHarness(
hoisted.getHistoryLimitFromSessionKeyMock.mockReset().mockReturnValue(undefined);
hoisted.limitHistoryTurnsMock.mockReset().mockImplementation((messages) => messages);
hoisted.preemptiveCompactionCalls.length = 0;
hoisted.systemPromptOverrideTexts.length = 0;
hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null);
hoisted.sessionManager.branch.mockReset();
hoisted.sessionManager.resetLeaf.mockReset();

View File

@@ -1,7 +1,6 @@
import { streamSimple } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import { appendBootstrapPromptWarning } from "../../bootstrap-budget.js";
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../system-prompt-cache-boundary.js";
import { buildAgentSystemPrompt } from "../../system-prompt.js";
import {
@@ -368,40 +367,23 @@ describe("composeSystemPromptWithHookContext", () => {
).toBe("append only");
});
it("keeps hook-composed system prompt stable when bootstrap warnings only change the user prompt", () => {
it("keeps bootstrap truncation notices in the system prompt instead of the user prompt", () => {
const baseSystemPrompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
contextFiles: [{ path: "AGENTS.md", content: "Follow AGENTS guidance." }],
toolNames: ["read"],
bootstrapTruncationNotice:
"[Bootstrap truncation warning]\nSome workspace bootstrap files were truncated before Project Context injection.\nTreat Project Context as partial and read the relevant files directly if details seem missing.",
});
const composedSystemPrompt = composeSystemPromptWithHookContext({
baseSystemPrompt,
appendSystemContext: "hook system context",
});
const turns = [
{
systemPrompt: composedSystemPrompt,
prompt: appendBootstrapPromptWarning("hello", ["AGENTS.md: 200 raw -> 0 injected"]),
},
{
systemPrompt: composedSystemPrompt,
prompt: appendBootstrapPromptWarning("hello again", []),
},
{
systemPrompt: composedSystemPrompt,
prompt: appendBootstrapPromptWarning("hello once more", [
"AGENTS.md: 200 raw -> 0 injected",
]),
},
];
expect(turns[0]?.systemPrompt).toBe(turns[1]?.systemPrompt);
expect(turns[1]?.systemPrompt).toBe(turns[2]?.systemPrompt);
expect(turns[0]?.prompt.startsWith("hello")).toBe(true);
expect(turns[1]?.prompt).toBe("hello again");
expect(turns[2]?.prompt.startsWith("hello once more")).toBe(true);
expect(turns[0]?.prompt).toContain("[Bootstrap truncation warning]");
expect(turns[2]?.prompt).toContain("[Bootstrap truncation warning]");
expect(composedSystemPrompt).toContain("[Bootstrap truncation warning]");
expect(composedSystemPrompt).toContain("Treat Project Context as partial");
expect(composedSystemPrompt).toContain("hook system context");
expect("hello").not.toContain("[Bootstrap truncation warning]");
});
});
@@ -423,8 +405,8 @@ describe("resolvePromptModeForSession", () => {
});
describe("shouldStripBootstrapFromEmbeddedContext", () => {
it("never injects raw BOOTSTRAP.md into embedded system context", () => {
expect(shouldStripBootstrapFromEmbeddedContext({ bootstrapMode: "full" })).toBe(true);
it("keeps BOOTSTRAP.md in system Project Context only for full bootstrap turns", () => {
expect(shouldStripBootstrapFromEmbeddedContext({ bootstrapMode: "full" })).toBe(false);
expect(shouldStripBootstrapFromEmbeddedContext({ bootstrapMode: "limited" })).toBe(true);
expect(shouldStripBootstrapFromEmbeddedContext({ bootstrapMode: "none" })).toBe(true);
});

View File

@@ -56,9 +56,9 @@ import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
import {
analyzeBootstrapBudget,
buildBootstrapPromptWarning,
buildBootstrapPromptWarningNotice,
buildBootstrapTruncationReportMeta,
buildBootstrapInjectionStats,
appendBootstrapPromptWarning,
} from "../../bootstrap-budget.js";
import {
FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
@@ -155,6 +155,7 @@ import {
import { resolveSystemPromptOverride } from "../../system-prompt-override.js";
import { buildSystemPromptParams } from "../../system-prompt-params.js";
import { buildSystemPromptReport } from "../../system-prompt-report.js";
import { appendAgentBootstrapSystemPromptSupplement } from "../../system-prompt.js";
import { resolveAgentTimeoutMs } from "../../timeout.js";
import {
buildEmptyExplicitToolAllowlistError,
@@ -239,7 +240,6 @@ import { abortable as abortableWithSignal } from "./abortable.js";
import { createEmbeddedAgentSessionWithResourceLoader } from "./attempt-session.js";
export { buildContextEnginePromptCacheInfo } from "./attempt.context-engine-helpers.js";
import {
appendBootstrapFileToUserPromptPrefix,
resolveAttemptWorkspaceBootstrapRouting,
shouldStripBootstrapFromEmbeddedContext,
} from "./attempt-bootstrap-routing.js";
@@ -1284,48 +1284,59 @@ export async function runEmbeddedAttempt(
context: promptContributionContext,
});
const builtAppendPrompt =
resolveSystemPromptOverride({
config: params.config,
agentId: sessionAgentId,
}) ??
buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel,
reasoningLevel: params.reasoningLevel ?? "off",
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
ownerDisplay: ownerDisplay.ownerDisplay,
ownerDisplaySecret: ownerDisplay.ownerDisplaySecret,
reasoningTagHint,
heartbeatPrompt,
skillsPrompt: effectiveSkillsPrompt,
docsPath: openClawReferences.docsPath ?? undefined,
sourcePath: openClawReferences.sourcePath ?? undefined,
ttsHint,
workspaceNotes: workspaceNotes?.length ? workspaceNotes : undefined,
reactionGuidance,
promptMode: effectivePromptMode,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
silentReplyPromptMode: params.silentReplyPromptMode,
acpEnabled: isAcpRuntimeSpawnAvailable({
config: params.config,
sandboxed: sandboxInfo?.enabled === true,
}),
nativeCommandGuidanceLines: listRegisteredPluginAgentPromptGuidance(),
runtimeInfo,
messageToolHints,
sandboxInfo,
tools: effectiveTools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
userTimeFormat,
contextFiles,
includeMemorySection: !activeContextEngine || activeContextEngine.info.id === "legacy",
memoryCitationsMode: params.config?.memory?.citations,
promptContribution,
});
const bootstrapTruncationNotice = buildBootstrapPromptWarningNotice(
bootstrapPromptWarning.lines,
);
const systemPromptOverrideText = resolveSystemPromptOverride({
config: params.config,
agentId: sessionAgentId,
});
const builtAppendPrompt = systemPromptOverrideText
? appendAgentBootstrapSystemPromptSupplement({
systemPrompt: systemPromptOverrideText,
bootstrapMode,
bootstrapTruncationNotice,
contextFiles,
})
: buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel,
reasoningLevel: params.reasoningLevel ?? "off",
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
ownerDisplay: ownerDisplay.ownerDisplay,
ownerDisplaySecret: ownerDisplay.ownerDisplaySecret,
reasoningTagHint,
heartbeatPrompt,
skillsPrompt: effectiveSkillsPrompt,
docsPath: openClawReferences.docsPath ?? undefined,
sourcePath: openClawReferences.sourcePath ?? undefined,
ttsHint,
workspaceNotes: workspaceNotes?.length ? workspaceNotes : undefined,
reactionGuidance,
promptMode: effectivePromptMode,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
silentReplyPromptMode: params.silentReplyPromptMode,
acpEnabled: isAcpRuntimeSpawnAvailable({
config: params.config,
sandboxed: sandboxInfo?.enabled === true,
}),
nativeCommandGuidanceLines: listRegisteredPluginAgentPromptGuidance(),
runtimeInfo,
messageToolHints,
sandboxInfo,
tools: effectiveTools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
userTimeFormat,
contextFiles,
bootstrapMode,
bootstrapTruncationNotice,
includeMemorySection: !activeContextEngine || activeContextEngine.info.id === "legacy",
memoryCitationsMode: params.config?.memory?.citations,
promptContribution,
});
const appendPrompt = isRawModelRun
? ""
: transformProviderSystemPrompt({
@@ -1375,11 +1386,6 @@ export async function runEmbeddedAttempt(
});
const systemPromptOverride = createSystemPromptOverride(appendPrompt);
let systemPromptText = systemPromptOverride();
const userPromptPrefixText = appendBootstrapFileToUserPromptPrefix({
prefixText: bootstrapRouting.userPromptPrefixText,
bootstrapMode,
contextFiles: remappedContextFiles,
});
prepStages.mark("system-prompt");
// Keep the session lock scoped to transcript/session mutations. Cold plugin
@@ -1831,7 +1837,6 @@ export async function runEmbeddedAttempt(
toolsAllow: params.toolsAllow,
skillsSnapshot: params.skillsSnapshot,
systemPromptReport,
userPromptPrefixText,
}),
);
@@ -2593,16 +2598,7 @@ 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 = appendBootstrapPromptWarning(
params.prompt,
bootstrapPromptWarning.lines,
{
preserveExactPrompt: heartbeatPrompt,
},
);
if (userPromptPrefixText) {
effectivePrompt = `${userPromptPrefixText}\n\n${effectivePrompt}`;
}
let effectivePrompt = params.prompt;
const hookCtx = {
runId: params.runId,
trace: freezeDiagnosticTraceContext(diagnosticTrace),

View File

@@ -2,6 +2,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js";
import type { MemoryCitationsMode } from "../../config/types.memory.js";
import type { BootstrapMode } from "../bootstrap-mode.js";
import type { ResolvedTimeFormat } from "../date-time.js";
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
import type { ProviderSystemPromptContribution } from "../system-prompt-contribution.js";
@@ -62,6 +63,8 @@ export function buildEmbeddedSystemPrompt(params: {
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
contextFiles?: EmbeddedContextFile[];
bootstrapMode?: BootstrapMode;
bootstrapTruncationNotice?: string;
includeMemorySection?: boolean;
memoryCitationsMode?: MemoryCitationsMode;
promptContribution?: ProviderSystemPromptContribution;
@@ -97,6 +100,8 @@ export function buildEmbeddedSystemPrompt(params: {
userTime: params.userTime,
userTimeFormat: params.userTimeFormat,
contextFiles: params.contextFiles,
bootstrapMode: params.bootstrapMode,
bootstrapTruncationNotice: params.bootstrapTruncationNotice,
includeMemorySection: params.includeMemorySection,
memoryCitationsMode: params.memoryCitationsMode,
promptContribution: params.promptContribution,

View File

@@ -4,8 +4,10 @@ import { typedCases } from "../test-utils/typed-cases.js";
import { buildSubagentSystemPrompt } from "./subagent-system-prompt.js";
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js";
import {
appendAgentBootstrapSystemPromptSupplement,
buildAgentBootstrapSystemContext,
buildAgentBootstrapSystemPromptSupplement,
buildAgentSystemPrompt,
buildAgentUserPromptPrefix,
buildRuntimeLine,
} from "./system-prompt.js";
@@ -502,29 +504,32 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("Reminder: commit your changes in this workspace after edits.");
});
it("keeps bootstrap instructions out of the privileged system prompt", () => {
it("includes bootstrap instructions in system prompt when bootstrap is pending", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
workspaceNotes: ["Reminder: commit your changes in this workspace after edits."],
bootstrapMode: "full",
contextFiles: [{ path: "/tmp/openclaw/BOOTSTRAP.md", content: "Ask who I am." }],
});
expect(prompt).not.toContain("## Bootstrap");
expect(prompt).not.toContain("Bootstrap is pending for this workspace.");
expect(prompt).not.toContain("BOOTSTRAP.md is present in Project Context");
expect(prompt).toContain("## Bootstrap Pending");
expect(prompt).toContain("BOOTSTRAP.md is included below in Project Context");
expect(prompt).toContain("must follow BOOTSTRAP.md, not a generic greeting");
expect(prompt).toContain("## /tmp/openclaw/BOOTSTRAP.md");
expect(prompt).toContain("Ask who I am.");
});
it("adds bootstrap-specific prelude text to the user prompt prefix when bootstrap is pending", () => {
const promptPrefix = buildAgentUserPromptPrefix({ bootstrapMode: "full" });
it("includes bootstrap truncation notice in system prompt without raw diagnostics", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
bootstrapTruncationNotice:
"[Bootstrap truncation warning]\nSome workspace bootstrap files were truncated before Project Context injection.\nTreat Project Context as partial and read the relevant files directly if details seem missing.",
});
expect(promptPrefix).toContain("[Bootstrap pending]");
expect(promptPrefix).toContain("Please read BOOTSTRAP.md from the workspace");
expect(promptPrefix).toContain("If this run can complete the BOOTSTRAP.md workflow, do so.");
expect(promptPrefix).toContain("explain the blocker briefly");
expect(promptPrefix).toContain("offer the simplest next step");
expect(promptPrefix).toContain("Do not use a generic first greeting or reply normally");
expect(promptPrefix).toContain(
"Your first user-visible reply for a bootstrap-pending workspace must follow BOOTSTRAP.md",
);
expect(prompt).toContain("## Bootstrap Context Notice");
expect(prompt).toContain("[Bootstrap truncation warning]");
expect(prompt).toContain("Treat Project Context as partial");
expect(prompt).not.toContain("raw ->");
expect(prompt).not.toContain("bootstrapMaxChars");
});
it("shows timezone section for 12h, 24h, and timezone-only modes", () => {
@@ -1073,12 +1078,15 @@ describe("buildAgentSystemPrompt", () => {
});
});
describe("buildAgentUserPromptPrefix", () => {
describe("buildAgentBootstrapSystemContext", () => {
it("uses friendly full bootstrap wording that is truthful about completion blockers", () => {
const prompt = buildAgentUserPromptPrefix({ bootstrapMode: "full" });
const prompt = buildAgentBootstrapSystemContext({
bootstrapMode: "full",
hasBootstrapFileInProjectContext: true,
}).join("\n");
expect(prompt).toContain("[Bootstrap pending]");
expect(prompt).toContain("Please read BOOTSTRAP.md");
expect(prompt).toContain("## Bootstrap Pending");
expect(prompt).toContain("BOOTSTRAP.md is included below in Project Context");
expect(prompt).toContain("If this run can complete the BOOTSTRAP.md workflow, do so.");
expect(prompt).toContain("explain the blocker briefly");
expect(prompt).toContain("offer the simplest next step");
@@ -1087,9 +1095,9 @@ describe("buildAgentUserPromptPrefix", () => {
});
it("uses limited bootstrap wording for constrained user-facing runs", () => {
const prompt = buildAgentUserPromptPrefix({ bootstrapMode: "limited" });
const prompt = buildAgentBootstrapSystemContext({ bootstrapMode: "limited" }).join("\n");
expect(prompt).toContain("[Bootstrap pending]");
expect(prompt).toContain("## Bootstrap Pending");
expect(prompt).toContain("cannot safely complete the full BOOTSTRAP.md workflow here");
expect(prompt).toContain("Do not claim bootstrap is complete");
expect(prompt).toContain("do not use a generic first greeting");
@@ -1097,8 +1105,38 @@ describe("buildAgentUserPromptPrefix", () => {
});
it("returns nothing when bootstrap is not pending", () => {
expect(buildAgentUserPromptPrefix({ bootstrapMode: "none" })).toBeUndefined();
expect(buildAgentUserPromptPrefix({})).toBeUndefined();
expect(buildAgentBootstrapSystemContext({ bootstrapMode: "none" })).toEqual([]);
expect(buildAgentBootstrapSystemContext({})).toEqual([]);
});
});
describe("buildAgentBootstrapSystemPromptSupplement", () => {
it("adds pending bootstrap guidance and BOOTSTRAP.md contents for override prompts", () => {
const supplement = buildAgentBootstrapSystemPromptSupplement({
bootstrapMode: "full",
contextFiles: [{ path: "/tmp/openclaw/BOOTSTRAP.md", content: "Ask who I am." }],
});
expect(supplement).toContain("## Bootstrap Pending");
expect(supplement).toContain("BOOTSTRAP.md is included below in Project Context");
expect(supplement).toContain("## /tmp/openclaw/BOOTSTRAP.md");
expect(supplement).toContain("Ask who I am.");
});
it("appends bootstrap supplement to configured system prompt overrides", () => {
const prompt = appendAgentBootstrapSystemPromptSupplement({
systemPrompt: "Custom override prompt.",
bootstrapMode: "full",
bootstrapTruncationNotice:
"[Bootstrap truncation warning]\nSome workspace bootstrap files were truncated before Project Context injection.\nTreat Project Context as partial and read the relevant files directly if details seem missing.",
contextFiles: [{ path: "/tmp/openclaw/BOOTSTRAP.md", content: "Ask who I am." }],
});
expect(prompt).toContain("Custom override prompt.");
expect(prompt).toContain("## Bootstrap Pending");
expect(prompt).toContain("Ask who I am.");
expect(prompt).toContain("## Bootstrap Context Notice");
expect(prompt).toContain("[Bootstrap truncation warning]");
});
});

View File

@@ -104,6 +104,10 @@ function isDynamicContextFile(pathValue: string): boolean {
return DYNAMIC_CONTEXT_FILE_BASENAMES.has(getContextFileBasename(pathValue));
}
function isBootstrapContextFile(pathValue: string): boolean {
return /(^|[\\/])BOOTSTRAP\.md$/iu.test(pathValue.trim());
}
function sanitizeContextFileContentForPrompt(content: string): string {
// Claude Code subscription mode rejects this exact prompt-policy quote when it
// appears in system context. The live heartbeat user turn still carries the
@@ -223,32 +227,83 @@ function buildMemorySection(params: {
});
}
export function buildAgentUserPromptPrefix(params: {
export function buildAgentBootstrapSystemContext(params: {
bootstrapMode?: BootstrapMode;
}): string | undefined {
hasBootstrapFileInProjectContext?: boolean;
}): string[] {
if (!params.bootstrapMode || params.bootstrapMode === "none") {
return undefined;
return [];
}
if (params.bootstrapMode === "limited") {
return [
"[Bootstrap pending]",
"## Bootstrap Pending",
...buildLimitedBootstrapPromptLines({
introLine:
"Bootstrap is still pending for this workspace, but this run cannot safely complete the full BOOTSTRAP.md workflow here.",
nextStepLine:
"Typical next steps include switching to a primary interactive run with normal workspace access or having the user complete the canonical BOOTSTRAP.md deletion afterward.",
}),
].join("\n");
"",
];
}
return [
"[Bootstrap pending]",
"## Bootstrap Pending",
...buildFullBootstrapPromptLines({
readLine:
"Please read BOOTSTRAP.md from the workspace and follow it before replying normally.",
readLine: params.hasBootstrapFileInProjectContext
? "BOOTSTRAP.md is included below in Project Context; follow it before replying normally."
: "Please read BOOTSTRAP.md from the workspace and follow it before replying normally.",
firstReplyLine:
"Your first user-visible reply for a bootstrap-pending workspace must follow BOOTSTRAP.md, not a generic greeting.",
}),
].join("\n");
"",
];
}
export function buildAgentBootstrapSystemPromptSupplement(params: {
bootstrapMode?: BootstrapMode;
bootstrapTruncationNotice?: string;
contextFiles?: EmbeddedContextFile[];
}): string | undefined {
const bootstrapFiles =
params.bootstrapMode === "full"
? sortContextFilesForPrompt(params.contextFiles ?? []).filter((file) =>
isBootstrapContextFile(file.path),
)
: [];
const lines = [
...buildAgentBootstrapSystemContext({
bootstrapMode: params.bootstrapMode,
hasBootstrapFileInProjectContext: bootstrapFiles.length > 0,
}),
];
const bootstrapTruncationNotice = params.bootstrapTruncationNotice?.trim();
if (bootstrapTruncationNotice) {
lines.push("## Bootstrap Context Notice", bootstrapTruncationNotice, "");
}
if (bootstrapFiles.length > 0) {
lines.push(
...buildProjectContextSection({
files: bootstrapFiles,
heading: "# Project Context",
dynamic: false,
}),
);
}
const supplement = lines.join("\n").trim();
return supplement.length > 0 ? supplement : undefined;
}
export function appendAgentBootstrapSystemPromptSupplement(params: {
systemPrompt: string;
bootstrapMode?: BootstrapMode;
bootstrapTruncationNotice?: string;
contextFiles?: EmbeddedContextFile[];
}): string {
const supplement = buildAgentBootstrapSystemPromptSupplement(params);
if (!supplement) {
return params.systemPrompt;
}
return `${params.systemPrompt.trimEnd()}\n\n${supplement}`;
}
function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: boolean) {
@@ -503,6 +558,8 @@ export function buildAgentSystemPrompt(params: {
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
contextFiles?: EmbeddedContextFile[];
bootstrapMode?: BootstrapMode;
bootstrapTruncationNotice?: string;
skillsPrompt?: string;
heartbeatPrompt?: string;
docsPath?: string;
@@ -762,6 +819,14 @@ export function buildAgentSystemPrompt(params: {
const orderedContextFiles = sortContextFilesForPrompt(validContextFiles);
const stableContextFiles = orderedContextFiles.filter((file) => !isDynamicContextFile(file.path));
const dynamicContextFiles = orderedContextFiles.filter((file) => isDynamicContextFile(file.path));
const hasBootstrapFileInProjectContext = orderedContextFiles.some((file) =>
isBootstrapContextFile(file.path),
);
const bootstrapSystemContext = buildAgentBootstrapSystemContext({
bootstrapMode: params.bootstrapMode,
hasBootstrapFileInProjectContext,
});
const bootstrapTruncationNotice = params.bootstrapTruncationNotice?.trim();
const stablePrefixCacheKey = hashStablePromptInput({
workspaceDir: params.workspaceDir,
promptMode,
@@ -787,6 +852,9 @@ export function buildAgentSystemPrompt(params: {
displayWorkspaceDir,
workspaceGuidance,
workspaceNotes,
bootstrapMode: params.bootstrapMode,
bootstrapSystemContext,
bootstrapTruncationNotice,
docsPath: params.docsPath,
sourcePath: params.sourcePath,
skillsPrompt,
@@ -995,6 +1063,10 @@ export function buildAgentSystemPrompt(params: {
...buildTimeSection({
userTimezone,
}),
...bootstrapSystemContext,
bootstrapTruncationNotice ? "## Bootstrap Context Notice" : "",
bootstrapTruncationNotice ?? "",
bootstrapTruncationNotice ? "" : "",
"## Workspace Files (injected)",
"These user-editable files are loaded by OpenClaw and included below in Project Context.",
"",