From f78aa5fed29ec92f3524bc9298603445adde2bb5 Mon Sep 17 00:00:00 2001 From: Fish <413281162@qq.com> Date: Mon, 2 Nov 2026 11:01:21 +0800 Subject: [PATCH 1/4] fix(teams): preload member connectors for peer turns so team experts keep their MCP tools --- CHANGELOG.md | 1 + src/octop/infra/agents/teams/team_manager.py | 56 ++++++++++++ .../gateway/test_global_processor_team.py | 85 +++++++++++++++++++ 3 files changed, 142 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc484612fc..e2fee582c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ ### 变更 - 聊天侧栏可以隐藏任意专家,不再限于别人共享来的。正在对话的隐藏专家留在「已隐藏专家」里,不再被提到列表上方 +- 团队里调用专家时,专家配置的连接器不再静默失效:peer 回合也会按聊天用户的可用范围解析并预载连接器,加载失败降级为不带工具(#2615) ## [1.0.3b2] - 2026-11-01 diff --git a/src/octop/infra/agents/teams/team_manager.py b/src/octop/infra/agents/teams/team_manager.py index 3299ec3abb..0652271771 100644 --- a/src/octop/infra/agents/teams/team_manager.py +++ b/src/octop/infra/agents/teams/team_manager.py @@ -162,6 +162,57 @@ def replace_thread_message_repo(self, repo: Any) -> None: # -- ask_agent session (member page) ------------------------------------- + async def _member_turn_mcp(self, agent_id: str, uid: int | None) -> int | None: + """Resolve and preload the member expert's connectors for one peer turn. + + Peer requests are built by the harness without MCP fields, so + ``ChatRequest.mcp_servers`` stays ``None`` and the MCP middleware + strips every connector tool from the member turn — connectors that + work on the expert's own 1:1 chat silently stop working in the team + (issue #2615). Mirror the direct-chat resolution with the chatting + user as connector scope; failures degrade to a tool-less turn the + same way a flaky IM connector does. + """ + if uid is None: + return None + from octop.infra.agents.teams import is_team_agent + + row = self._agent_manager.get_row(agent_id) + if row is None or is_team_agent(row): + return None + extra_defaults = self._agent_manager.default_mcp_servers(agent_id) + merged = self._agent_manager.merge_turn_mcp_servers( + uid, + None, + apply_defaults=True, + extra_defaults=extra_defaults, + ) + if not merged: + return None + try: + failed = await self._agent_manager.prepare_chat_mcp( + agent_id, + merged, + connector_user_id=uid, + ) + except Exception: + logger.warning( + "member turn MCP prepare crashed agent=%s uid=%s", + agent_id, + uid, + exc_info=True, + ) + return None + if failed: + logger.warning( + "member turn MCP load failed agent=%s uid=%s servers=%s", + agent_id, + uid, + ", ".join(failed), + ) + return None + return merged + async def prepare_peer_session(self, call: PeerCall) -> PeerSession | None: """Open the member thread and stash the host's rewritten assignment.""" thread_id = ( @@ -173,6 +224,11 @@ async def prepare_peer_session(self, call: PeerCall) -> PeerSession | None: uid = _octop_user_id(call.user_id) group = self._is_group_dispatch(call) extra = self._member_runtime(call) + servers = await self._member_turn_mcp(call.to_agent_id, uid) + if extra is None: + extra = {} if servers else None + if servers: + extra["mcp_servers"] = list(servers) question = "" dispatch_message = None if group: diff --git a/tests/unit/gateway/test_global_processor_team.py b/tests/unit/gateway/test_global_processor_team.py index d046fdb99a..7a067f1ae5 100644 --- a/tests/unit/gateway/test_global_processor_team.py +++ b/tests/unit/gateway/test_global_processor_team.py @@ -2341,3 +2341,88 @@ def test_stamp_stream_speaker_sets_agent_alias() -> None: ) assert member["agent_id"] == "doctor" assert member["agent"] == "doctor" + +@pytest.mark.asyncio +async def test_prepare_peer_session_stamps_member_connectors(processor_env: dict) -> None: + """Issue #2615: a member expert's configured connectors ride the peer turn. + + The harness builds peer ChatRequests without MCP fields, so without this + stamp the MCP middleware strips every connector tool in team rooms even + when the same expert can use it on their own 1:1 chat. + """ + from octop_harness.teams.util import PeerCall + + processor = processor_env["processor"] + parent_sk = processor_env["parent_sk"] + am = processor._agent_manager + am.default_mcp_servers = MagicMock(return_value=["weather"]) + am.merge_turn_mcp_servers = MagicMock(return_value=["weather", "docs"]) + am.prepare_chat_mcp = AsyncMock(return_value=[]) # nothing failed to load + + prepared = await processor.prepare_peer_session( + PeerCall( + from_agent_id="parent", + to_agent_id="child", + user_id=1, + message="hello", + source_thread_id="thr_parent", + source_session_key=str(parent_sk), + ) + ) + assert prepared is not None + assert prepared.configurable.get("mcp_servers") == ["weather", "docs"] + am.prepare_chat_mcp.assert_awaited_once_with( + "child", ["weather", "docs"], connector_user_id=1 + ) + + +@pytest.mark.asyncio +async def test_prepare_peer_session_skips_connectors_for_team_callee(processor_env: dict) -> None: + from octop_harness.teams.util import PeerCall + + processor = processor_env["processor"] + parent_sk = processor_env["parent_sk"] + am = processor._agent_manager + am.default_mcp_servers = MagicMock(return_value=["weather"]) + am.merge_turn_mcp_servers = MagicMock(return_value=["weather"]) + am.prepare_chat_mcp = AsyncMock(return_value=[]) # unused short-circuit + + prepared = await processor.prepare_peer_session( + PeerCall( + from_agent_id="parent", + to_agent_id="other-team", + user_id=1, + message="hello", + source_thread_id=None, + source_session_key=str(parent_sk), + ) + ) + assert prepared is not None + has_mcp = bool(prepared.configurable and prepared.configurable.get("mcp_servers")) + assert not has_mcp + am.prepare_chat_mcp.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_prepare_peer_session_connector_load_failure_degrades_to_none(processor_env: dict) -> None: + from octop_harness.teams.util import PeerCall + + processor = processor_env["processor"] + parent_sk = processor_env["parent_sk"] + am = processor._agent_manager + am.default_mcp_servers = MagicMock(return_value=["weather"]) + am.merge_turn_mcp_servers = MagicMock(return_value=["weather"]) + am.prepare_chat_mcp = AsyncMock(return_value=["weather"]) # failed to load + + prepared = await processor.prepare_peer_session( + PeerCall( + from_agent_id="parent", + to_agent_id="child", + user_id=1, + message="hello", + source_thread_id="thr_parent", + source_session_key=str(parent_sk), + ) + ) + assert prepared is not None + assert prepared.configurable is None or "mcp_servers" not in (prepared.configurable or {}) From f1ecd968ba7f9d513985a44c60d18bc6abc217b7 Mon Sep 17 00:00:00 2001 From: wxhking <186391338+wxhking@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:19:26 +0800 Subject: [PATCH 2/4] style: ruff format tests/unit/gateway/test_global_processor_team.py --- tests/unit/gateway/test_global_processor_team.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/unit/gateway/test_global_processor_team.py b/tests/unit/gateway/test_global_processor_team.py index 7a067f1ae5..b906e23bf6 100644 --- a/tests/unit/gateway/test_global_processor_team.py +++ b/tests/unit/gateway/test_global_processor_team.py @@ -2342,6 +2342,7 @@ def test_stamp_stream_speaker_sets_agent_alias() -> None: assert member["agent_id"] == "doctor" assert member["agent"] == "doctor" + @pytest.mark.asyncio async def test_prepare_peer_session_stamps_member_connectors(processor_env: dict) -> None: """Issue #2615: a member expert's configured connectors ride the peer turn. @@ -2371,9 +2372,7 @@ async def test_prepare_peer_session_stamps_member_connectors(processor_env: dict ) assert prepared is not None assert prepared.configurable.get("mcp_servers") == ["weather", "docs"] - am.prepare_chat_mcp.assert_awaited_once_with( - "child", ["weather", "docs"], connector_user_id=1 - ) + am.prepare_chat_mcp.assert_awaited_once_with("child", ["weather", "docs"], connector_user_id=1) @pytest.mark.asyncio @@ -2404,7 +2403,9 @@ async def test_prepare_peer_session_skips_connectors_for_team_callee(processor_e @pytest.mark.asyncio -async def test_prepare_peer_session_connector_load_failure_degrades_to_none(processor_env: dict) -> None: +async def test_prepare_peer_session_connector_load_failure_degrades_to_none( + processor_env: dict, +) -> None: from octop_harness.teams.util import PeerCall processor = processor_env["processor"] From c9ad57706ed22af9d9b73e271d79e382b243fd86 Mon Sep 17 00:00:00 2001 From: Fish <413281162@qq.com> Date: Mon, 2 Nov 2026 12:33:00 +0800 Subject: [PATCH 3/4] chore: retrigger CI on updated head From e325a6bf6c76415ddb6e0457c24dd305c0241481 Mon Sep 17 00:00:00 2001 From: wxhking <186391338+wxhking@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:00:29 +0800 Subject: [PATCH 4/4] fix(typecheck): correct _member_turn_mcp return type and gate the extra assignment --- src/octop/infra/agents/teams/team_manager.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/octop/infra/agents/teams/team_manager.py b/src/octop/infra/agents/teams/team_manager.py index 0652271771..1df1ef57a5 100644 --- a/src/octop/infra/agents/teams/team_manager.py +++ b/src/octop/infra/agents/teams/team_manager.py @@ -162,7 +162,7 @@ def replace_thread_message_repo(self, repo: Any) -> None: # -- ask_agent session (member page) ------------------------------------- - async def _member_turn_mcp(self, agent_id: str, uid: int | None) -> int | None: + async def _member_turn_mcp(self, agent_id: str, uid: int | None) -> list[str] | None: """Resolve and preload the member expert's connectors for one peer turn. Peer requests are built by the harness without MCP fields, so @@ -225,9 +225,12 @@ async def prepare_peer_session(self, call: PeerCall) -> PeerSession | None: group = self._is_group_dispatch(call) extra = self._member_runtime(call) servers = await self._member_turn_mcp(call.to_agent_id, uid) - if extra is None: - extra = {} if servers else None - if servers: + if not servers: + if extra is None: + extra = None + else: + if extra is None: + extra = {} extra["mcp_servers"] = list(servers) question = "" dispatch_message = None