fix(agent): preserve agent-owned state in project workspaces (#4945)
This commit is contained in:
@@ -78,6 +78,11 @@ class TestLoadBootstrapFiles:
|
||||
builder = _builder(tmp_path)
|
||||
assert builder._load_bootstrap_files() == ""
|
||||
|
||||
def test_empty_bootstrap_files(self, tmp_path):
|
||||
(tmp_path / "AGENTS.md").write_text("\n", encoding="utf-8")
|
||||
builder = _builder(tmp_path)
|
||||
assert builder._load_bootstrap_files() == ""
|
||||
|
||||
def test_agents_md(self, tmp_path):
|
||||
(tmp_path / "AGENTS.md").write_text("Be helpful.", encoding="utf-8")
|
||||
builder = _builder(tmp_path)
|
||||
@@ -116,6 +121,66 @@ class TestLoadBootstrapFiles:
|
||||
result = builder._load_bootstrap_files()
|
||||
assert "用中文回复" in result
|
||||
|
||||
def test_selected_project_supplies_only_agents_file(self, tmp_path):
|
||||
agent_home = tmp_path / "agent-home"
|
||||
project = tmp_path / "project"
|
||||
agent_home.mkdir()
|
||||
project.mkdir()
|
||||
(agent_home / "AGENTS.md").write_text("global project rules", encoding="utf-8")
|
||||
(agent_home / "SOUL.md").write_text("global soul", encoding="utf-8")
|
||||
(agent_home / "USER.md").write_text("global user", encoding="utf-8")
|
||||
(project / "AGENTS.md").write_text("selected project rules", encoding="utf-8")
|
||||
(project / "SOUL.md").write_text("project soul collision", encoding="utf-8")
|
||||
(project / "USER.md").write_text("project user collision", encoding="utf-8")
|
||||
|
||||
result = ContextBuilder(agent_home).build_system_prompt(
|
||||
workspace=project,
|
||||
include_memory_recent_history=False,
|
||||
)
|
||||
|
||||
assert "selected project rules" in result
|
||||
assert "global project rules" not in result
|
||||
assert "global soul" in result
|
||||
assert "global user" in result
|
||||
assert "project soul collision" not in result
|
||||
assert "project user collision" not in result
|
||||
|
||||
def test_selected_project_without_agents_does_not_fall_back(self, tmp_path):
|
||||
agent_home = tmp_path / "agent-home"
|
||||
project = tmp_path / "project"
|
||||
agent_home.mkdir()
|
||||
project.mkdir()
|
||||
(agent_home / "AGENTS.md").write_text("default workspace rules", encoding="utf-8")
|
||||
|
||||
result = ContextBuilder(agent_home).build_system_prompt(
|
||||
workspace=project,
|
||||
include_memory_recent_history=False,
|
||||
)
|
||||
|
||||
assert "default workspace rules" not in result
|
||||
|
||||
def test_unmodified_agents_and_user_templates_are_skipped(self, tmp_path):
|
||||
from nanobot.utils.helpers import sync_workspace_templates
|
||||
|
||||
sync_workspace_templates(tmp_path, silent=True)
|
||||
|
||||
result = ContextBuilder(tmp_path)._load_bootstrap_files()
|
||||
|
||||
assert "## AGENTS.md" not in result
|
||||
assert "## USER.md" not in result
|
||||
assert "## SOUL.md" in result
|
||||
|
||||
def test_customized_user_template_is_loaded(self, tmp_path):
|
||||
from nanobot.utils.helpers import sync_workspace_templates
|
||||
|
||||
sync_workspace_templates(tmp_path, silent=True)
|
||||
(tmp_path / "USER.md").write_text("User prefers Chinese.", encoding="utf-8")
|
||||
|
||||
result = ContextBuilder(tmp_path)._load_bootstrap_files()
|
||||
|
||||
assert "## USER.md" in result
|
||||
assert "User prefers Chinese." in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_template_content (static)
|
||||
@@ -239,6 +304,19 @@ class TestBuildSystemPrompt:
|
||||
result = builder.build_system_prompt()
|
||||
assert "workspace" in result.lower() or "python" in result.lower()
|
||||
|
||||
def test_selected_project_identity_keeps_agent_data_in_agent_workspace(self, tmp_path):
|
||||
agent_home = tmp_path / "agent-home"
|
||||
project = tmp_path / "project"
|
||||
agent_home.mkdir()
|
||||
project.mkdir()
|
||||
|
||||
result = ContextBuilder(agent_home)._get_identity(workspace=project)
|
||||
|
||||
assert f"current project workspace is at: {project.resolve()}" 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
|
||||
|
||||
def test_includes_bootstrap_files(self, tmp_path):
|
||||
(tmp_path / "AGENTS.md").write_text("Be helpful and concise.", encoding="utf-8")
|
||||
builder = _builder(tmp_path)
|
||||
|
||||
@@ -332,22 +332,33 @@ def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path
|
||||
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."""
|
||||
def test_memory_skill_is_lazy_loaded_from_skills_index(tmp_path) -> None:
|
||||
"""Memory search guidance should be discoverable without loading its full body."""
|
||||
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
|
||||
assert "### Skill: memory" not in prompt
|
||||
assert "**memory**" in prompt
|
||||
assert "Search Past Events" not in prompt
|
||||
assert "Examples (replace `keyword`)" not 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_fresh_workspace_omits_default_prompt_scaffolding(tmp_path) -> None:
|
||||
from nanobot.utils.helpers import sync_workspace_templates
|
||||
|
||||
workspace = _make_workspace(tmp_path)
|
||||
sync_workspace_templates(workspace, silent=True)
|
||||
|
||||
prompt = ContextBuilder(workspace).build_system_prompt()
|
||||
|
||||
assert "## AGENTS.md" not in prompt
|
||||
assert "## USER.md" not in prompt
|
||||
assert "8281248569" not in prompt
|
||||
assert "(your name)" not in prompt
|
||||
assert "apt/brew" not in prompt
|
||||
assert prompt.count("Do not use the 'message' tool for normal replies") == 1
|
||||
|
||||
|
||||
def test_template_memory_md_is_skipped(tmp_path) -> None:
|
||||
@@ -359,10 +370,7 @@ def test_template_memory_md_is_skipped(tmp_path) -> None:
|
||||
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".
|
||||
# This block is produced only when populated long-term memory is injected.
|
||||
assert "# Memory\n\n## Long-term Memory" not in prompt
|
||||
assert "This file is automatically updated by nanobot" not in prompt
|
||||
|
||||
|
||||
@@ -297,6 +297,45 @@ 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:
|
||||
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")
|
||||
|
||||
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_path) not in summary
|
||||
assert str(builtin_path) not in summary
|
||||
assert "`alpha/SKILL.md`" in summary
|
||||
assert "`beta/SKILL.md`" in summary
|
||||
|
||||
|
||||
def test_bundled_update_setup_description_is_valid_yaml(tmp_path: Path) -> None:
|
||||
metadata = SkillsLoader(tmp_path).get_skill_metadata("update-setup")
|
||||
|
||||
assert metadata is not None
|
||||
assert metadata["description"].startswith("One-time setup wizard")
|
||||
assert "Triggers:" in metadata["description"]
|
||||
|
||||
|
||||
def test_bundled_skills_use_agent_owned_paths(tmp_path: Path) -> None:
|
||||
loader = SkillsLoader(tmp_path)
|
||||
memory = loader.load_skill("memory")
|
||||
update_setup = loader.load_skill("update-setup")
|
||||
|
||||
assert memory is not None
|
||||
assert "<history-log-path>" in memory
|
||||
assert 'path="memory/history.jsonl"' not in memory
|
||||
assert update_setup is not None
|
||||
assert "<agent-workspace>/skills/update/SKILL.md" in update_setup
|
||||
assert "Never substitute a project-relative" in update_setup
|
||||
|
||||
|
||||
def test_disabled_skills_excluded_from_get_always_skills(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "ws"
|
||||
ws_skills = workspace / "skills"
|
||||
|
||||
@@ -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.security.workspace_access import build_workspace_scope
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
|
||||
@@ -82,6 +83,71 @@ 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):
|
||||
agent_workspace = tmp_path / "agent"
|
||||
project = tmp_path / "project"
|
||||
global_skill = agent_workspace / "skills" / "global-custom" / "SKILL.md"
|
||||
project_skill = project / "skills" / "project-custom" / "SKILL.md"
|
||||
global_skill.parent.mkdir(parents=True)
|
||||
project_skill.parent.mkdir(parents=True)
|
||||
global_skill.write_text("---\ndescription: global skill\n---\nGlobal", encoding="utf-8")
|
||||
project_skill.write_text("---\ndescription: project skill\n---\nProject", encoding="utf-8")
|
||||
manager = SubagentManager(
|
||||
workspace=agent_workspace,
|
||||
bus=MessageBus(),
|
||||
max_tool_result_chars=16_000,
|
||||
)
|
||||
|
||||
prompt = manager._build_subagent_prompt(workspace=project)
|
||||
|
||||
assert "one absolute 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 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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_keeps_project_runtime_scope_with_agent_owned_tools(tmp_path):
|
||||
agent_workspace = tmp_path / "agent"
|
||||
project = tmp_path / "project"
|
||||
agent_workspace.mkdir()
|
||||
project.mkdir()
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.get_default_model.return_value = "test"
|
||||
manager = SubagentManager(
|
||||
workspace=agent_workspace,
|
||||
bus=MessageBus(),
|
||||
max_tool_result_chars=16_000,
|
||||
)
|
||||
manager.runner.run = AsyncMock(
|
||||
return_value=AgentRunResult(final_content="ok", messages=[], stop_reason="completed")
|
||||
)
|
||||
manager._announce_result = AsyncMock()
|
||||
status = SubagentStatus(
|
||||
task_id="t1",
|
||||
label="label",
|
||||
task_description="task",
|
||||
started_at=0.0,
|
||||
)
|
||||
|
||||
await manager._run_subagent(
|
||||
"t1",
|
||||
"task",
|
||||
"label",
|
||||
{"channel": "websocket", "chat_id": "direct"},
|
||||
status,
|
||||
_runtime(provider),
|
||||
workspace_scope=build_workspace_scope(project, "restricted"),
|
||||
)
|
||||
|
||||
spec = manager.runner.run.call_args.args[0]
|
||||
assert spec.workspace == project
|
||||
assert spec.tools.get("read_file")._workspace == agent_workspace.resolve()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_forwards_fail_on_tool_error_to_runner(tmp_path):
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -7,14 +9,15 @@ from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.cli_apps import CliAppsTool
|
||||
from nanobot.agent.tools.context import RequestContext, request_context
|
||||
from nanobot.agent.tools.context import RequestContext, ToolContext, request_context
|
||||
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
|
||||
from nanobot.agent.tools.image_generation import ImageGenerationError, ImageGenerationTool
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.search import GrepTool
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig
|
||||
from nanobot.config.schema import ImageGenerationToolConfig, ProviderConfig
|
||||
from nanobot.config.schema import ImageGenerationToolConfig, ProviderConfig, ToolsConfig
|
||||
from nanobot.security.workspace_access import (
|
||||
WORKSPACE_SCOPE_METADATA_KEY,
|
||||
WorkspaceScopeError,
|
||||
@@ -33,6 +36,24 @@ PNG_BYTES = (
|
||||
)
|
||||
|
||||
|
||||
def _make_directory_link(link: Path, target: Path) -> None:
|
||||
if os.name == "nt":
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(link), str(target)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip(f"directory junction unavailable: {result.stderr or result.stdout}")
|
||||
return
|
||||
|
||||
try:
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
except (NotImplementedError, OSError) as exc:
|
||||
pytest.skip(f"directory symlink unavailable: {exc}")
|
||||
|
||||
|
||||
def test_workspace_scope_defaults_match_legacy_config(tmp_path: Path) -> None:
|
||||
unrestricted = default_workspace_scope(tmp_path, restrict_to_workspace=False)
|
||||
restricted = default_workspace_scope(tmp_path, restrict_to_workspace=True)
|
||||
@@ -118,6 +139,101 @@ async def test_filesystem_tool_uses_current_restricted_workspace_scope(tmp_path:
|
||||
reset_workspace_scope(token)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restricted_project_can_read_agent_skills_and_exact_history(tmp_path: Path) -> None:
|
||||
agent_workspace = tmp_path / "agent"
|
||||
project = tmp_path / "project"
|
||||
skill_file = agent_workspace / "skills" / "custom" / "SKILL.md"
|
||||
history_file = agent_workspace / "memory" / "history.jsonl"
|
||||
private_memory_file = agent_workspace / "memory" / "private.txt"
|
||||
private_file = agent_workspace / "private.txt"
|
||||
project_file = project / "project.txt"
|
||||
skill_file.parent.mkdir(parents=True)
|
||||
history_file.parent.mkdir(parents=True)
|
||||
project.mkdir()
|
||||
skill_file.write_text("global skill", encoding="utf-8")
|
||||
history_file.write_text('{"content":"global history"}\n', encoding="utf-8")
|
||||
private_memory_file.write_text("private memory", encoding="utf-8")
|
||||
private_file.write_text("private", encoding="utf-8")
|
||||
project_file.write_text("project", encoding="utf-8")
|
||||
|
||||
ctx = ToolContext(
|
||||
config=ToolsConfig(restrict_to_workspace=True),
|
||||
workspace=str(agent_workspace),
|
||||
)
|
||||
read_tool = ReadFileTool.create(ctx)
|
||||
grep_tool = GrepTool.create(ctx)
|
||||
write_tool = WriteFileTool.create(ctx)
|
||||
scope = validate_workspace_scope_payload(
|
||||
{"project_path": str(project), "access_mode": "restricted"},
|
||||
default_workspace=agent_workspace,
|
||||
default_restrict_to_workspace=True,
|
||||
)
|
||||
|
||||
token = bind_workspace_scope(scope)
|
||||
try:
|
||||
project_result = await read_tool.execute(path="project.txt")
|
||||
skill_result = await read_tool.execute(path=str(skill_file))
|
||||
history_result = await grep_tool.execute(
|
||||
pattern="global history",
|
||||
path=str(history_file),
|
||||
output_mode="content",
|
||||
)
|
||||
private_memory_result = await read_tool.execute(path=str(private_memory_file))
|
||||
private_result = await read_tool.execute(path=str(private_file))
|
||||
write_result = await write_tool.execute(path=str(skill_file), content="changed")
|
||||
history_write_result = await write_tool.execute(path=str(history_file), content="changed")
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
|
||||
assert "project" in project_result
|
||||
assert "global skill" in skill_result
|
||||
assert "global history" in history_result
|
||||
assert "outside allowed directory" in private_memory_result
|
||||
assert "outside allowed directory" in private_result
|
||||
assert "outside allowed directory" in write_result
|
||||
assert "outside allowed directory" in history_write_result
|
||||
assert skill_file.read_text(encoding="utf-8") == "global skill"
|
||||
assert history_file.read_text(encoding="utf-8") == '{"content":"global history"}\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restricted_project_reads_history_from_linked_agent_workspace(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
real_agent_workspace = tmp_path / "real-agent"
|
||||
linked_agent_workspace = tmp_path / "agent-link"
|
||||
project = tmp_path / "project"
|
||||
history_file = real_agent_workspace / "memory" / "history.jsonl"
|
||||
history_file.parent.mkdir(parents=True)
|
||||
project.mkdir()
|
||||
history_file.write_text('{"content":"linked history"}\n', encoding="utf-8")
|
||||
_make_directory_link(linked_agent_workspace, real_agent_workspace)
|
||||
|
||||
ctx = ToolContext(
|
||||
config=ToolsConfig(restrict_to_workspace=True),
|
||||
workspace=str(linked_agent_workspace),
|
||||
)
|
||||
grep_tool = GrepTool.create(ctx)
|
||||
scope = validate_workspace_scope_payload(
|
||||
{"project_path": str(project), "access_mode": "restricted"},
|
||||
default_workspace=linked_agent_workspace,
|
||||
default_restrict_to_workspace=True,
|
||||
)
|
||||
|
||||
token = bind_workspace_scope(scope)
|
||||
try:
|
||||
result = await grep_tool.execute(
|
||||
pattern="linked history",
|
||||
path=str(history_file.resolve()),
|
||||
output_mode="content",
|
||||
)
|
||||
finally:
|
||||
reset_workspace_scope(token)
|
||||
|
||||
assert "linked history" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filesystem_write_tool_full_scope_allows_outside_project(tmp_path: Path) -> None:
|
||||
project = tmp_path / "project"
|
||||
|
||||
@@ -294,6 +294,39 @@ async def test_grep_reports_skipped_binary_and_large_files(
|
||||
assert "skipped 1 large files" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_uses_a_larger_bounded_limit_for_an_explicit_file(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
large_file = tmp_path / "history.jsonl"
|
||||
large_file.write_text("needle\n" + "x" * 20, encoding="utf-8")
|
||||
monkeypatch.setattr(GrepTool, "_MAX_FILE_BYTES", 10)
|
||||
monkeypatch.setattr(GrepTool, "_MAX_EXPLICIT_FILE_BYTES", 100)
|
||||
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
|
||||
explicit_result = await tool.execute(
|
||||
pattern="needle",
|
||||
path=str(large_file),
|
||||
output_mode="content",
|
||||
)
|
||||
directory_result = await tool.execute(pattern="needle", path=".")
|
||||
monkeypatch.setattr(GrepTool, "_MAX_EXPLICIT_FILE_BYTES", 10)
|
||||
capped_result = await tool.execute(pattern="needle", path=str(large_file))
|
||||
|
||||
assert "needle" in explicit_result
|
||||
assert "skipped 1 large files" in directory_result
|
||||
assert "skipped 1 large files" in capped_result
|
||||
|
||||
|
||||
def test_grep_description_keeps_size_thresholds_implementation_specific(tmp_path: Path) -> None:
|
||||
tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path)
|
||||
|
||||
assert "limits are enforced by the tool" in tool.description
|
||||
assert "2 MB" not in tool.description
|
||||
assert "100 MB" not in tool.description
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_tools_reject_paths_outside_workspace(tmp_path: Path) -> None:
|
||||
outside = tmp_path.parent / "outside-search.txt"
|
||||
|
||||
Reference in New Issue
Block a user