mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-04 00:35:58 +00:00
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:
parent
5683c79a6e
commit
6fbada5363
@ -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]],
|
||||||
|
|||||||
@ -16,10 +16,6 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _escape_xml(text: str) -> str:
|
|
||||||
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
@ -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 }}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user