fix(agents): avoid injecting memory file twice on case-insensitive mounts (#26054)

* fix(agents): avoid injecting memory file twice on case-insensitive mounts

On case-insensitive file systems mounted into Docker from macOS, both
MEMORY.md and memory.md pass fs.access() even when they are the same
underlying file. The previous dedup via fs.realpath() failed in this
scenario because realpath does not normalise case through the Docker
mount layer, so both paths were treated as distinct entries and the
same content was injected into the bootstrap context twice, wasting
tokens.

Fix by replacing the collect-then-dedup approach with an early-exit:
try MEMORY.md first; fall back to memory.md only when MEMORY.md is
absent. This makes the function return at most one entry regardless
of filesystem case-sensitivity.

* docs: clarify singular memory bootstrap fallback

* fix: note memory bootstrap fallback docs and changelog (#26054) (thanks @Lanfei)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Jealous 2026-03-13 02:09:51 -07:00 committed by GitHub
parent 7638052178
commit a3eed2b70f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 18 additions and 31 deletions

View File

@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv.
- Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman.
- Gateway/session reset: preserve `lastAccountId` and `lastThreadId` across gateway session resets so replies keep routing back to the same account and thread after `/reset`. (#44773) Thanks @Lanfei.
- Agents/memory bootstrap: load only one root memory file, preferring `MEMORY.md` and using `memory.md` as a fallback, so case-insensitive Docker mounts no longer inject duplicate memory context. (#26054) Thanks @Lanfei.
## 2026.3.12

View File

@ -59,7 +59,7 @@ Bootstrap files are trimmed and appended under **Project Context** so the model
- `USER.md`
- `HEARTBEAT.md`
- `BOOTSTRAP.md` (only on brand-new workspaces)
- `MEMORY.md` and/or `memory.md` (when present in the workspace; either or both may be injected)
- `MEMORY.md` when present, otherwise `memory.md` as a lowercase fallback
All of these files are **injected into the context window** on every turn, which
means they consume tokens. Keep them concise — especially `MEMORY.md`, which can

View File

@ -18,7 +18,7 @@ OpenClaw assembles its own system prompt on every run. It includes:
- Tool list + short descriptions
- Skills list (only metadata; instructions are loaded on demand with `read`)
- Self-update instructions
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` and/or `memory.md` when present). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 150000). `memory/*.md` files are on-demand via memory tools and are not auto-injected.
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present or `memory.md` as a lowercase fallback). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 150000). `memory/*.md` files are on-demand via memory tools and are not auto-injected.
- Time (UTC + user timezone)
- Reply tags + heartbeat behavior
- Runtime metadata (host/OS/model/thinking)

View File

@ -458,41 +458,24 @@ export async function ensureAgentWorkspace(params?: {
};
}
async function resolveMemoryBootstrapEntries(
async function resolveMemoryBootstrapEntry(
resolvedDir: string,
): Promise<Array<{ name: WorkspaceBootstrapFileName; filePath: string }>> {
const candidates: WorkspaceBootstrapFileName[] = [
DEFAULT_MEMORY_FILENAME,
DEFAULT_MEMORY_ALT_FILENAME,
];
const entries: Array<{ name: WorkspaceBootstrapFileName; filePath: string }> = [];
for (const name of candidates) {
): Promise<{ name: WorkspaceBootstrapFileName; filePath: string } | null> {
// Prefer MEMORY.md; fall back to memory.md only when absent.
// Checking both and deduplicating via realpath is unreliable on case-insensitive
// file systems mounted in Docker (e.g. macOS volumes), where both names pass
// fs.access() but realpath does not normalise case through the mount layer,
// causing the same content to be injected twice and wasting tokens.
for (const name of [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const) {
const filePath = path.join(resolvedDir, name);
try {
await fs.access(filePath);
entries.push({ name, filePath });
return { name, filePath };
} catch {
// optional
// try next candidate
}
}
if (entries.length <= 1) {
return entries;
}
const seen = new Set<string>();
const deduped: Array<{ name: WorkspaceBootstrapFileName; filePath: string }> = [];
for (const entry of entries) {
let key = entry.filePath;
try {
key = await fs.realpath(entry.filePath);
} catch {}
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(entry);
}
return deduped;
return null;
}
export async function loadWorkspaceBootstrapFiles(dir: string): Promise<WorkspaceBootstrapFile[]> {
@ -532,7 +515,10 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise<Workspac
},
];
entries.push(...(await resolveMemoryBootstrapEntries(resolvedDir)));
const memoryEntry = await resolveMemoryBootstrapEntry(resolvedDir);
if (memoryEntry) {
entries.push(memoryEntry);
}
const result: WorkspaceBootstrapFile[] = [];
for (const entry of entries) {