fix: protect bootstrap files during memory flush (#38574)

Merged via squash.

Prepared head SHA: a0b9a02e2e
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
This commit is contained in:
Frank Yang
2026-03-10 12:44:33 +08:00
committed by GitHub
parent 989ee21b24
commit 96e4975922
13 changed files with 468 additions and 15 deletions

View File

@@ -34,6 +34,7 @@ import {
import {
hasAlreadyFlushedForCurrentCompaction,
resolveMemoryFlushContextWindowTokens,
resolveMemoryFlushRelativePathForRun,
resolveMemoryFlushPromptForRun,
resolveMemoryFlushSettings,
shouldRunMemoryFlush,
@@ -465,6 +466,11 @@ export async function runMemoryFlushIfNeeded(params: {
});
}
let memoryCompactionCompleted = false;
const memoryFlushNowMs = Date.now();
const memoryFlushWritePath = resolveMemoryFlushRelativePathForRun({
cfg: params.cfg,
nowMs: memoryFlushNowMs,
});
const flushSystemPrompt = [
params.followupRun.run.extraSystemPrompt,
memoryFlushSettings.systemPrompt,
@@ -495,9 +501,11 @@ export async function runMemoryFlushIfNeeded(params: {
...senderContext,
...runBaseParams,
trigger: "memory",
memoryFlushWritePath,
prompt: resolveMemoryFlushPromptForRun({
prompt: memoryFlushSettings.prompt,
cfg: params.cfg,
nowMs: memoryFlushNowMs,
}),
extraSystemPrompt: flushSystemPrompt,
bootstrapPromptWarningSignaturesSeen,

View File

@@ -28,6 +28,7 @@ type AgentRunParams = {
type EmbeddedRunParams = {
prompt?: string;
extraSystemPrompt?: string;
memoryFlushWritePath?: string;
bootstrapPromptWarningSignaturesSeen?: string[];
bootstrapPromptWarningSignature?: string;
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
@@ -1611,9 +1612,14 @@ describe("runReplyAgent memory flush", () => {
const flushCall = calls[0];
expect(flushCall?.prompt).toContain("Write notes.");
expect(flushCall?.prompt).toContain("NO_REPLY");
expect(flushCall?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/);
expect(flushCall?.prompt).toContain("MEMORY.md");
expect(flushCall?.memoryFlushWritePath).toMatch(/^memory\/\d{4}-\d{2}-\d{2}\.md$/);
expect(flushCall?.extraSystemPrompt).toContain("extra system");
expect(flushCall?.extraSystemPrompt).toContain("Flush memory now.");
expect(flushCall?.extraSystemPrompt).toContain("NO_REPLY");
expect(flushCall?.extraSystemPrompt).toContain("memory/YYYY-MM-DD.md");
expect(flushCall?.extraSystemPrompt).toContain("MEMORY.md");
expect(calls[1]?.prompt).toBe("hello");
});
});
@@ -1701,9 +1707,17 @@ describe("runReplyAgent memory flush", () => {
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
const calls: Array<{ prompt?: string }> = [];
const calls: Array<{
prompt?: string;
extraSystemPrompt?: string;
memoryFlushWritePath?: string;
}> = [];
state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
calls.push({ prompt: params.prompt });
calls.push({
prompt: params.prompt,
extraSystemPrompt: params.extraSystemPrompt,
memoryFlushWritePath: params.memoryFlushWritePath,
});
if (params.prompt?.includes("Pre-compaction memory flush.")) {
return { payloads: [], meta: {} };
}
@@ -1730,6 +1744,10 @@ describe("runReplyAgent memory flush", () => {
expect(calls[0]?.prompt).toContain("Pre-compaction memory flush.");
expect(calls[0]?.prompt).toContain("Current time:");
expect(calls[0]?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/);
expect(calls[0]?.prompt).toContain("MEMORY.md");
expect(calls[0]?.memoryFlushWritePath).toMatch(/^memory\/\d{4}-\d{2}-\d{2}\.md$/);
expect(calls[0]?.extraSystemPrompt).toContain("memory/YYYY-MM-DD.md");
expect(calls[0]?.extraSystemPrompt).toContain("MEMORY.md");
expect(calls[1]?.prompt).toBe("hello");
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { DEFAULT_MEMORY_FLUSH_PROMPT, resolveMemoryFlushPromptForRun } from "./memory-flush.js";
import {
DEFAULT_MEMORY_FLUSH_PROMPT,
resolveMemoryFlushPromptForRun,
resolveMemoryFlushRelativePathForRun,
} from "./memory-flush.js";
describe("resolveMemoryFlushPromptForRun", () => {
const cfg = {
@@ -35,6 +39,15 @@ describe("resolveMemoryFlushPromptForRun", () => {
expect(prompt).toContain("Current time: already present");
expect((prompt.match(/Current time:/g) ?? []).length).toBe(1);
});
it("resolves the canonical relative memory path using user timezone", () => {
const relativePath = resolveMemoryFlushRelativePathForRun({
cfg,
nowMs: Date.UTC(2026, 1, 16, 15, 0, 0),
});
expect(relativePath).toBe("memory/2026-02-16.md");
});
});
describe("DEFAULT_MEMORY_FLUSH_PROMPT", () => {

View File

@@ -10,10 +10,23 @@ import { SILENT_REPLY_TOKEN } from "../tokens.js";
export const DEFAULT_MEMORY_FLUSH_SOFT_TOKENS = 4000;
export const DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
const MEMORY_FLUSH_TARGET_HINT =
"Store durable memories only in memory/YYYY-MM-DD.md (create memory/ if needed).";
const MEMORY_FLUSH_APPEND_ONLY_HINT =
"If memory/YYYY-MM-DD.md already exists, APPEND new content only and do not overwrite existing entries.";
const MEMORY_FLUSH_READ_ONLY_HINT =
"Treat workspace bootstrap/reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only during this flush; never overwrite, replace, or edit them.";
const MEMORY_FLUSH_REQUIRED_HINTS = [
MEMORY_FLUSH_TARGET_HINT,
MEMORY_FLUSH_APPEND_ONLY_HINT,
MEMORY_FLUSH_READ_ONLY_HINT,
];
export const DEFAULT_MEMORY_FLUSH_PROMPT = [
"Pre-compaction memory flush.",
"Store durable memories now (use memory/YYYY-MM-DD.md; create memory/ if needed).",
"IMPORTANT: If the file already exists, APPEND new content only — do not overwrite existing entries.",
MEMORY_FLUSH_TARGET_HINT,
MEMORY_FLUSH_READ_ONLY_HINT,
MEMORY_FLUSH_APPEND_ONLY_HINT,
"Do NOT create timestamped variant files (e.g., YYYY-MM-DD-HHMM.md); always use the canonical YYYY-MM-DD.md filename.",
`If nothing to store, reply with ${SILENT_REPLY_TOKEN}.`,
].join(" ");
@@ -21,6 +34,9 @@ export const DEFAULT_MEMORY_FLUSH_PROMPT = [
export const DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT = [
"Pre-compaction memory flush turn.",
"The session is near auto-compaction; capture durable memories to disk.",
MEMORY_FLUSH_TARGET_HINT,
MEMORY_FLUSH_READ_ONLY_HINT,
MEMORY_FLUSH_APPEND_ONLY_HINT,
`You may reply, but usually ${SILENT_REPLY_TOKEN} is correct.`,
].join(" ");
@@ -40,14 +56,29 @@ function formatDateStampInTimezone(nowMs: number, timezone: string): string {
return new Date(nowMs).toISOString().slice(0, 10);
}
export function resolveMemoryFlushRelativePathForRun(params: {
cfg?: OpenClawConfig;
nowMs?: number;
}): string {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const { userTimezone } = resolveCronStyleNow(params.cfg ?? {}, nowMs);
const dateStamp = formatDateStampInTimezone(nowMs, userTimezone);
return `memory/${dateStamp}.md`;
}
export function resolveMemoryFlushPromptForRun(params: {
prompt: string;
cfg?: OpenClawConfig;
nowMs?: number;
}): string {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const { userTimezone, timeLine } = resolveCronStyleNow(params.cfg ?? {}, nowMs);
const dateStamp = formatDateStampInTimezone(nowMs, userTimezone);
const { timeLine } = resolveCronStyleNow(params.cfg ?? {}, nowMs);
const dateStamp = resolveMemoryFlushRelativePathForRun({
cfg: params.cfg,
nowMs,
})
.replace(/^memory\//, "")
.replace(/\.md$/, "");
const withDate = params.prompt.replaceAll("YYYY-MM-DD", dateStamp).trimEnd();
if (!withDate) {
return timeLine;
@@ -90,8 +121,12 @@ export function resolveMemoryFlushSettings(cfg?: OpenClawConfig): MemoryFlushSet
const forceFlushTranscriptBytes =
parseNonNegativeByteSize(defaults?.forceFlushTranscriptBytes) ??
DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES;
const prompt = defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT;
const systemPrompt = defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT;
const prompt = ensureMemoryFlushSafetyHints(
defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT,
);
const systemPrompt = ensureMemoryFlushSafetyHints(
defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT,
);
const reserveTokensFloor =
normalizeNonNegativeInt(cfg?.agents?.defaults?.compaction?.reserveTokensFloor) ??
DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR;
@@ -113,6 +148,16 @@ function ensureNoReplyHint(text: string): string {
return `${text}\n\nIf no user-visible reply is needed, start with ${SILENT_REPLY_TOKEN}.`;
}
function ensureMemoryFlushSafetyHints(text: string): string {
let next = text.trim();
for (const hint of MEMORY_FLUSH_REQUIRED_HINTS) {
if (!next.includes(hint)) {
next = next ? `${next}\n\n${hint}` : hint;
}
}
return next;
}
export function resolveMemoryFlushContextWindowTokens(params: {
modelId?: string;
agentCfgContextTokens?: number;

View File

@@ -203,6 +203,10 @@ describe("memory flush settings", () => {
expect(settings?.forceFlushTranscriptBytes).toBe(DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES);
expect(settings?.prompt.length).toBeGreaterThan(0);
expect(settings?.systemPrompt.length).toBeGreaterThan(0);
expect(settings?.prompt).toContain("memory/YYYY-MM-DD.md");
expect(settings?.prompt).toContain("MEMORY.md");
expect(settings?.systemPrompt).toContain("memory/YYYY-MM-DD.md");
expect(settings?.systemPrompt).toContain("MEMORY.md");
});
it("respects disable flag", () => {
@@ -230,6 +234,10 @@ describe("memory flush settings", () => {
});
expect(settings?.prompt).toContain("NO_REPLY");
expect(settings?.systemPrompt).toContain("NO_REPLY");
expect(settings?.prompt).toContain("memory/YYYY-MM-DD.md");
expect(settings?.prompt).toContain("MEMORY.md");
expect(settings?.systemPrompt).toContain("memory/YYYY-MM-DD.md");
expect(settings?.systemPrompt).toContain("MEMORY.md");
});
it("falls back to defaults when numeric values are invalid", () => {