diff --git a/letta/schemas/memory.py b/letta/schemas/memory.py index c82e793b..c1bd9f1a 100644 --- a/letta/schemas/memory.py +++ b/letta/schemas/memory.py @@ -289,15 +289,28 @@ class Memory(BaseModel, validate_assignment=True): s.write("\n\n\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 # leaf (LEAF_KEY present), show both / and .md. dirs = [] files = [] + skill_summary_blocks = {} for name, val in node.items(): if name == LEAF_KEY: continue if isinstance(val, dict): + # Special-case skills//SKILL.md so the skills section + # is concise in the system prompt: + # skills/ + # skills/ (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) if LEAF_KEY in val: files.append(name) @@ -314,8 +327,22 @@ class Memory(BaseModel, validate_assignment=True): if is_dir: s.write(f"{prefix}{connector}{name}/\n") 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: + # 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 desc_suffix = "" if not in_system: diff --git a/letta/server/rest_api/routers/v1/git_http.py b/letta/server/rest_api/routers/v1/git_http.py index f7ab7b47..6bb0b8bb 100644 --- a/letta/server/rest_api/routers/v1/git_http.py +++ b/letta/server/rest_api/routers/v1/git_http.py @@ -34,6 +34,23 @@ logger = get_logger(__name__) _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) # 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() 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]] logger.info( "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 for file_path, content in files.items(): - if not file_path.endswith(".md"): + if not _is_syncable_block_markdown_path(file_path): continue label = file_path[:-3] diff --git a/letta/services/memory_repo/memfs_client_base.py b/letta/services/memory_repo/memfs_client_base.py index c58d36f2..5cccb770 100644 --- a/letta/services/memory_repo/memfs_client_base.py +++ b/letta/services/memory_repo/memfs_client_base.py @@ -133,7 +133,8 @@ class MemfsClient: except FileNotFoundError: 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 = [] for file_path, content in files.items(): if file_path.endswith(".md"): diff --git a/tests/test_log_context_middleware.py b/tests/test_log_context_middleware.py index 9cf0e100..0d42c4e9 100644 --- a/tests/test_log_context_middleware.py +++ b/tests/test_log_context_middleware.py @@ -54,6 +54,8 @@ class TestLogContextMiddleware: return { "system/human.md": "---\ndescription: human\n---\nname: sarah", "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: @@ -95,6 +97,8 @@ class TestLogContextMiddleware: labels = {call["label"] for call in synced_calls} assert "system/human" 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): response = client.get("/v1/agents/agent-123e4567-e89b-42d3-8456-426614174000", headers={"user_id": "user-abc123"}) diff --git a/tests/test_memory.py b/tests/test_memory.py index 4595dad8..410fd4d3 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -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 # reference/api.md has a description 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