fix(memfs): collapse large dirs in reflection snapshot tree (#1481)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Cameron
2026-03-20 21:52:26 -06:00
committed by GitHub
parent 5de95e92e3
commit 18176c5323
2 changed files with 132 additions and 12 deletions

View File

@@ -9,6 +9,7 @@ import {
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { MEMORY_SYSTEM_DIR } from "../../agent/memoryFilesystem";
import { getDirectoryLimits } from "../../utils/directoryLimits";
import { parseFrontmatter } from "../../utils/frontmatter";
import { type Line, linesToTranscript } from "./accumulator";
@@ -192,24 +193,96 @@ function buildParentMemoryTree(files: ParentMemoryFile[]): string {
},
);
const lines: string[] = ["/memory/"];
const limits = getDirectoryLimits();
const maxLines = Math.max(2, limits.memfsTreeMaxLines);
const maxChars = Math.max(128, limits.memfsTreeMaxChars);
const maxChildrenPerDir = Math.max(1, limits.memfsTreeMaxChildrenPerDir);
const render = (node: TreeNode, prefix: string) => {
const entries = sortedEntries(node);
for (const [index, [name, child]] of entries.entries()) {
const isLast = index === entries.length - 1;
const branch = isLast ? "└──" : "├──";
const suffix = child.isFile ? "" : "/";
const description = child.description ? ` (${child.description})` : "";
lines.push(`${prefix}${branch} ${name}${suffix}${description}`);
const rootLine = "/memory/";
const lines: string[] = [rootLine];
let totalChars = rootLine.length;
const countTreeEntries = (node: TreeNode): number => {
let total = 0;
for (const [, child] of node.children) {
total += 1;
if (child.children.size > 0) {
const nextPrefix = `${prefix}${isLast ? " " : "│ "}`;
render(child, nextPrefix);
total += countTreeEntries(child);
}
}
return total;
};
render(root, "");
const canAppendLine = (line: string): boolean => {
const nextLineCount = lines.length + 1;
const nextCharCount = totalChars + 1 + line.length;
return nextLineCount <= maxLines && nextCharCount <= maxChars;
};
const render = (node: TreeNode, prefix: string): boolean => {
const entries = sortedEntries(node);
const visibleEntries = entries.slice(0, maxChildrenPerDir);
const omittedEntries = Math.max(0, entries.length - visibleEntries.length);
const renderItems: Array<
| { kind: "entry"; name: string; child: TreeNode }
| { kind: "omitted"; omittedCount: number }
> = visibleEntries.map(([name, child]) => ({
kind: "entry",
name,
child,
}));
if (omittedEntries > 0) {
renderItems.push({ kind: "omitted", omittedCount: omittedEntries });
}
for (const [index, item] of renderItems.entries()) {
const isLast = index === renderItems.length - 1;
const branch = isLast ? "└──" : "├──";
const line =
item.kind === "entry"
? `${prefix}${branch} ${item.name}${item.child.isFile ? "" : "/"}${item.child.description ? ` (${item.child.description})` : ""}`
: `${prefix}${branch} … (${item.omittedCount.toLocaleString()} more entries)`;
if (!canAppendLine(line)) {
return false;
}
lines.push(line);
totalChars += 1 + line.length;
if (item.kind === "entry" && item.child.children.size > 0) {
const nextPrefix = `${prefix}${isLast ? " " : "│ "}`;
if (!render(item.child, nextPrefix)) {
return false;
}
}
}
return true;
};
const totalEntries = countTreeEntries(root);
const fullyRendered = render(root, "");
if (!fullyRendered) {
while (lines.length > 1) {
const shownEntries = Math.max(0, lines.length - 1);
const omittedEntries = Math.max(1, totalEntries - shownEntries);
const notice = `[Tree truncated: showing ${shownEntries.toLocaleString()} of ${totalEntries.toLocaleString()} entries. ${omittedEntries.toLocaleString()} omitted.]`;
if (canAppendLine(notice)) {
lines.push(notice);
break;
}
const removed = lines.pop();
if (removed) {
totalChars -= 1 + removed.length;
}
}
}
return lines.join("\n");
}

View File

@@ -11,6 +11,23 @@ import {
finalizeAutoReflectionPayload,
getReflectionTranscriptPaths,
} from "../../cli/helpers/reflectionTranscript";
import { DIRECTORY_LIMIT_ENV } from "../../utils/directoryLimits";
const DIRECTORY_LIMIT_ENV_KEYS = Object.values(DIRECTORY_LIMIT_ENV);
const ORIGINAL_DIRECTORY_ENV = Object.fromEntries(
DIRECTORY_LIMIT_ENV_KEYS.map((key) => [key, process.env[key]]),
) as Record<string, string | undefined>;
function restoreDirectoryLimitEnv(): void {
for (const key of DIRECTORY_LIMIT_ENV_KEYS) {
const original = ORIGINAL_DIRECTORY_ENV[key];
if (original === undefined) {
delete process.env[key];
} else {
process.env[key] = original;
}
}
}
describe("reflectionTranscript helper", () => {
const agentId = "agent-test";
@@ -23,6 +40,7 @@ describe("reflectionTranscript helper", () => {
});
afterEach(async () => {
restoreDirectoryLimitEnv();
delete process.env.LETTA_TRANSCRIPT_ROOT;
await rm(testRoot, { recursive: true, force: true });
});
@@ -182,6 +200,35 @@ describe("reflectionTranscript helper", () => {
expect(snapshot).toContain("</parent_memory>");
});
test("buildParentMemorySnapshot collapses large users directory with omission marker", async () => {
process.env[DIRECTORY_LIMIT_ENV.memfsTreeMaxChildrenPerDir] = "3";
const memoryDir = join(testRoot, "memory-large-users");
await mkdir(join(memoryDir, "system"), { recursive: true });
await mkdir(join(memoryDir, "users"), { recursive: true });
await writeFile(
join(memoryDir, "system", "human.md"),
"---\ndescription: User context\n---\nSystem content\n",
"utf-8",
);
for (let idx = 0; idx < 10; idx += 1) {
const suffix = String(idx).padStart(2, "0");
await writeFile(
join(memoryDir, "users", `user_${suffix}.md`),
`---\ndescription: User block ${suffix}\n---\ncontent ${suffix}\n`,
"utf-8",
);
}
const snapshot = await buildParentMemorySnapshot(memoryDir);
expect(snapshot).toContain("users/");
expect(snapshot).toContain("… (7 more entries)");
expect(snapshot).not.toContain("user_09.md");
});
test("buildReflectionSubagentPrompt uses expanded reflection instructions", () => {
const prompt = buildReflectionSubagentPrompt({
transcriptPath: "/tmp/transcript.txt",