Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
- 安装清理不再删掉运行环境。PostgreSQL 上的知识库检索和关怀去重恢复正常

### 变更
- 聊天侧栏可以隐藏任意专家,不再限于别人共享来的。正在对话的隐藏专家留在「已隐藏专家」里,不再被提到列表上方
- 团队里调用专家时,专家配置的连接器不再静默失效:peer 回合也会按聊天用户的可用范围解析并预载连接器,加载失败降级为不带工具(#2615)
- 升级 octop-harness 至 1.0.7
- 内置专家并入专家市场,安装包不再自带市场目录

Expand Down
59 changes: 59 additions & 0 deletions src/octop/infra/agents/teams/team_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) -> 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
``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 = (
Expand All @@ -173,6 +224,14 @@ 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 not servers:
if extra is None:
extra = None
else:
if extra is None:
extra = {}
extra["mcp_servers"] = list(servers)
question = ""
dispatch_message = None
if group:
Expand Down
86 changes: 86 additions & 0 deletions tests/unit/gateway/test_global_processor_team.py
Original file line number Diff line number Diff line change
Expand Up @@ -2341,3 +2341,89 @@ 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 {})
Loading