refactor: drop memory/ prefix from git memory repo file paths and update core memory rendering [LET-7356] (#9395)

This commit is contained in:
Sarah Wooders
2026-02-09 20:49:41 -08:00
committed by Caren Thomas
parent 5fd5a6dd07
commit bbc648909b
4 changed files with 70 additions and 36 deletions

View File

@@ -190,11 +190,49 @@ class Memory(BaseModel, validate_assignment=True):
s.write("\n") s.write("\n")
s.write("\n</memory_blocks>") s.write("\n</memory_blocks>")
def _render_memory_blocks_git(self, s: StringIO):
"""Render memory blocks as individual file tags with YAML frontmatter.
Each block is rendered as <label.md>---frontmatter---value</label.md>,
matching the format stored in the git repo. Labels without a 'system/'
prefix get one added automatically.
"""
renderable = self._get_renderable_blocks()
if not renderable:
return
for idx, block in enumerate(renderable):
label = block.label or "block"
# Ensure system/ prefix
if not label.startswith("system/"):
label = f"system/{label}"
tag = f"{label}.md"
value = block.value or ""
s.write(f"\n\n<{tag}>\n")
# Build frontmatter (same fields as serialize_block)
front_lines = []
if block.description:
front_lines.append(f"description: {block.description}")
if block.limit is not None:
front_lines.append(f"limit: {block.limit}")
if getattr(block, "read_only", False):
front_lines.append("read_only: true")
if front_lines:
s.write("---\n")
s.write("\n".join(front_lines))
s.write("\n---\n")
s.write(f"{value}\n")
s.write(f"</{tag}>")
def _render_memory_filesystem(self, s: StringIO): def _render_memory_filesystem(self, s: StringIO):
"""Render a filesystem tree view of all memory blocks. """Render a filesystem tree view of all memory blocks.
Only rendered for git-memory-enabled agents. Shows all blocks Only rendered for git-memory-enabled agents. Uses box-drawing
(system and non-system) as a tree with char counts and descriptions. characters (├──, └──, │) like the Unix `tree` command.
""" """
if not self.blocks: if not self.blocks:
return return
@@ -211,26 +249,23 @@ class Memory(BaseModel, validate_assignment=True):
node = node.setdefault(part, {}) node = node.setdefault(part, {})
node[parts[-1]] = block node[parts[-1]] = block
s.write("\n\n<memory_filesystem>\nmemory/\n") s.write("\n\n<memory_filesystem>\n")
def _render_tree(node: dict, indent: int = 1): def _render_tree(node: dict, prefix: str = ""):
prefix = " " * indent
# Sort: directories first, then files # Sort: directories first, then files
dirs = sorted(k for k, v in node.items() if isinstance(v, dict)) dirs = sorted(k for k, v in node.items() if isinstance(v, dict))
files = sorted(k for k, v in node.items() if not isinstance(v, dict)) files = sorted(k for k, v in node.items() if not isinstance(v, dict))
entries = [(d, True) for d in dirs] + [(f, False) for f in files]
for d in dirs: for i, (name, is_dir) in enumerate(entries):
s.write(f"{prefix}{d}/\n") is_last = i == len(entries) - 1
_render_tree(node[d], indent + 1) connector = "└── " if is_last else "├── "
if is_dir:
for f in files: s.write(f"{prefix}{connector}{name}/\n")
block = node[f] extension = " " if is_last else ""
chars = len(block.value or "") _render_tree(node[name], prefix + extension)
desc = block.description or "" else:
line = f"{prefix}{f}.md ({chars} chars)" s.write(f"{prefix}{connector}{name}.md\n")
if desc:
line += f" - {desc}"
s.write(f"{line}\n")
_render_tree(tree) _render_tree(tree)
s.write("</memory_filesystem>") s.write("</memory_filesystem>")
@@ -353,15 +388,15 @@ class Memory(BaseModel, validate_assignment=True):
# Memory blocks (not for react/workflow). Always include wrapper for preview/tests. # Memory blocks (not for react/workflow). Always include wrapper for preview/tests.
if not is_react: if not is_react:
if is_line_numbered: if self.git_enabled:
# Git-enabled: filesystem tree + file-style block rendering
self._render_memory_filesystem(s)
self._render_memory_blocks_git(s)
elif is_line_numbered:
self._render_memory_blocks_line_numbered(s) self._render_memory_blocks_line_numbered(s)
else: else:
self._render_memory_blocks_standard(s) self._render_memory_blocks_standard(s)
# For git-memory-enabled agents, render a filesystem tree of all blocks
if self.git_enabled:
self._render_memory_filesystem(s)
if tool_usage_rules is not None: if tool_usage_rules is not None:
desc = getattr(tool_usage_rules, "description", None) or "" desc = getattr(tool_usage_rules, "description", None) or ""
val = getattr(tool_usage_rules, "value", None) or "" val = getattr(tool_usage_rules, "value", None) or ""

View File

@@ -498,10 +498,10 @@ async def _sync_after_push(actor_id: str, agent_id: str) -> None:
synced = 0 synced = 0
for file_path, content in files.items(): for file_path, content in files.items():
if not file_path.startswith("memory/") or not file_path.endswith(".md"): if not file_path.endswith(".md"):
continue continue
label = file_path[len("memory/") : -3] label = file_path[:-3]
expected_labels.add(label) expected_labels.add(label)
# Parse frontmatter to extract metadata alongside value # Parse frontmatter to extract metadata alongside value
@@ -524,12 +524,12 @@ async def _sync_after_push(actor_id: str, agent_id: str) -> None:
logger.exception("Failed to sync block %s to PostgreSQL (agent=%s)", label, agent_id) logger.exception("Failed to sync block %s to PostgreSQL (agent=%s)", label, agent_id)
if synced == 0: if synced == 0:
logger.warning("No memory/*.md files found in repo HEAD during post-push sync (agent=%s)", agent_id) logger.warning("No *.md files found in repo HEAD during post-push sync (agent=%s)", agent_id)
else: else:
# Detach blocks that were removed in git. # Detach blocks that were removed in git.
# #
# We treat git as the source of truth for which blocks are attached to # We treat git as the source of truth for which blocks are attached to
# this agent. If a memory/*.md file disappears from HEAD, detach the # this agent. If a *.md file disappears from HEAD, detach the
# corresponding block from the agent in Postgres. # corresponding block from the agent in Postgres.
try: try:
existing_blocks = await _server_instance.agent_manager.list_agent_blocks_async( existing_blocks = await _server_instance.agent_manager.list_agent_blocks_async(

View File

@@ -389,7 +389,7 @@ class GitEnabledBlockManager(BlockManager):
# Check which blocks are missing from repo # Check which blocks are missing from repo
missing_blocks = [] missing_blocks = []
for block in blocks: for block in blocks:
expected_path = f"memory/{block.label}.md" expected_path = f"{block.label}.md"
if expected_path not in repo_files: if expected_path not in repo_files:
missing_blocks.append(block) missing_blocks.append(block)
@@ -552,7 +552,7 @@ class GitEnabledBlockManager(BlockManager):
if self.memory_repo_manager is None: if self.memory_repo_manager is None:
raise ValueError("Memory repo manager not configured") raise ValueError("Memory repo manager not configured")
path = f"memory/{label}.md" if label else None path = f"{label}.md" if label else None
return await self.memory_repo_manager.get_history_async( return await self.memory_repo_manager.get_history_async(
agent_id=agent_id, agent_id=agent_id,
actor=actor, actor=actor,

View File

@@ -26,8 +26,7 @@ from letta.utils import enforce_types
logger = get_logger(__name__) logger = get_logger(__name__)
# File paths within the memory repository # File paths within the memory repository (blocks stored at repo root as {label}.md)
MEMORY_DIR = "memory"
# Default local storage path # Default local storage path
DEFAULT_LOCAL_PATH = os.path.expanduser("~/.letta/memfs") DEFAULT_LOCAL_PATH = os.path.expanduser("~/.letta/memfs")
@@ -88,7 +87,7 @@ class MemfsClient:
initial_files = {} initial_files = {}
for block in initial_blocks: for block in initial_blocks:
file_path = f"{MEMORY_DIR}/{block.label}.md" file_path = f"{block.label}.md"
initial_files[file_path] = serialize_block( initial_files[file_path] = serialize_block(
value=block.value or "", value=block.value or "",
description=block.description, description=block.description,
@@ -137,8 +136,8 @@ class MemfsClient:
# Convert block files to PydanticBlock (metadata is in frontmatter) # Convert block files to PydanticBlock (metadata is in frontmatter)
blocks = [] blocks = []
for file_path, content in files.items(): for file_path, content in files.items():
if file_path.startswith(f"{MEMORY_DIR}/") and file_path.endswith(".md"): if file_path.endswith(".md"):
label = file_path[len(f"{MEMORY_DIR}/") : -3] label = file_path[:-3]
parsed = parse_block_markdown(content) parsed = parse_block_markdown(content)
@@ -235,7 +234,7 @@ class MemfsClient:
await self._ensure_repo_exists(agent_id, actor) await self._ensure_repo_exists(agent_id, actor)
file_path = f"{MEMORY_DIR}/{label}.md" file_path = f"{label}.md"
file_content = serialize_block( file_content = serialize_block(
value=value, value=value,
description=description, description=description,
@@ -289,7 +288,7 @@ class MemfsClient:
changes = [ changes = [
FileChange( FileChange(
path=f"{MEMORY_DIR}/{block.label}.md", path=f"{block.label}.md",
content=file_content, content=file_content,
change_type="add", change_type="add",
), ),
@@ -333,7 +332,7 @@ class MemfsClient:
changes = [ changes = [
FileChange( FileChange(
path=f"{MEMORY_DIR}/{label}.md", path=f"{label}.md",
content=None, content=None,
change_type="delete", change_type="delete",
), ),