From 81a373c0e5a951e5322c64626e8f2426899d95c8 Mon Sep 17 00:00:00 2001 From: Nolan Date: Tue, 8 Sep 2026 12:10:59 +0800 Subject: [PATCH] fix(workspace): honor human @mentions deterministically in dynamic threads A human @mention is an explicit addressing decision, but dynamic and workflow threads handed every message to the LLM router, which re-decided who should answer. Since the thread picker adds every workspace agent as a participant by default, the router kept picking bystanders the user never added to the thread (#333). Route human messages that carry a workspace-known @mention straight to the mentioned agents and skip the router call. Agent-authored mentions still go through the routers, and master mode keeps its star topology. Fixes #333 --- workspace/backend/app/mods/workspace_mod.py | 10 ++- workspace/backend/tests/test_llm_router.py | 86 ++++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/workspace/backend/app/mods/workspace_mod.py b/workspace/backend/app/mods/workspace_mod.py index 69ad0c947..bed5f3dda 100644 --- a/workspace/backend/app/mods/workspace_mod.py +++ b/workspace/backend/app/mods/workspace_mod.py @@ -1478,7 +1478,15 @@ async def _handle_message_posted(event: Event, ctx: PipelineContext) -> Optional from app.config import config mode = (getattr(channel, "orchestration_mode", None) or "dynamic").lower() - if mode == "master": + # An explicit @mention from a human is a direct addressing decision, + # not a routing question — honor it deterministically instead of + # consulting the LLM router, so bystander participants aren't pulled + # into the conversation (#333). Agent-authored mentions stay with the + # modes below: "master" already delegates via mentions, and the + # routers weigh the whole conversation when picking the next hop. + if mentions and event.source.startswith("human:") and mode != "master": + targets = list(mentions) + elif mode == "master": # Deterministic star topology — no LLM. If the channel somehow # has no master, fall back to the generic mention/online logic # so messages aren't stranded. diff --git a/workspace/backend/tests/test_llm_router.py b/workspace/backend/tests/test_llm_router.py index 9f4965534..7a29e119d 100644 --- a/workspace/backend/tests/test_llm_router.py +++ b/workspace/backend/tests/test_llm_router.py @@ -7,7 +7,7 @@ import asyncio import pytest -from unittest.mock import patch, MagicMock +from unittest.mock import patch, MagicMock, AsyncMock from app.models import Channel, ChannelMember, WorkspaceMember, Workspace from app.mods.workspace_mod import _route_with_llm, _master_targets, _handle_message_posted @@ -278,6 +278,90 @@ def test_handle_message_posted_master_mode_subagent_returns(self, db, multi_agen assert out.metadata.get("target_agents") == ["agent-master"] +class TestHumanMentionDeterministicRouting: + """A human @mention is an explicit addressing decision (#333). + + In dynamic/workflow threads every workspace agent tends to be a channel + participant (the UI's thread picker adds them by default), so letting the + LLM router re-decide who a "@name" message is for lets bystanders — + including agents the user never added to the thread — answer instead. + The mention must win. + """ + + def _dynamic_thread(self, db, multi_agent_workspace): + """Reopen the fixture channel as a master-less dynamic thread with + a third participant the user never meant to talk to.""" + ws = multi_agent_workspace["workspace"] + ch = multi_agent_workspace["channel"] + ch.master_agent = None + ch.orchestration_mode = "dynamic" + db.add(WorkspaceMember(workspace_id=ws.id, agent_name="agent-bystander", role="member", status="online")) + db.add(ChannelMember(channel_id=ch.id, agent_name="agent-bystander")) + db.flush() + db.refresh(ch) + return ws, ch + + @patch("app.mods.workspace_mod._route_with_llm", new_callable=AsyncMock) + @patch("app.mods.workspace_mod._get_router_api_key", return_value="test-key") + def test_human_mention_bypasses_llm_router(self, _key, mock_router, db, multi_agent_workspace): + ws, _ch = self._dynamic_thread(db, multi_agent_workspace) + mock_router.return_value = ["agent-bystander"] + event = _make_event("human:user", "channel/session-test", "@agent-worker can you check the logs?") + ctx = PipelineContext(network_id=str(ws.id), agent_address="human:user", db=db, workspace=ws) + out = _run(_handle_message_posted(event, ctx)) + assert out.metadata.get("target_agents") == ["agent-worker"] + mock_router.assert_not_called() + + @patch("app.mods.workspace_mod._route_with_llm", new_callable=AsyncMock) + @patch("app.mods.workspace_mod._get_router_api_key", return_value="test-key") + def test_human_multiple_mentions_target_all(self, _key, mock_router, db, multi_agent_workspace): + ws, _ch = self._dynamic_thread(db, multi_agent_workspace) + mock_router.return_value = ["agent-bystander"] + event = _make_event("human:user", "channel/session-test", "@agent-master and @agent-worker please pair on this") + ctx = PipelineContext(network_id=str(ws.id), agent_address="human:user", db=db, workspace=ws) + out = _run(_handle_message_posted(event, ctx)) + assert out.metadata.get("target_agents") == ["agent-master", "agent-worker"] + mock_router.assert_not_called() + + @patch("app.mods.workspace_mod._route_with_llm", new_callable=AsyncMock) + @patch("app.mods.workspace_mod._get_router_api_key", return_value="test-key") + def test_human_mention_ignores_unknown_agent_names(self, _key, mock_router, db, multi_agent_workspace): + """A @token that isn't a workspace member isn't a mention — the + router still decides.""" + ws, _ch = self._dynamic_thread(db, multi_agent_workspace) + mock_router.return_value = ["agent-worker"] + event = _make_event("human:user", "channel/session-test", "@nonexistent-style points, who takes this?") + ctx = PipelineContext(network_id=str(ws.id), agent_address="human:user", db=db, workspace=ws) + out = _run(_handle_message_posted(event, ctx)) + assert out.metadata.get("target_agents") == ["agent-worker"] + mock_router.assert_called_once() + + @patch("app.mods.workspace_mod._route_with_llm", new_callable=AsyncMock) + @patch("app.mods.workspace_mod._get_router_api_key", return_value="test-key") + def test_agent_mention_still_uses_llm_router(self, _key, mock_router, db, multi_agent_workspace): + """Only human-authored mentions are deterministic; an agent @mentioning + a peer is still a routing decision.""" + ws, _ch = self._dynamic_thread(db, multi_agent_workspace) + mock_router.return_value = ["agent-master"] + event = _make_event("openagents:agent-worker", "channel/session-test", "@agent-master handoff: finished the scan") + ctx = PipelineContext(network_id=str(ws.id), agent_address="openagents:agent-worker", db=db, workspace=ws) + out = _run(_handle_message_posted(event, ctx)) + assert out.metadata.get("target_agents") == ["agent-master"] + mock_router.assert_called_once() + + def test_master_mode_human_mention_still_routes_to_master(self, db, multi_agent_workspace): + """Master mode keeps its star topology: the hub owns every human + request, mention or not.""" + ws = multi_agent_workspace["workspace"] + ch = multi_agent_workspace["channel"] + ch.orchestration_mode = "master" + db.flush() + event = _make_event("human:user", "channel/session-test", "@agent-worker can you check the logs?") + ctx = PipelineContext(network_id=str(ws.id), agent_address="human:user", db=db, workspace=ws) + out = _run(_handle_message_posted(event, ctx)) + assert out.metadata.get("target_agents") == ["agent-master"] + + class TestWorkflowMode: """'workflow' mode reuses the LLM router but injects a user-authored natural-language collaboration plan into the prompt."""