mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-24 07:31:44 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user