From c60363a25d683d3650dea8807a2f39130bcda1ad Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Sun, 15 Mar 2026 11:12:38 -0700 Subject: [PATCH] fix(reflection): given reflection agents the parent memory (#1376) Co-authored-by: Letta Code --- src/cli/App.tsx | 5 + src/cli/helpers/reflectionTranscript.ts | 201 +++++++++++++++++++- src/tests/cli/reflection-transcript.test.ts | 74 ++++++- 3 files changed, 273 insertions(+), 7 deletions(-) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 21b7d34..3e2b594 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -260,6 +260,7 @@ import { resolveReasoningTabToggleCommand } from "./helpers/reasoningTabToggle"; import { appendTranscriptDeltaJsonl, buildAutoReflectionPayload, + buildParentMemorySnapshot, buildReflectionSubagentPrompt, finalizeAutoReflectionPayload, } from "./helpers/reflectionTranscript"; @@ -9435,10 +9436,12 @@ export default function App({ } const memoryDir = getMemoryFilesystemRoot(agentId); + const parentMemory = await buildParentMemorySnapshot(memoryDir); const reflectionPrompt = buildReflectionSubagentPrompt({ transcriptPath: autoPayload.payloadPath, memoryDir, cwd: process.cwd(), + parentMemory, }); const { spawnBackgroundSubagentTask } = await import( @@ -9799,10 +9802,12 @@ ${SYSTEM_REMINDER_CLOSE} } const memoryDir = getMemoryFilesystemRoot(agentId); + const parentMemory = await buildParentMemorySnapshot(memoryDir); const reflectionPrompt = buildReflectionSubagentPrompt({ transcriptPath: autoPayload.payloadPath, memoryDir, cwd: process.cwd(), + parentMemory, }); const { spawnBackgroundSubagentTask } = await import( diff --git a/src/cli/helpers/reflectionTranscript.ts b/src/cli/helpers/reflectionTranscript.ts index 9764340..1235947 100644 --- a/src/cli/helpers/reflectionTranscript.ts +++ b/src/cli/helpers/reflectionTranscript.ts @@ -1,6 +1,15 @@ -import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import type { Dirent } from "node:fs"; +import { + appendFile, + mkdir, + readdir, + readFile, + writeFile, +} from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; +import { MEMORY_SYSTEM_DIR } from "../../agent/memoryFilesystem"; +import { parseFrontmatter } from "../../utils/frontmatter"; import { type Line, linesToTranscript } from "./accumulator"; const TRANSCRIPT_ROOT_ENV = "LETTA_TRANSCRIPT_ROOT"; @@ -43,19 +52,198 @@ export interface ReflectionPromptInput { transcriptPath: string; memoryDir: string; cwd?: string; + parentMemory?: string; } export function buildReflectionSubagentPrompt( input: ReflectionPromptInput, ): string { - const lines = [ - "Review the conversation transcript and update memory files.", - `The current conversation transcript has been saved to: ${input.transcriptPath}`, - `The primary agent's memory filesystem is located at: ${input.memoryDir}`, - ]; + const lines: string[] = []; + if (input.cwd) { lines.push(`Your current working directory is: ${input.cwd}`); + lines.push(""); } + + lines.push( + `Review the conversation transcript and update memory files. The current conversation transcript has been saved to: ${input.transcriptPath}`, + "", + `The primary agent's memory filesystem is located at: ${input.memoryDir}`, + "In-context memory (in the parent agent's system prompt) is stored in the `system/` folder and are rendered in tags below. Modification to files in `system/` will edit the parent agent's system prompt.", + "Additional memory files (such as skills and external memory) may also be read and modified.", + "", + ); + + if (input.parentMemory) { + lines.push(input.parentMemory); + } + return lines.join("\n"); +} + +interface ParentMemoryFile { + relativePath: string; + content: string; + description?: string; +} + +function isSystemMemoryFile(relativePath: string): boolean { + return relativePath.startsWith(`${MEMORY_SYSTEM_DIR}/`); +} + +async function collectParentMemoryFiles( + memoryDir: string, +): Promise { + const files: ParentMemoryFile[] = []; + + const walk = async (currentDir: string, relativeDir: string) => { + let entries: Dirent[] = []; + try { + entries = await readdir(currentDir, { withFileTypes: true }); + } catch { + return; + } + + const sortedEntries = entries + .filter((entry) => !entry.name.startsWith(".")) + .sort((a, b) => { + if (a.isDirectory() !== b.isDirectory()) { + return a.isDirectory() ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + for (const entry of sortedEntries) { + const entryPath = join(currentDir, entry.name); + const relativePath = relativeDir + ? `${relativeDir}/${entry.name}` + : entry.name; + + if (entry.isDirectory()) { + await walk(entryPath, relativePath); + continue; + } + + if (!entry.isFile() || !entry.name.endsWith(".md")) { + continue; + } + + try { + const content = await readFile(entryPath, "utf-8"); + const { frontmatter } = parseFrontmatter(content); + const description = + typeof frontmatter.description === "string" + ? frontmatter.description + : undefined; + files.push({ + relativePath: relativePath.replace(/\\/g, "/"), + content, + description, + }); + } catch { + // Skip unreadable files. + } + } + }; + + await walk(memoryDir, ""); + return files; +} + +function buildParentMemoryTree(files: ParentMemoryFile[]): string { + type TreeNode = { + children: Map; + isFile: boolean; + description?: string; + }; + + const makeNode = (): TreeNode => ({ children: new Map(), isFile: false }); + const root = makeNode(); + + for (const file of files) { + const normalizedPath = file.relativePath.replace(/\\/g, "/"); + const parts = normalizedPath.split("/"); + let current = root; + + for (const [index, part] of parts.entries()) { + if (!current.children.has(part)) { + current.children.set(part, makeNode()); + } + current = current.children.get(part) as TreeNode; + if (index === parts.length - 1) { + current.isFile = true; + if (file.description && !isSystemMemoryFile(normalizedPath)) { + current.description = file.description; + } + } + } + } + + if (!root.children.has(MEMORY_SYSTEM_DIR)) { + root.children.set(MEMORY_SYSTEM_DIR, makeNode()); + } + + const sortedEntries = (node: TreeNode) => + Array.from(node.children.entries()).sort( + ([nameA, nodeA], [nameB, nodeB]) => { + if (nodeA.isFile !== nodeB.isFile) { + return nodeA.isFile ? 1 : -1; + } + return nameA.localeCompare(nameB); + }, + ); + + const lines: string[] = ["/memory/"]; + + 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}`); + if (child.children.size > 0) { + const nextPrefix = `${prefix}${isLast ? " " : "│ "}`; + render(child, nextPrefix); + } + } + }; + + render(root, ""); + + return lines.join("\n"); +} + +export async function buildParentMemorySnapshot( + memoryDir: string, +): Promise { + const files = await collectParentMemoryFiles(memoryDir); + const tree = buildParentMemoryTree(files); + const systemFiles = files.filter((file) => + isSystemMemoryFile(file.relativePath), + ); + + const lines = [ + "", + "", + tree, + "", + ]; + + if (files.length === 0) { + lines.push("(no memory markdown files found)"); + } else { + for (const file of systemFiles) { + const normalizedPath = file.relativePath.replace(/\\/g, "/"); + const absolutePath = `${memoryDir.replace(/\\/g, "/")}/${normalizedPath}`; + lines.push(""); + lines.push(`${absolutePath}`); + lines.push(file.content); + lines.push(""); + } + } + + lines.push(""); return lines.join("\n"); } @@ -272,6 +460,7 @@ export async function buildAutoReflectionPayload( } const snapshotLines = lines.slice(cursorLine); + const entries = snapshotLines .map((line) => parseJsonLine(line)) .filter((entry): entry is TranscriptEntry => entry !== null); diff --git a/src/tests/cli/reflection-transcript.test.ts b/src/tests/cli/reflection-transcript.test.ts index a81ffa0..db2f084 100644 --- a/src/tests/cli/reflection-transcript.test.ts +++ b/src/tests/cli/reflection-transcript.test.ts @@ -1,11 +1,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { appendTranscriptDeltaJsonl, buildAutoReflectionPayload, + buildParentMemorySnapshot, + buildReflectionSubagentPrompt, finalizeAutoReflectionPayload, getReflectionTranscriptPaths, } from "../../cli/helpers/reflectionTranscript"; @@ -128,4 +130,74 @@ describe("reflectionTranscript helper", () => { const payloadText = await readFile(secondAttempt.payloadPath, "utf-8"); expect(payloadText).toContain("second"); }); + + test("buildParentMemorySnapshot renders tree descriptions and system blocks", async () => { + const memoryDir = join(testRoot, "memory"); + await mkdir(join(memoryDir, "system"), { recursive: true }); + await mkdir(join(memoryDir, "reference"), { recursive: true }); + await mkdir(join(memoryDir, "skills", "bird"), { recursive: true }); + + await writeFile( + join(memoryDir, "system", "human.md"), + "---\ndescription: User context\n---\nDr. Wooders prefers direct answers.\n", + "utf-8", + ); + await writeFile( + join(memoryDir, "reference", "project.md"), + "---\ndescription: Project notes\n---\nletta-code CLI details\n", + "utf-8", + ); + await writeFile( + join(memoryDir, "skills", "bird", "SKILL.md"), + "---\nname: bird\ndescription: X/Twitter CLI for posting\n---\nThis body should not be inlined into parent memory.\n", + "utf-8", + ); + + const snapshot = await buildParentMemorySnapshot(memoryDir); + + expect(snapshot).toContain(""); + expect(snapshot).toContain(""); + expect(snapshot).toContain("/memory/"); + expect(snapshot).toContain("system/"); + expect(snapshot).toContain("reference/"); + expect(snapshot).toContain("skills/"); + expect(snapshot).toContain("project.md (Project notes)"); + expect(snapshot).toContain("SKILL.md (X/Twitter CLI for posting)"); + + expect(snapshot).toContain(""); + expect(snapshot).toContain(`${memoryDir}/system/human.md`); + expect(snapshot).toContain("Dr. Wooders prefers direct answers."); + expect(snapshot).toContain(""); + + expect(snapshot).not.toContain( + `${memoryDir}/reference/project.md`, + ); + expect(snapshot).not.toContain("letta-code CLI details"); + expect(snapshot).not.toContain( + "This body should not be inlined into parent memory.", + ); + expect(snapshot).toContain(""); + }); + + test("buildReflectionSubagentPrompt uses expanded reflection instructions", () => { + const prompt = buildReflectionSubagentPrompt({ + transcriptPath: "/tmp/transcript.txt", + memoryDir: "/tmp/memory", + cwd: "/tmp/work", + parentMemory: "snapshot", + }); + + expect(prompt).toContain("Review the conversation transcript"); + expect(prompt).toContain("Your current working directory is: /tmp/work"); + expect(prompt).toContain( + "The current conversation transcript has been saved", + ); + expect(prompt).toContain( + "In-context memory (in the parent agent's system prompt) is stored in the `system/` folder and are rendered in tags below.", + ); + expect(prompt).toContain( + "Additional memory files (such as skills and external memory) may also be read and modified.", + ); + expect(prompt).toContain("snapshot"); + }); });