refactor(context): deduplicate system prompt — markdown skills index, skip template MEMORY.md

- Convert skills summary from verbose XML (4-5 lines/skill) to compact
  markdown list (1 line/skill) with inline path for read_file lookup
- Exclude always-loaded skills (e.g. memory) from the skills index to
  avoid duplicating content already in the Active Skills section
- Skip injecting the Memory section when MEMORY.md still matches the
  bundled template (i.e. Dream hasn't populated it yet)
This commit is contained in:
chengyongru 2026-04-15 15:44:27 +08:00 committed by Xubin Ren
parent 5683c79a6e
commit 6fbada5363
4 changed files with 81 additions and 23 deletions

View File

@ -3,6 +3,7 @@
import base64 import base64
import mimetypes import mimetypes
import platform import platform
from importlib.resources import files as pkg_files
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -39,7 +40,7 @@ class ContextBuilder:
parts.append(bootstrap) parts.append(bootstrap)
memory = self.memory.get_memory_context() 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}") parts.append(f"# Memory\n\n{memory}")
always_skills = self.skills.get_always_skills() always_skills = self.skills.get_always_skills()
@ -48,7 +49,7 @@ class ContextBuilder:
if always_content: if always_content:
parts.append(f"# Active Skills\n\n{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: if skills_summary:
parts.append(render_template("agent/skills_section.md", skills_summary=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 "" 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( def build_messages(
self, self,
history: list[dict[str, Any]], history: list[dict[str, Any]],

View File

@ -16,10 +16,6 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
) )
def _escape_xml(text: str) -> str:
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
class SkillsLoader: class SkillsLoader:
""" """
Loader for agent skills. Loader for agent skills.
@ -110,39 +106,37 @@ class SkillsLoader:
] ]
return "\n\n---\n\n".join(parts) 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). Build a summary of all skills (name, description, path, availability).
This is used for progressive loading - the agent can read the full This is used for progressive loading - the agent can read the full
skill content using read_file when needed. skill content using read_file when needed.
Args:
exclude: Set of skill names to omit from the summary.
Returns: Returns:
XML-formatted skills summary. Markdown-formatted skills summary.
""" """
all_skills = self.list_skills(filter_unavailable=False) all_skills = self.list_skills(filter_unavailable=False)
if not all_skills: if not all_skills:
return "" return ""
lines: list[str] = ["<skills>"] lines: list[str] = []
for entry in all_skills: for entry in all_skills:
skill_name = entry["name"] skill_name = entry["name"]
if exclude and skill_name in exclude:
continue
meta = self._get_skill_meta(skill_name) meta = self._get_skill_meta(skill_name)
available = self._check_requirements(meta) available = self._check_requirements(meta)
lines.extend( desc = self._get_skill_description(skill_name)
[ if available:
f' <skill available="{str(available).lower()}">', lines.append(f"- **{skill_name}** — {desc} `{entry['path']}`")
f" <name>{_escape_xml(skill_name)}</name>", else:
f" <description>{_escape_xml(self._get_skill_description(skill_name))}</description>",
f" <location>{entry['path']}</location>",
]
)
if not available:
missing = self._get_missing_requirements(meta) missing = self._get_missing_requirements(meta)
if missing: suffix = f" (unavailable: {missing})" if missing else " (unavailable)"
lines.append(f" <requires>{_escape_xml(missing)}</requires>") lines.append(f"- **{skill_name}** — {desc}{suffix} `{entry['path']}`")
lines.append(" </skill>")
lines.append("</skills>")
return "\n".join(lines) return "\n".join(lines)
def _get_missing_requirements(self, skill_meta: dict) -> str: def _get_missing_requirements(self, skill_meta: dict) -> str:

View File

@ -1,6 +1,6 @@
# Skills # Skills
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. 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 }} {{ skills_summary }}

View File

@ -219,3 +219,55 @@ def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path
for left, right in zip(messages, messages[1:]): for left, right in zip(messages, messages[1:]):
assert not (left.get("role") == right.get("role") == "assistant") 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