diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index cab7b0579..f58baf0a9 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -3,6 +3,7 @@ import base64 import mimetypes import platform +from importlib.resources import files as pkg_files from pathlib import Path from typing import Any @@ -39,7 +40,7 @@ class ContextBuilder: parts.append(bootstrap) memory = self.memory.get_memory_context() - if memory: + if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"): parts.append(f"# Memory\n\n{memory}") always_skills = self.skills.get_always_skills() @@ -48,7 +49,7 @@ class ContextBuilder: if always_content: parts.append(f"# Active Skills\n\n{always_content}") - skills_summary = self.skills.build_skills_summary() + skills_summary = self.skills.build_skills_summary(exclude=set(always_skills)) if skills_summary: parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary)) @@ -114,6 +115,17 @@ class ContextBuilder: return "\n\n".join(parts) if parts else "" + @staticmethod + def _is_template_content(content: str, template_path: str) -> bool: + """Check if *content* is identical to the bundled template (user hasn't customized it).""" + try: + tpl = pkg_files("nanobot") / "templates" / template_path + if tpl.is_file(): + return content.strip() == tpl.read_text(encoding="utf-8").strip() + except Exception: + pass + return False + def build_messages( self, history: list[dict[str, Any]], diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index e9ef1986f..5d18cce53 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -16,10 +16,6 @@ _STRIP_SKILL_FRONTMATTER = re.compile( ) -def _escape_xml(text: str) -> str: - return text.replace("&", "&").replace("<", "<").replace(">", ">") - - class SkillsLoader: """ Loader for agent skills. @@ -110,39 +106,37 @@ class SkillsLoader: ] return "\n\n---\n\n".join(parts) - def build_skills_summary(self) -> str: + def build_skills_summary(self, exclude: set[str] | None = None) -> str: """ Build a summary of all skills (name, description, path, availability). This is used for progressive loading - the agent can read the full skill content using read_file when needed. + Args: + exclude: Set of skill names to omit from the summary. + Returns: - XML-formatted skills summary. + Markdown-formatted skills summary. """ all_skills = self.list_skills(filter_unavailable=False) if not all_skills: return "" - lines: list[str] = [""] + lines: list[str] = [] for entry in all_skills: skill_name = entry["name"] + if exclude and skill_name in exclude: + continue meta = self._get_skill_meta(skill_name) available = self._check_requirements(meta) - lines.extend( - [ - f' ', - f" {_escape_xml(skill_name)}", - f" {_escape_xml(self._get_skill_description(skill_name))}", - f" {entry['path']}", - ] - ) - if not available: + desc = self._get_skill_description(skill_name) + if available: + lines.append(f"- **{skill_name}** — {desc} `{entry['path']}`") + else: missing = self._get_missing_requirements(meta) - if missing: - lines.append(f" {_escape_xml(missing)}") - lines.append(" ") - lines.append("") + suffix = f" (unavailable: {missing})" if missing else " (unavailable)" + lines.append(f"- **{skill_name}** — {desc}{suffix} `{entry['path']}`") return "\n".join(lines) def _get_missing_requirements(self, skill_meta: dict) -> str: diff --git a/nanobot/templates/agent/skills_section.md b/nanobot/templates/agent/skills_section.md index b495c9ef5..300c56790 100644 --- a/nanobot/templates/agent/skills_section.md +++ b/nanobot/templates/agent/skills_section.md @@ -1,6 +1,6 @@ # Skills The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. -Skills with available="false" need dependencies installed first - you can try installing them with apt/brew. +Unavailable skills need dependencies installed first — you can try installing them with apt/brew. {{ skills_summary }} diff --git a/tests/agent/test_context_prompt_cache.py b/tests/agent/test_context_prompt_cache.py index 26f73027e..b3e80b9ce 100644 --- a/tests/agent/test_context_prompt_cache.py +++ b/tests/agent/test_context_prompt_cache.py @@ -219,3 +219,55 @@ def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path for left, right in zip(messages, messages[1:]): assert not (left.get("role") == right.get("role") == "assistant") + + +def test_always_skills_excluded_from_skills_index(tmp_path) -> None: + """Always skills should appear in Active Skills but NOT in the skills index.""" + workspace = _make_workspace(tmp_path) + builder = ContextBuilder(workspace) + + prompt = builder.build_system_prompt() + + # memory skill should be in Active Skills section + assert "# Active Skills" in prompt + assert "### Skill: memory" in prompt + + # memory skill should NOT appear in the skills index + skills_section = prompt.split("# Skills\n", 1) + if len(skills_section) > 1: + index_text = skills_section[1].split("\n\n---")[0] + assert "**memory**" not in index_text + + +def test_template_memory_md_is_skipped(tmp_path) -> None: + """MEMORY.md matching the bundled template should not inject the Memory section.""" + workspace = _make_workspace(tmp_path) + from nanobot.utils.helpers import sync_workspace_templates + sync_workspace_templates(workspace, silent=True) + + builder = ContextBuilder(workspace) + prompt = builder.build_system_prompt() + + # The "# Memory\n\n## Long-term Memory" block is produced only by + # build_system_prompt() when MEMORY.md is injected. The memory skill + # also contains "# Memory" but is followed by "## Structure", not + # "## Long-term Memory". + assert "# Memory\n\n## Long-term Memory" not in prompt + assert "This file is automatically updated by nanobot" not in prompt + + +def test_customized_memory_md_is_injected(tmp_path) -> None: + """A Dream-populated MEMORY.md should be injected normally.""" + workspace = _make_workspace(tmp_path) + from nanobot.utils.helpers import sync_workspace_templates + sync_workspace_templates(workspace, silent=True) + + (workspace / "memory" / "MEMORY.md").write_text( + "# Long-term Memory\n\nUser prefers dark mode.\n", encoding="utf-8" + ) + + builder = ContextBuilder(workspace) + prompt = builder.build_system_prompt() + + assert "# Memory\n\n## Long-term Memory" in prompt + assert "User prefers dark mode" in prompt