diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 1b092ec41..772aceb52 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -123,7 +123,10 @@ class ContextBuilder: if active_content: parts.append(f"# Active Skills\n\n{active_content}") - skills_summary = self.skills.build_skills_summary(exclude=set(active_skills)) + skills_summary = self.skills.build_skills_summary( + exclude=set(active_skills), + workspace=root, + ) if skills_summary: parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary)) diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index b7ec66afd..9b565285b 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -201,7 +201,12 @@ class SkillsLoader: ), ) - def build_skills_summary(self, exclude: set[str] | None = None) -> str: + def build_skills_summary( + self, + exclude: set[str] | None = None, + *, + workspace: Path | None = None, + ) -> str: """ Build a summary of all skills (name, description, path, availability). @@ -210,6 +215,7 @@ class SkillsLoader: Args: exclude: Set of skill names to omit from the summary. + workspace: Effective project workspace used to choose safe display paths. Returns: Markdown-formatted skills summary. @@ -218,6 +224,9 @@ class SkillsLoader: if not all_skills: return "" + agent_workspace = self.workspace.expanduser().resolve() + project_workspace = (workspace or self.workspace).expanduser().resolve() + use_relative_roots = project_workspace == agent_workspace sections: list[str] = [] groups = ( ("Workspace skills", "workspace", self.workspace_skills), @@ -233,7 +242,12 @@ class SkillsLoader: if not entries: continue - lines = [f"### {label} (`{root.expanduser().resolve()}`)"] + resolved_root = root.expanduser().resolve() + if use_relative_roots: + display_root = Path("plugins" if source == "plugin" else "skills") + else: + display_root = resolved_root + lines = [f"### {label} (`{display_root}`)"] for entry in entries: skill_name = entry["name"] meta = self._get_skill_meta(skill_name) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index e46146ea1..a3ea0a367 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -540,12 +540,17 @@ class SubagentManager: skills_summary = SkillsLoader( self.workspace, disabled_skills=self.disabled_skills, - ).build_skills_summary() + ).build_skills_summary(workspace=project_workspace) + history_log = ( + str(agent_workspace / "memory" / "history.jsonl") + if agent_workspace != project_workspace + else "memory/history.jsonl" + ) return render_template( "agent/subagent_system.md", workspace=str(project_workspace), agent_workspace=str(agent_workspace), - history_log=str(agent_workspace / "memory" / "history.jsonl"), + history_log=history_log, skills_summary=skills_summary or "", ) diff --git a/nanobot/skills/memory/SKILL.md b/nanobot/skills/memory/SKILL.md index e7a5f2c9c..cf40fdad9 100644 --- a/nanobot/skills/memory/SKILL.md +++ b/nanobot/skills/memory/SKILL.md @@ -15,8 +15,8 @@ description: Search conversation history and understand Dream-managed profile an ## Search Past Events -Use the absolute `History log` path shown in the system prompt. Always pass it to -`grep`; never substitute a project-relative `memory/history.jsonl`, which may belong +Use the `History log` path shown in the system prompt. Always pass it to `grep`; +never substitute a different project-relative `memory/history.jsonl`, which may belong to the selected project. Each JSONL line contains `cursor`, `timestamp`, and `content`. - For broad searches, start with `output_mode="count"` or the default @@ -25,7 +25,7 @@ to the selected project. Each JSONL line contains `cursor`, `timestamp`, and `co - Use `fixed_strings=true` for literal timestamps or JSON fragments - Use `head_limit` / `offset` to page through long histories -Examples (replace `` with the absolute path from the system prompt): +Examples (replace `` with the path from the system prompt): - `grep(pattern="keyword", path="", case_insensitive=true)` - `grep(pattern="2026-04-02 10:00", path="", fixed_strings=true)` - `grep(pattern="keyword", path="", output_mode="count", case_insensitive=true)` diff --git a/nanobot/templates/agent/identity.md b/nanobot/templates/agent/identity.md index dfcfb1b60..e24a4dabc 100644 --- a/nanobot/templates/agent/identity.md +++ b/nanobot/templates/agent/identity.md @@ -2,14 +2,18 @@ {{ runtime }} ## Workspace -Your current project workspace is at: {{ workspace_path }} {% if agent_workspace_path != workspace_path %} Nanobot's agent workspace is at: {{ agent_workspace_path }} -{% endif %} - Agent profile: {{ agent_workspace_path }}/SOUL.md and {{ agent_workspace_path }}/USER.md (automatically managed by Dream — do not edit directly) - Long-term memory: {{ agent_workspace_path }}/memory/MEMORY.md (automatically managed by Dream — do not edit directly) - History log: {{ agent_workspace_path }}/memory/history.jsonl (append-only JSONL; prefer built-in `grep` for search). - Custom skills: {{ agent_workspace_path }}/skills/{% raw %}{skill-name}{% endraw %}/SKILL.md +{% else %} +- Agent profile: SOUL.md and USER.md (automatically managed by Dream — do not edit directly) +- Long-term memory: memory/MEMORY.md (automatically managed by Dream — do not edit directly) +- History log: memory/history.jsonl (append-only JSONL; prefer built-in `grep` for search). +- Custom skills: skills/{% raw %}{skill-name}{% endraw %}/SKILL.md +{% endif %} {{ platform_policy }} {% if channel == 'telegram' or channel == 'qq' or channel == 'discord' %} diff --git a/nanobot/templates/agent/skills_section.md b/nanobot/templates/agent/skills_section.md index 023542869..d4d6441c6 100644 --- a/nanobot/templates/agent/skills_section.md +++ b/nanobot/templates/agent/skills_section.md @@ -1,5 +1,5 @@ # Skills -The following skills extend your capabilities. Each group lists one absolute root and relative SKILL.md paths; join them when using `read_file`. +The following skills extend your capabilities. Each group lists one root and relative SKILL.md paths; join them when using `read_file`. {{ skills_summary }} diff --git a/nanobot/templates/agent/subagent_system.md b/nanobot/templates/agent/subagent_system.md index b07b01cc5..38bcd4685 100644 --- a/nanobot/templates/agent/subagent_system.md +++ b/nanobot/templates/agent/subagent_system.md @@ -6,7 +6,6 @@ Stay focused on the assigned task. Your final response will be reported back to {% include 'agent/_snippets/untrusted_content.md' %} ## Workspace -Current project workspace: {{ workspace }} {% if agent_workspace != workspace %} Nanobot's agent workspace: {{ agent_workspace }} {% endif %} @@ -15,7 +14,7 @@ History log: {{ history_log }} ## Skills -Each group lists one absolute root and relative SKILL.md paths. Join them when using `read_file`. +Each group lists one root and relative SKILL.md paths. Join them when using `read_file`. {{ skills_summary }} {% endif %} diff --git a/tests/agent/test_agent_plugins.py b/tests/agent/test_agent_plugins.py index 8eb8a43ff..6e23206bb 100644 --- a/tests/agent/test_agent_plugins.py +++ b/tests/agent/test_agent_plugins.py @@ -84,7 +84,10 @@ def test_plugin_skill_lifecycle_and_precedence(tmp_path: Path) -> None: assert loader.get_explicitly_invoked_skills("Use $shared") == ["shared"] assert loader.get_always_skills() == ["shared"] assert "Plugin body" in (loader.load_skill("shared") or "") - assert "`demo/skills/shared/SKILL.md`" in loader.build_skills_summary() + summary = loader.build_skills_summary() + assert "### Agent Plugin skills (`plugins`)" in summary + assert "`demo/skills/shared/SKILL.md`" in summary + assert str(tmp_path.resolve()) not in summary set_agent_plugin_enabled(tmp_path, "demo", False) assert [entry["source"] for entry in loader.list_skills()] == ["builtin"] diff --git a/tests/agent/test_context_builder.py b/tests/agent/test_context_builder.py index ce5485ad4..12a2f4e78 100644 --- a/tests/agent/test_context_builder.py +++ b/tests/agent/test_context_builder.py @@ -309,6 +309,14 @@ class TestBuildSystemPrompt: result = builder.build_system_prompt() assert "workspace" in result.lower() or "python" in result.lower() + def test_default_identity_uses_relative_agent_paths(self, tmp_path): + result = ContextBuilder(tmp_path)._get_identity() + + assert str(tmp_path.resolve()) not in result + assert "Agent profile: SOUL.md and USER.md" in result + assert "History log: memory/history.jsonl" in result + assert "Custom skills: skills/{skill-name}/SKILL.md" in result + def test_selected_project_identity_keeps_agent_data_in_agent_workspace(self, tmp_path): agent_home = tmp_path / "agent-home" project = tmp_path / "project" @@ -317,7 +325,7 @@ class TestBuildSystemPrompt: result = ContextBuilder(agent_home)._get_identity(workspace=project) - assert f"current project workspace is at: {project.resolve()}" in result + assert str(project.resolve()) not in result assert f"agent workspace is at: {agent_home.resolve()}" in result assert f"{agent_home.resolve()}/SOUL.md" in result assert f"{project.resolve()}/SOUL.md" not in result diff --git a/tests/agent/test_skills_loader.py b/tests/agent/test_skills_loader.py index fd8d0b290..d5e2c98df 100644 --- a/tests/agent/test_skills_loader.py +++ b/tests/agent/test_skills_loader.py @@ -298,7 +298,7 @@ def test_disabled_skills_excluded_from_build_skills_summary(tmp_path: Path) -> N assert "beta" in summary -def test_build_skills_summary_groups_paths_by_root(tmp_path: Path) -> None: +def test_build_skills_summary_uses_relative_roots_in_agent_workspace(tmp_path: Path) -> None: workspace = tmp_path / "ws" workspace_skills = workspace / "skills" workspace_skills.mkdir(parents=True) @@ -308,14 +308,34 @@ def test_build_skills_summary_groups_paths_by_root(tmp_path: Path) -> None: summary = SkillsLoader(workspace, builtin_skills_dir=builtin).build_skills_summary() - assert summary.count(str(workspace_skills)) == 1 - assert summary.count(str(builtin)) == 1 + assert str(workspace_skills) not in summary + assert str(builtin) not in summary assert str(workspace_path) not in summary assert str(builtin_path) not in summary + assert summary.count("(`skills`)") == 2 assert "`alpha/SKILL.md`" in summary assert "`beta/SKILL.md`" in summary +def test_build_skills_summary_keeps_absolute_roots_for_selected_project(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + workspace_skills = workspace / "skills" + workspace_skills.mkdir(parents=True) + _write_skill(workspace_skills, "alpha", body="# Alpha") + builtin = tmp_path / "builtin" + _write_skill(builtin, "beta", body="# Beta") + project = tmp_path / "project" + project.mkdir() + + summary = SkillsLoader(workspace, builtin_skills_dir=builtin).build_skills_summary( + workspace=project, + ) + + assert summary.count(str(workspace_skills.resolve())) == 1 + assert summary.count(str(builtin.resolve())) == 1 + assert str(project.resolve()) not in summary + + def test_bundled_update_setup_description_is_valid_yaml(tmp_path: Path) -> None: metadata = SkillsLoader(tmp_path).get_skill_metadata("update-setup") diff --git a/tests/agent/test_subagent.py b/tests/agent/test_subagent.py index 551dd9860..724a31f78 100644 --- a/tests/agent/test_subagent.py +++ b/tests/agent/test_subagent.py @@ -83,7 +83,7 @@ def test_subagent_respects_file_tool_toggle(tmp_path): assert file_tools.isdisjoint(tools.tool_names) -def test_subagent_prompt_explains_grouped_skill_paths(tmp_path): +def test_subagent_prompt_keeps_agent_paths_for_selected_project(tmp_path): agent_workspace = tmp_path / "agent" project = tmp_path / "project" global_skill = agent_workspace / "skills" / "global-custom" / "SKILL.md" @@ -100,15 +100,32 @@ def test_subagent_prompt_explains_grouped_skill_paths(tmp_path): prompt = manager._build_subagent_prompt(workspace=project) - assert "one absolute root and relative SKILL.md paths" in prompt + assert "one root and relative SKILL.md paths" in prompt assert "Join them when using `read_file`" in prompt - assert f"Current project workspace: {project.resolve()}" in prompt + assert str(project.resolve()) not in prompt assert f"Nanobot's agent workspace: {agent_workspace.resolve()}" in prompt assert f"History log: {agent_workspace.resolve() / 'memory' / 'history.jsonl'}" in prompt assert "global-custom" in prompt assert "project-custom" not in prompt +def test_subagent_prompt_uses_relative_paths_in_agent_workspace(tmp_path): + skill = tmp_path / "skills" / "custom" / "SKILL.md" + skill.parent.mkdir(parents=True) + skill.write_text("---\ndescription: custom skill\n---\nCustom", encoding="utf-8") + manager = SubagentManager( + workspace=tmp_path, + bus=MessageBus(), + max_tool_result_chars=16_000, + ) + + prompt = manager._build_subagent_prompt() + + assert str(tmp_path.resolve()) not in prompt + assert "History log: memory/history.jsonl" in prompt + assert "### Workspace skills (`skills`)" in prompt + + @pytest.mark.asyncio async def test_subagent_keeps_project_runtime_scope_with_agent_owned_tools(tmp_path): agent_workspace = tmp_path / "agent"