Skip to content

Commit a3eed2b

Browse files
Lanfeiobviyus
andauthored
fix(agents): avoid injecting memory file twice on case-insensitive mounts (openclaw#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 (openclaw#26054) (thanks @Lanfei) --------- Co-authored-by: Ayaan Zaidi <[email protected]>
1 parent 7638052 commit a3eed2b

File tree

4 files changed

+18
-31
lines changed

4 files changed

+18
-31
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
1515
- 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.
1616
- 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.
1717
- 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.
18+
- 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.
1819

1920
## 2026.3.12
2021

docs/concepts/system-prompt.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Bootstrap files are trimmed and appended under **Project Context** so the model
5959
- `USER.md`
6060
- `HEARTBEAT.md`
6161
- `BOOTSTRAP.md` (only on brand-new workspaces)
62-
- `MEMORY.md` and/or `memory.md` (when present in the workspace; either or both may be injected)
62+
- `MEMORY.md` when present, otherwise `memory.md` as a lowercase fallback
6363

6464
All of these files are **injected into the context window** on every turn, which
6565
means they consume tokens. Keep them concise — especially `MEMORY.md`, which can

docs/reference/token-use.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ OpenClaw assembles its own system prompt on every run. It includes:
1818
- Tool list + short descriptions
1919
- Skills list (only metadata; instructions are loaded on demand with `read`)
2020
- Self-update instructions
21-
- 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.
21+
- 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.
2222
- Time (UTC + user timezone)
2323
- Reply tags + heartbeat behavior
2424
- Runtime metadata (host/OS/model/thinking)

src/agents/workspace.ts

Lines changed: 15 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -458,41 +458,24 @@ export async function ensureAgentWorkspace(params?: {
458458
};
459459
}
460460

461-
async function resolveMemoryBootstrapEntries(
461+
async function resolveMemoryBootstrapEntry(
462462
resolvedDir: string,
463-
): Promise<Array<{ name: WorkspaceBootstrapFileName; filePath: string }>> {
464-
const candidates: WorkspaceBootstrapFileName[] = [
465-
DEFAULT_MEMORY_FILENAME,
466-
DEFAULT_MEMORY_ALT_FILENAME,
467-
];
468-
const entries: Array<{ name: WorkspaceBootstrapFileName; filePath: string }> = [];
469-
for (const name of candidates) {
463+
): Promise<{ name: WorkspaceBootstrapFileName; filePath: string } | null> {
464+
// Prefer MEMORY.md; fall back to memory.md only when absent.
465+
// Checking both and deduplicating via realpath is unreliable on case-insensitive
466+
// file systems mounted in Docker (e.g. macOS volumes), where both names pass
467+
// fs.access() but realpath does not normalise case through the mount layer,
468+
// causing the same content to be injected twice and wasting tokens.
469+
for (const name of [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const) {
470470
const filePath = path.join(resolvedDir, name);
471471
try {
472472
await fs.access(filePath);
473-
entries.push({ name, filePath });
473+
return { name, filePath };
474474
} catch {
475-
// optional
475+
// try next candidate
476476
}
477477
}
478-
if (entries.length <= 1) {
479-
return entries;
480-
}
481-
482-
const seen = new Set<string>();
483-
const deduped: Array<{ name: WorkspaceBootstrapFileName; filePath: string }> = [];
484-
for (const entry of entries) {
485-
let key = entry.filePath;
486-
try {
487-
key = await fs.realpath(entry.filePath);
488-
} catch {}
489-
if (seen.has(key)) {
490-
continue;
491-
}
492-
seen.add(key);
493-
deduped.push(entry);
494-
}
495-
return deduped;
478+
return null;
496479
}
497480

498481
export async function loadWorkspaceBootstrapFiles(dir: string): Promise<WorkspaceBootstrapFile[]> {
@@ -532,7 +515,10 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise<Workspac
532515
},
533516
];
534517

535-
entries.push(...(await resolveMemoryBootstrapEntries(resolvedDir)));
518+
const memoryEntry = await resolveMemoryBootstrapEntry(resolvedDir);
519+
if (memoryEntry) {
520+
entries.push(memoryEntry);
521+
}
536522

537523
const result: WorkspaceBootstrapFile[] = [];
538524
for (const entry of entries) {

0 commit comments

Comments
 (0)