fix(memfs): collapse large dirs in reflection snapshot tree (#1481)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user