fix(agent): preserve agent-owned state in project workspaces (#4945)

This commit is contained in:
chengyongru
2026-07-22 17:25:22 +08:00
committed by GitHub
parent 4cd6eb6c38
commit b189a37648
23 changed files with 560 additions and 89 deletions
+78
View File
@@ -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)
+22 -14
View File
@@ -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
+39
View File
@@ -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"
+66
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.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)
+118 -2
View File
@@ -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"
+33
View File
@@ -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"