From 68466b1c2a6d6d166f8042d6ed82e4925f51f09a Mon Sep 17 00:00:00 2001 From: chengyongru Date: Mon, 20 Apr 2026 11:53:29 +0800 Subject: [PATCH] fix(agent): propagate effective session key through subagent pipeline The previous fix hardcoded session_key_override as channel:chat_id which broke unified session mode where pending queues use "unified:default". Propagate the effective key from _set_tool_context through SpawnTool into the origin dict so _announce_result routes to the correct pending queue in both normal and unified session modes. --- nanobot/agent/loop.py | 8 ++- nanobot/agent/subagent.py | 12 +++-- nanobot/agent/tools/spawn.py | 4 +- tests/agent/test_task_cancel.py | 88 +++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 8 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 53cb49d7..a3b29fb9 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -318,10 +318,16 @@ class AgentLoop: def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Update context for all tools that need routing info.""" + # Compute the effective session key (accounts for unified sessions) + # so that subagent results route to the correct pending queue. + effective_key = UNIFIED_SESSION_KEY if self._unified_session else f"{channel}:{chat_id}" for name in ("message", "spawn", "cron", "my"): if tool := self.tools.get(name): if hasattr(tool, "set_context"): - tool.set_context(channel, chat_id, *([message_id] if name == "message" else [])) + if name == "spawn": + tool.set_context(channel, chat_id, effective_key=effective_key) + else: + tool.set_context(channel, chat_id, *([message_id] if name == "message" else [])) @staticmethod def _strip_think(text: str | None) -> str | None: diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 58daafb6..7db62dcf 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -107,7 +107,7 @@ class SubagentManager: """Spawn a subagent to execute a task in the background.""" task_id = str(uuid.uuid4())[:8] display_label = label or task[:30] + ("..." if len(task) > 30 else "") - origin = {"channel": origin_channel, "chat_id": origin_chat_id} + origin = {"channel": origin_channel, "chat_id": origin_chat_id, "session_key": session_key} status = SubagentStatus( task_id=task_id, @@ -241,15 +241,17 @@ class SubagentManager: ) # Inject as system message to trigger main agent. - # Use session_key_override to align with the main agent's session key - # so the result is routed to the pending queue (mid-turn injection) - # instead of being dispatched as a competing independent task. + # Use session_key_override to align with the main agent's effective + # session key (which accounts for unified sessions) so the result is + # routed to the correct pending queue (mid-turn injection) instead of + # being dispatched as a competing independent task. + override = origin.get("session_key") or f"{origin['channel']}:{origin['chat_id']}" msg = InboundMessage( channel="system", sender_id="subagent", chat_id=f"{origin['channel']}:{origin['chat_id']}", content=announce_content, - session_key_override=f"{origin['channel']}:{origin['chat_id']}", + session_key_override=override, metadata={ "injected_event": "subagent_result", "subagent_task_id": task_id, diff --git a/nanobot/agent/tools/spawn.py b/nanobot/agent/tools/spawn.py index 86319e99..8ffb438b 100644 --- a/nanobot/agent/tools/spawn.py +++ b/nanobot/agent/tools/spawn.py @@ -25,11 +25,11 @@ class SpawnTool(Tool): self._origin_chat_id = "direct" self._session_key = "cli:direct" - def set_context(self, channel: str, chat_id: str) -> None: + def set_context(self, channel: str, chat_id: str, effective_key: str | None = None) -> None: """Set the origin context for subagent announcements.""" self._origin_channel = channel self._origin_chat_id = chat_id - self._session_key = f"{channel}:{chat_id}" + self._session_key = effective_key or f"{channel}:{chat_id}" @property def name(self) -> str: diff --git a/tests/agent/test_task_cancel.py b/tests/agent/test_task_cancel.py index c1c36ca8..7133554b 100644 --- a/tests/agent/test_task_cancel.py +++ b/tests/agent/test_task_cancel.py @@ -412,3 +412,91 @@ class TestSubagentCancellation: assert cancelled.is_set() assert task.cancelled() mgr._announce_result.assert_not_awaited() + + +class TestSubagentAnnounceSessionKey: + """Verify _announce_result uses the effective session key for mid-turn routing.""" + + def _make_mgr(self): + """Create a SubagentManager with mocked deps and its bus.""" + from nanobot.agent.subagent import SubagentManager + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + mgr = SubagentManager( + provider=provider, + workspace=MagicMock(), + bus=bus, + max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, + ) + return mgr, bus + + @pytest.mark.asyncio + async def test_announce_uses_effective_key_in_unified_mode(self): + """In unified session mode, session_key_override must be 'unified:default' + so the result matches the pending queue key.""" + mgr, bus = self._make_mgr() + + origin = {"channel": "telegram", "chat_id": "111", "session_key": "unified:default"} + await mgr._announce_result("sub-1", "label", "task", "result", origin, "ok") + + msg = await bus.consume_inbound() + assert msg.session_key_override == "unified:default" + assert msg.session_key == "unified:default" + + @pytest.mark.asyncio + async def test_announce_uses_raw_key_in_normal_mode(self): + """Without unified sessions, session_key_override is the raw channel:chat_id.""" + mgr, bus = self._make_mgr() + + origin = {"channel": "telegram", "chat_id": "222", "session_key": "telegram:222"} + await mgr._announce_result("sub-2", "label", "task", "result", origin, "ok") + + msg = await bus.consume_inbound() + assert msg.session_key_override == "telegram:222" + assert msg.session_key == "telegram:222" + + @pytest.mark.asyncio + async def test_announce_falls_back_to_origin_when_no_session_key(self): + """When session_key is None, fallback to f'{channel}:{chat_id}'.""" + mgr, bus = self._make_mgr() + + origin = {"channel": "discord", "chat_id": "333", "session_key": None} + await mgr._announce_result("sub-3", "label", "task", "result", origin, "ok") + + msg = await bus.consume_inbound() + assert msg.session_key_override == "discord:333" + assert msg.channel == "system" + assert msg.chat_id == "discord:333" + + @pytest.mark.asyncio + async def test_session_key_flows_through_run_subagent(self): + """Verify session_key in origin propagates from _run_subagent to _announce_result.""" + from nanobot.agent.subagent import SubagentStatus + + mgr, bus = self._make_mgr() + + async def fake_run(spec): + return SimpleNamespace( + stop_reason="done", + final_content="done", + error=None, + tool_events=[], + ) + + mgr.runner.run = AsyncMock(side_effect=fake_run) + + status = SubagentStatus( + task_id="sub-4", label="label", task_description="task", + started_at=time.monotonic(), + ) + await mgr._run_subagent( + "sub-4", "task", "label", + {"channel": "telegram", "chat_id": "444", "session_key": "unified:default"}, + status, + ) + + msg = await bus.consume_inbound() + assert msg.session_key_override == "unified:default"