diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 1b88ede11..e71eb4834 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -99,7 +99,6 @@ class SubagentManager: self._running_tasks: dict[str, asyncio.Task[None]] = {} self._task_statuses: dict[str, SubagentStatus] = {} self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...} - self._tools_cache: ToolRegistry | None = None def _subagent_tools_config(self) -> ToolsConfig: """Build a ToolsConfig scoped for subagent use.""" @@ -110,9 +109,7 @@ class SubagentManager: ) def _build_tools(self) -> ToolRegistry: - """Build the subagent tool registry via ToolLoader (cached).""" - if self._tools_cache is not None: - return self._tools_cache + """Build an isolated subagent tool registry via ToolLoader.""" registry = ToolRegistry() ctx = ToolContext( config=self._subagent_tools_config(), @@ -120,7 +117,6 @@ class SubagentManager: file_state_store=FileStates(), ) ToolLoader().load(ctx, registry, scope="subagent") - self._tools_cache = registry return registry def set_provider(self, provider: LLMProvider, model: str) -> None: diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index fb36d330d..339f9bdcf 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -79,8 +79,7 @@ class MessageTool(Tool, ContextAware): self._default_channel.set(ctx.channel) self._default_chat_id.set(ctx.chat_id) self._default_message_id.set(ctx.message_id) - if ctx.metadata: - self._default_metadata.set(ctx.metadata) + self._default_metadata.set(dict(ctx.metadata or {})) def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None: """Set the callback for sending messages.""" diff --git a/tests/agent/test_subagent.py b/tests/agent/test_subagent.py index 72a0f458d..ef6940a7c 100644 --- a/tests/agent/test_subagent.py +++ b/tests/agent/test_subagent.py @@ -28,3 +28,27 @@ async def test_subagent_uses_tool_loader(): assert tools.has("glob") assert not tools.has("message") assert not tools.has("spawn") + + +@pytest.mark.asyncio +async def test_subagent_build_tools_isolates_file_read_state(tmp_path): + """Each spawned subagent needs a fresh file-state cache.""" + (tmp_path / "note.txt").write_text("hello\n", encoding="utf-8") + provider = MagicMock(spec=LLMProvider) + provider.get_default_model.return_value = "test" + sm = SubagentManager( + provider=provider, + workspace=tmp_path, + bus=MessageBus(), + model="test", + max_tool_result_chars=16_000, + ) + + first_read = sm._build_tools().get("read_file") + second_read = sm._build_tools().get("read_file") + + assert first_read is not second_read + assert (await first_read.execute(path="note.txt")).startswith("1| hello") + second_result = await second_read.execute(path="note.txt") + assert second_result.startswith("1| hello") + assert "File unchanged" not in second_result diff --git a/tests/tools/test_message_tool.py b/tests/tools/test_message_tool.py index d32b07778..d4439422a 100644 --- a/tests/tools/test_message_tool.py +++ b/tests/tools/test_message_tool.py @@ -91,6 +91,29 @@ async def test_message_tool_inherits_metadata_for_same_target() -> None: assert sent[0].metadata == slack_meta +@pytest.mark.asyncio +async def test_message_tool_clears_metadata_when_context_has_none() -> None: + sent: list[OutboundMessage] = [] + + async def _send(msg: OutboundMessage) -> None: + sent.append(msg) + + tool = MessageTool(send_callback=_send) + from nanobot.agent.tools.context import RequestContext + tool.set_context( + RequestContext( + channel="slack", + chat_id="C123", + metadata={"slack": {"thread_ts": "111.222", "channel_type": "channel"}}, + ), + ) + tool.set_context(RequestContext(channel="slack", chat_id="C123", metadata={})) + + await tool.execute(content="plain reply") + + assert sent[0].metadata == {} + + @pytest.mark.asyncio async def test_message_tool_does_not_inherit_metadata_for_cross_target() -> None: sent: list[OutboundMessage] = []