From 7fb0811fbb978bb76687e19a0923658e0b4229ce Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:21:07 +0800 Subject: [PATCH] fix(agent): honor selected project workspace --- nanobot/agent/context.py | 8 +++++++ tests/agent/test_context_prompt_cache.py | 23 ++++++++++++++++++ tests/agent/test_workspace_scope.py | 30 ++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 772aceb52..a2aad8351 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -112,6 +112,14 @@ class ContextBuilder: parts.append(render_template("agent/tool_contract.md")) + project_path = root.expanduser().resolve() + if project_path != self.workspace.expanduser().resolve(): + parts.append( + "# Current Project\n\n" + f"Working directory: {project_path}\n" + "Use it as the default root for project files and relative tool paths." + ) + if include_memory: memory = self.memory.read_memory() if memory and not self._is_template_content(memory, "memory/MEMORY.md"): diff --git a/tests/agent/test_context_prompt_cache.py b/tests/agent/test_context_prompt_cache.py index f9393f1c2..b3ddcb32e 100644 --- a/tests/agent/test_context_prompt_cache.py +++ b/tests/agent/test_context_prompt_cache.py @@ -49,6 +49,29 @@ def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> assert prompt1 == prompt2 +def test_selected_project_path_follows_shared_cache_prefix(tmp_path) -> None: + """Project paths must not invalidate the stable identity and tool contract prefix.""" + agent_home = tmp_path / "agent-home" + project_a = tmp_path / "project-a" + project_b = tmp_path / "project-b" + agent_home.mkdir() + project_a.mkdir() + project_b.mkdir() + builder = ContextBuilder(agent_home) + + prompt_a = builder.build_system_prompt(workspace=project_a) + prompt_b = builder.build_system_prompt(workspace=project_b) + marker = "# Current Project" + prefix_a = prompt_a[: prompt_a.index(marker)] + prefix_b = prompt_b[: prompt_b.index(marker)] + + assert prefix_a == prefix_b + assert "# Tool Usage Notes" in prefix_a + assert str(project_a.resolve()) not in prefix_a + assert str(project_b.resolve()) not in prefix_b + assert prompt_a == builder.build_system_prompt(workspace=project_a) + + def test_system_prompt_reflects_current_dream_memory_contract(tmp_path) -> None: workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) diff --git a/tests/agent/test_workspace_scope.py b/tests/agent/test_workspace_scope.py index 8ca766963..515246045 100644 --- a/tests/agent/test_workspace_scope.py +++ b/tests/agent/test_workspace_scope.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock import pytest +from nanobot.agent.context import ContextBuilder from nanobot.agent.tools.cli_apps import CliAppsTool from nanobot.agent.tools.context import RequestContext, ToolContext, request_context from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool @@ -21,6 +22,7 @@ from nanobot.config.schema import ImageGenerationToolConfig, ProviderConfig, Too from nanobot.security.workspace_access import ( WORKSPACE_SCOPE_METADATA_KEY, WorkspaceScopeError, + WorkspaceScopeResolver, bind_workspace_scope, default_workspace_scope, reset_workspace_scope, @@ -101,6 +103,34 @@ def test_workspace_scope_accepts_home_relative_project_path( assert scope.metadata()["project_path"] == str(project.resolve()) +@pytest.mark.parametrize("access_mode", ["restricted", "full"]) +def test_selected_websocket_project_is_visible_to_the_model( + tmp_path: Path, + access_mode: str, +) -> None: + agent_home = tmp_path / "agent-home" + project = tmp_path / "project" + agent_home.mkdir() + project.mkdir() + resolver = WorkspaceScopeResolver(agent_home, default_restrict_to_workspace=False) + + scope = resolver.for_turn( + channel="websocket", + message_metadata={ + WORKSPACE_SCOPE_METADATA_KEY: { + "project_path": str(project), + "access_mode": access_mode, + } + }, + session_metadata=None, + ) + prompt = ContextBuilder(agent_home).build_system_prompt(workspace=scope.project_path) + + assert prompt.index("# Tool Usage Notes") < prompt.index("# Current Project") + assert f"Working directory: {project.resolve()}" in prompt + assert "Use it as the default root for project files" in prompt + + def test_workspace_scope_metadata_falls_back_for_stale_session(tmp_path: Path) -> None: scope = workspace_scope_from_metadata( {