feat(core): reserve skills in memfs sync and list top-level skill directory [LET-7710] (#9691)

This commit is contained in:
Sarah Wooders
2026-02-26 13:41:05 -08:00
committed by Caren Thomas
parent 750b83a2ea
commit 57e7e0e52b
5 changed files with 99 additions and 5 deletions

View File

@@ -289,15 +289,28 @@ class Memory(BaseModel, validate_assignment=True):
s.write("\n\n<memory_filesystem>\n") s.write("\n\n<memory_filesystem>\n")
def _render_tree(node: dict, prefix: str = "", in_system: bool = False): def _render_tree(node: dict, prefix: str = "", in_system: bool = False, path_parts: tuple[str, ...] = ()):
# Sort: directories first, then files. If a node is both a directory and a # Sort: directories first, then files. If a node is both a directory and a
# leaf (LEAF_KEY present), show both <name>/ and <name>.md. # leaf (LEAF_KEY present), show both <name>/ and <name>.md.
dirs = [] dirs = []
files = [] files = []
skill_summary_blocks = {}
for name, val in node.items(): for name, val in node.items():
if name == LEAF_KEY: if name == LEAF_KEY:
continue continue
if isinstance(val, dict): if isinstance(val, dict):
# Special-case skills/<skill_name>/SKILL.md so the skills section
# is concise in the system prompt:
# skills/
# skills/<skill_name> (description)
# instead of rendering nested SKILL.md + support docs/scripts.
if path_parts == ("skills",):
skill_block = val.get("SKILL")
if skill_block is not None and not isinstance(skill_block, dict):
files.append(name)
skill_summary_blocks[name] = skill_block
continue
dirs.append(name) dirs.append(name)
if LEAF_KEY in val: if LEAF_KEY in val:
files.append(name) files.append(name)
@@ -314,8 +327,22 @@ class Memory(BaseModel, validate_assignment=True):
if is_dir: if is_dir:
s.write(f"{prefix}{connector}{name}/\n") s.write(f"{prefix}{connector}{name}/\n")
extension = " " if is_last else "" extension = " " if is_last else ""
_render_tree(node[name], prefix + extension, in_system=in_system or name == "system") _render_tree(
node[name],
prefix + extension,
in_system=in_system or name == "system",
path_parts=(*path_parts, name),
)
else: else:
# Render condensed skills top-level summaries.
if path_parts == ("skills",) and name in skill_summary_blocks:
block = skill_summary_blocks[name]
desc = getattr(block, "description", None)
desc_line = (desc or "").strip().split("\n")[0].strip()
desc_suffix = f" ({desc_line})" if desc_line else ""
s.write(f"{prefix}{connector}{name}/{desc_suffix}\n")
continue
# For files outside system/, append the block description # For files outside system/, append the block description
desc_suffix = "" desc_suffix = ""
if not in_system: if not in_system:

View File

@@ -34,6 +34,23 @@ logger = get_logger(__name__)
_background_tasks: set[asyncio.Task] = set() _background_tasks: set[asyncio.Task] = set()
def _is_syncable_block_markdown_path(path: str) -> bool:
"""Return whether a markdown path should be mirrored into block cache.
For skills/, do not mirror any files into block cache.
Agent-scoped skills are stored in MemFS, but they should not be injected
into block-backed core memory/system prompt.
"""
if not path.endswith(".md"):
return False
if path.startswith("skills/"):
return False
return True
router = APIRouter(prefix="/git", tags=["git"], include_in_schema=False) router = APIRouter(prefix="/git", tags=["git"], include_in_schema=False)
# Global storage for the server instance (set during app startup) # Global storage for the server instance (set during app startup)
@@ -100,7 +117,7 @@ async def _sync_after_push(actor_id: str, agent_id: str) -> None:
expected_labels = set() expected_labels = set()
from letta.services.memory_repo.block_markdown import parse_block_markdown from letta.services.memory_repo.block_markdown import parse_block_markdown
md_file_paths = sorted([file_path for file_path in files if file_path.endswith(".md")]) md_file_paths = sorted([file_path for file_path in files if _is_syncable_block_markdown_path(file_path)])
nested_md_file_paths = [file_path for file_path in md_file_paths if "/" in file_path[:-3]] nested_md_file_paths = [file_path for file_path in md_file_paths if "/" in file_path[:-3]]
logger.info( logger.info(
"Post-push sync file scan: agent=%s total_files=%d md_files=%d nested_md_files=%d sample_md_paths=%s", "Post-push sync file scan: agent=%s total_files=%d md_files=%d nested_md_files=%d sample_md_paths=%s",
@@ -113,7 +130,7 @@ 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.endswith(".md"): if not _is_syncable_block_markdown_path(file_path):
continue continue
label = file_path[:-3] label = file_path[:-3]

View File

@@ -133,7 +133,8 @@ class MemfsClient:
except FileNotFoundError: except FileNotFoundError:
return [] return []
# Convert block files to PydanticBlock (metadata is in frontmatter) # Convert block files to PydanticBlock (metadata is in frontmatter).
# skills/ is intentionally excluded from block sync/render.
blocks = [] blocks = []
for file_path, content in files.items(): for file_path, content in files.items():
if file_path.endswith(".md"): if file_path.endswith(".md"):

View File

@@ -54,6 +54,8 @@ class TestLogContextMiddleware:
return { return {
"system/human.md": "---\ndescription: human\n---\nname: sarah", "system/human.md": "---\ndescription: human\n---\nname: sarah",
"system/persona.md": "---\ndescription: persona\n---\nbe helpful", "system/persona.md": "---\ndescription: persona\n---\nbe helpful",
"skills/research-helper/SKILL.md": "---\ndescription: helper\n---\n# Research Helper",
"skills/research-helper/references/details.md": "---\ndescription: nested\n---\nShould not be synced",
} }
class DummyMemoryRepoManager: class DummyMemoryRepoManager:
@@ -95,6 +97,8 @@ class TestLogContextMiddleware:
labels = {call["label"] for call in synced_calls} labels = {call["label"] for call in synced_calls}
assert "system/human" in labels assert "system/human" in labels
assert "system/persona" in labels assert "system/persona" in labels
assert "skills/research-helper/SKILL" not in labels
assert "skills/research-helper/references/details" not in labels
def test_extracts_actor_id_from_headers(self, client): def test_extracts_actor_id_from_headers(self, client):
response = client.get("/v1/agents/agent-123e4567-e89b-42d3-8456-426614174000", headers={"user_id": "user-abc123"}) response = client.get("/v1/agents/agent-123e4567-e89b-42d3-8456-426614174000", headers={"user_id": "user-abc123"})

View File

@@ -309,3 +309,48 @@ def test_compile_git_memory_filesystem_no_description_when_empty():
assert "notes.md\n" in out or "notes.md\n" in out assert "notes.md\n" in out or "notes.md\n" in out
# reference/api.md has a description # reference/api.md has a description
assert "api.md (API docs)" in out assert "api.md (API docs)" in out
def test_compile_git_memory_filesystem_condenses_skills_to_top_level_entries():
"""skills/ should render as top-level skill folders with description.
We intentionally avoid showing nested files under skills/ in the system prompt
tree to keep context concise.
"""
m = Memory(
agent_type=AgentType.letta_v1_agent,
git_enabled=True,
blocks=[
Block(label="system/human", value="human data", limit=100),
Block(
label="skills/searching-messages/SKILL",
value="# searching messages",
limit=100,
description="Search past messages to recall context.",
),
Block(
label="skills/creating-skills/SKILL",
value="# creating skills",
limit=100,
description="Guide for creating effective skills.",
),
Block(
label="skills/creating-skills/references/workflows",
value="nested docs",
limit=100,
description="Nested workflow docs (should not appear)",
),
],
)
out = m.compile()
# Condensed top-level skill entries with descriptions.
assert "searching-messages/ (Search past messages to recall context.)" in out
assert "creating-skills/ (Guide for creating effective skills.)" in out
# Do not show SKILL.md or nested skill docs in tree.
assert "skills/searching-messages/SKILL.md" not in out
assert "skills/creating-skills/SKILL.md" not in out
assert "references/workflows" not in out