feat(core): add stable resource path aliases

This commit is contained in:
chengyongru
2026-07-28 11:52:25 +08:00
parent fa5d27696a
commit 1f51c12343
22 changed files with 1662 additions and 29 deletions
+79
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import pytest
from nanobot.agent.context import ContextBuilder
from nanobot.resource_links import ResourceView
from nanobot.runtime_context import RuntimeContextBlock
# ---------------------------------------------------------------------------
@@ -346,6 +347,65 @@ class TestBuildSystemPrompt:
assert "## AGENTS.md" not in result
assert "[Archived Context Summary]" not in result
def test_resource_aliases_are_absent_without_explicit_mode(self, tmp_path):
aliases = tmp_path / "resources" / "view"
resource_view = ResourceView(
root=aliases,
agent=aliases / "agent",
media=aliases / "media",
package=aliases / "package",
)
result = _builder(tmp_path, resource_view=resource_view).build_system_prompt()
assert "## Resource Aliases" not in result
def test_full_resource_aliases_show_roots_and_policy(self, tmp_path):
aliases = tmp_path / "resources" / "view"
resource_view = ResourceView(
root=aliases,
agent=aliases / "agent",
media=aliases / "media",
package=aliases / "package",
)
result = _builder(tmp_path, resource_view=resource_view).build_system_prompt(
resource_view_mode="full",
)
assert "## Resource Aliases" in result
assert f"Agent workspace: `{resource_view.agent}`" in result
assert f"Media: `{resource_view.media}`" in result
assert f"Nanobot package: `{resource_view.package}`" in result
assert f"Long-term memory: {resource_view.agent}/memory/MEMORY.md" in result
assert f"History log: {resource_view.agent}/memory/history.jsonl" in result
assert f"Custom skills: {resource_view.agent}/skills/" in result
assert "do not grant additional file or shell permissions" in result
assert "sandboxed shell may not expose an alias" in result
assert "paths relative to the current project workspace" in result
def test_restricted_resource_aliases_only_show_allowed_subtrees(self, tmp_path):
aliases = tmp_path / "resources" / "view"
resource_view = ResourceView(
root=aliases,
agent=aliases / "agent",
media=aliases / "media",
package=aliases / "package",
)
result = _builder(tmp_path, resource_view=resource_view).build_system_prompt(
resource_view_mode="restricted",
)
assert f"Custom skills: `{resource_view.agent / 'skills'}`" in result
assert f"Media: `{resource_view.media}`" in result
assert f"Built-in skills: `{resource_view.package / 'skills'}`" in result
assert f"Agent workspace: `{resource_view.agent}`" not in result
assert f"Nanobot package: `{resource_view.package}`" not in result
canonical_workspace = tmp_path.resolve()
assert f"History log: {canonical_workspace}/memory/history.jsonl" in result
assert f"History log: {resource_view.agent}/memory/history.jsonl" not in result
# ---------------------------------------------------------------------------
# build_messages
@@ -369,6 +429,25 @@ class TestBuildMessages:
assert messages[1]["role"] == "user"
assert "hello" in str(messages[1]["content"])
def test_resource_view_mode_is_forwarded_to_system_prompt(self, tmp_path):
aliases = tmp_path / "resources" / "view"
resource_view = ResourceView(
root=aliases,
agent=aliases / "agent",
media=aliases / "media",
package=aliases / "package",
)
builder = _builder(tmp_path, resource_view=resource_view)
messages = builder.build_messages(
[],
"hello",
resource_view_mode="restricted",
)
assert "## Resource Aliases" in messages[0]["content"]
assert f"Custom skills: `{resource_view.agent / 'skills'}`" in messages[0]["content"]
def test_public_builder_preserves_assistant_role_compatibility(self, tmp_path):
from nanobot.agent import ContextBuilder as PublicContextBuilder
+22
View File
@@ -5,6 +5,7 @@ import pytest
from nanobot.agent.memory import MemoryStore
from nanobot.config.schema import ModelPresetConfig
from nanobot.providers.base import LLMResponse
from nanobot.resource_links import ResourceView
from nanobot.security.workspace_access import (
bind_workspace_scope,
default_workspace_scope,
@@ -62,6 +63,27 @@ class TestBuildDreamPrompt:
prompt, _ = result
assert "skill-creator" in prompt
def test_prompt_uses_package_alias_for_skill_creator(self, tmp_path):
aliases = tmp_path / "resources" / "view"
resource_view = ResourceView(
root=aliases,
agent=aliases / "agent",
media=aliases / "media",
package=aliases / "package",
)
store = MemoryStore(tmp_path / "workspace", resource_view=resource_view)
store.append_history("test")
result = store.build_dream_prompt()
assert result is not None
prompt, _ = result
expected = resource_view.package / "skills" / "skill-creator" / "SKILL.md"
assert str(expected) in prompt
def test_default_dream_prompt_class_call_remains_compatible(self):
assert "skill-creator" in MemoryStore.default_dream_prompt()
def test_prompt_embeds_current_memory_file_contents(self, store):
"""Dream must see the real current file contents (Tier 4) so it edits the
files, not a stale mental model."""
+107
View File
@@ -0,0 +1,107 @@
"""AgentLoop integration tests for the runtime resource view."""
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from nanobot.agent.loop import AgentLoop, TurnKind
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ToolsConfig
from nanobot.resource_links import ResourceView
from nanobot.security.workspace_access import build_workspace_scope
def _provider() -> MagicMock:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation = SimpleNamespace(
max_tokens=4096,
temperature=0.1,
reasoning_effort=None,
)
return provider
def _loop(
tmp_path: Path,
*,
resource_view: ResourceView | None,
tools_config: ToolsConfig | None = None,
) -> tuple[AgentLoop, MagicMock, MagicMock]:
with (
patch("nanobot.agent.loop.ContextBuilder") as context_builder,
patch("nanobot.agent.loop.SessionManager"),
patch("nanobot.agent.loop.SubagentManager") as subagent_manager,
patch.object(AgentLoop, "_register_default_tools"),
):
loop = AgentLoop(
bus=MessageBus(),
provider=_provider(),
workspace=tmp_path,
tools_config=tools_config,
resource_view=resource_view,
)
return loop, context_builder, subagent_manager
def test_loop_injects_resource_view_without_creating_one(tmp_path: Path) -> None:
view = ResourceView(root=tmp_path / "resources" / "view")
loop, context_builder, subagent_manager = _loop(
tmp_path,
resource_view=view,
)
assert loop.resource_view is view
assert context_builder.call_args.kwargs["resource_view"] is view
assert subagent_manager.call_args.kwargs["resource_view"] is view
@pytest.mark.parametrize(
("access_mode", "sandbox", "expected"),
[
("full", "", "full"),
("restricted", "", "restricted"),
("full", "bwrap", "restricted"),
],
)
def test_initial_prompt_uses_effective_resource_view_mode(
tmp_path: Path,
access_mode: str,
sandbox: str,
expected: str,
) -> None:
tools_config = ToolsConfig()
tools_config.exec.sandbox = sandbox
view = ResourceView(root=tmp_path / "resources" / "view")
loop, _, _ = _loop(
tmp_path,
resource_view=view,
tools_config=tools_config,
)
scope = build_workspace_scope(tmp_path, access_mode)
loop.workspace_scopes = SimpleNamespace(for_message=MagicMock(return_value=scope))
loop.context.build_messages.return_value = []
turn = SimpleNamespace(
session=SimpleNamespace(key="cli:test", metadata={}),
msg=SimpleNamespace(content="hello", media=None),
history=[],
kind=TurnKind.USER,
delivery=SimpleNamespace(route=SimpleNamespace(channel="cli")),
pending_summary=None,
runtime_context_blocks=[],
ephemeral=False,
)
loop._build_initial_messages(turn)
assert loop.context.build_messages.call_args.kwargs["resource_view_mode"] == expected
def test_initial_prompt_keeps_legacy_mode_without_resource_view(tmp_path: Path) -> None:
loop, _, _ = _loop(tmp_path, resource_view=None)
scope = build_workspace_scope(tmp_path, "full")
assert loop._resource_view_mode_for_scope(scope) is None
+36
View File
@@ -8,6 +8,7 @@ from pathlib import Path
import pytest
from nanobot.agent.skills import SkillsLoader
from nanobot.resource_links import ResourceView
def _write_skill(
@@ -315,6 +316,41 @@ def test_build_skills_summary_groups_paths_by_root(tmp_path: Path) -> None:
assert "`beta/SKILL.md`" in summary
def test_build_skills_summary_uses_alias_roots_but_keeps_canonical_entries(
tmp_path: Path,
) -> None:
workspace = tmp_path / "ws"
workspace_skills = workspace / "skills"
workspace_skills.mkdir(parents=True)
workspace_path = _write_skill(workspace_skills, "alpha", body="# Alpha")
builtin = tmp_path / "builtin"
builtin_path = _write_skill(builtin, "beta", body="# Beta")
aliases = tmp_path / "resources" / "view"
resource_view = ResourceView(
root=aliases,
agent=aliases / "agent",
media=aliases / "media",
package=aliases / "package",
)
loader = SkillsLoader(
workspace,
builtin_skills_dir=builtin,
resource_view=resource_view,
)
entries = loader.list_skills(filter_unavailable=False)
summary = loader.build_skills_summary()
assert {entry["path"] for entry in entries} == {
str(workspace_path),
str(builtin_path),
}
assert f"`{resource_view.agent / 'skills'}`" in summary
assert f"`{resource_view.package / 'skills'}`" in summary
assert str(workspace_path) not in summary
assert str(builtin_path) 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")
+46
View File
@@ -11,6 +11,7 @@ from nanobot.agent.tools.filesystem import FileToolsConfig
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ToolsConfig
from nanobot.providers.base import GenerationSettings, LLMProvider
from nanobot.resource_links import ResourceView
from nanobot.security.workspace_access import build_workspace_scope
from nanobot.utils.llm_runtime import LLMRuntime
@@ -109,6 +110,51 @@ def test_subagent_prompt_explains_grouped_skill_paths(tmp_path):
assert "project-custom" not in prompt
def test_subagent_prompt_uses_restricted_resource_aliases(tmp_path):
agent_workspace = tmp_path / "agent"
aliases = tmp_path / "resources" / "view"
resource_view = ResourceView(
root=aliases,
agent=aliases / "agent",
media=aliases / "media",
package=aliases / "package",
)
manager = SubagentManager(
workspace=agent_workspace,
bus=MessageBus(),
max_tool_result_chars=16_000,
resource_view=resource_view,
)
prompt = manager._build_subagent_prompt(resource_view_mode="restricted")
assert f"Custom skills: `{resource_view.agent / 'skills'}`" in prompt
assert f"Media: `{resource_view.media}`" in prompt
assert f"Built-in skills: `{resource_view.package / 'skills'}`" in prompt
assert f"Agent workspace: `{resource_view.agent}`" not in prompt
assert f"Nanobot package: `{resource_view.package}`" not in prompt
assert f"History log: {agent_workspace.resolve() / 'memory' / 'history.jsonl'}" in prompt
def test_subagent_prompt_uses_agent_alias_for_full_history_path(tmp_path):
agent_workspace = tmp_path / "agent"
aliases = tmp_path / "resources" / "view"
resource_view = ResourceView(
root=aliases,
agent=aliases / "agent",
)
manager = SubagentManager(
workspace=agent_workspace,
bus=MessageBus(),
max_tool_result_chars=16_000,
resource_view=resource_view,
)
prompt = manager._build_subagent_prompt(resource_view_mode="full")
assert f"History log: {resource_view.agent / 'memory' / 'history.jsonl'}" in prompt
@pytest.mark.asyncio
async def test_subagent_keeps_project_runtime_scope_with_agent_owned_tools(tmp_path):
agent_workspace = tmp_path / "agent"