mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
feat(core): add stable resource path aliases
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user