"""ExpertTeam - 专家团队容器(hub-and-spoke 模式) 管理 Expert 生命周期、团队状态和事件广播, 是 Expert Team hub-and-spoke 协作模式的中央协调点。 简化说明(U3): - 移除 CollaborationPlan 依赖(Lead Expert 自主分解任务) - 移除跨阶段状态共享(Lead Expert 持有所有状态) - 保留 handoff_transport 用于事件广播(不再用于 Agent 间通信) - 保留 workspace 用于输出保存(不再用于跨阶段状态共享) """ from __future__ import annotations import asyncio import enum import logging import time import uuid from .config import ExpertConfig from .expert import Expert from .registry import ExpertTemplateRegistry from ..core.handoff_transport import InProcessHandoffTransport from ..core.shared_workspace import SharedWorkspace from ..core.agent_pool import AgentPool logger = logging.getLogger(__name__) class TeamStatus(str, enum.Enum): """ExpertTeam lifecycle states. 流水线模式生命周期: FORMING → PLANNING → EXECUTING → SYNTHESIZING → COMPLETED → DISSOLVED PLANNING 状态在 Lead Expert 分解任务为阶段时设置(KTD6), 与前端 IExpertTeamState.status 的 'planning' 值对齐。 """ FORMING = "forming" PLANNING = "planning" EXECUTING = "executing" SYNTHESIZING = "synthesizing" COMPLETED = "completed" DISSOLVED = "dissolved" class ExpertTeam: """Container managing a team of Experts in hub-and-spoke mode. In hub-and-spoke mode: - Lead Expert (hub) receives the task and decomposes it - Member Experts (spokes) execute subtasks in parallel - Lead Expert synthesizes the final result - No inter-agent communication (Lead Expert holds all state) """ def __init__( self, team_id: str | None = None, workspace: SharedWorkspace | None = None, pool: AgentPool | None = None, template_registry: ExpertTemplateRegistry | None = None, redis_client: object | None = None, ): self.team_id = team_id or str(uuid.uuid4()) # U4: Accept redis_client for SharedWorkspace state offloading. # If workspace is explicitly provided, redis_client is ignored. self._workspace = workspace or SharedWorkspace(redis_client=redis_client) self._pool = pool self._template_registry = template_registry or ExpertTemplateRegistry() self._handoff_transport = InProcessHandoffTransport() self._experts: dict[str, Expert] = {} self._lead_expert_name: str | None = None self._status = TeamStatus.FORMING self._team_channel = f"team:{self.team_id}" self._orchestrator_task: asyncio.Task | None = None # U4: User intervention queue — bounded to prevent unbounded growth. # Consumed by TeamOrchestrator at phase boundaries. self._interventions: asyncio.Queue[str] = asyncio.Queue(maxsize=64) @property def status(self) -> TeamStatus: return self._status @property def lead_expert(self) -> Expert | None: if self._lead_expert_name: return self._experts.get(self._lead_expert_name) return None @property def experts(self) -> list[Expert]: return list(self._experts.values()) @property def active_experts(self) -> list[Expert]: return [e for e in self._experts.values() if e.is_active] @property def workspace(self) -> SharedWorkspace: """Public read access to the team's shared workspace. In hub-and-spoke mode, workspace is used for output preservation only, not for cross-phase state sharing. """ return self._workspace @property def handoff_transport(self): """Public read access to the team's handoff transport. In hub-and-spoke mode, handoff_transport is used for event broadcasting (team_formed, expert_step, expert_result, team_synthesis, team_dissolved), not for inter-agent communication. """ return self._handoff_transport @property def team_channel(self) -> str: """Public read access to the team's communication channel.""" return self._team_channel @property def pool(self) -> AgentPool | None: """Public read access to the team's AgentPool. Used by TeamOrchestrator to create independent ConfigDrivenAgent instances for context isolation in pipeline mode (KTD3). """ return self._pool def get_expert(self, name: str) -> Expert | None: """Get an expert by name. Returns None if not found.""" return self._experts.get(name) def set_status(self, status: TeamStatus) -> None: """Update the team's status.""" self._status = status async def create_team( self, lead_config: ExpertConfig, member_configs: list[ExpertConfig] | None = None, ) -> None: """Create a team with a Lead Expert and optional members. In hub-and-spoke mode, the Lead Expert acts as the hub: - Receives the task and decomposes it into subtasks - Dispatches subtasks to member experts (spokes) - Synthesizes the final result """ if not self._pool: raise RuntimeError("AgentPool not configured") # Create Lead Expert team_context = self._build_team_context(lead_config, member_configs or []) lead = await Expert.create( config=lead_config, pool=self._pool, handoff_transport=self._handoff_transport, workspace=self._workspace, team_context=team_context, ) lead.team_id = self.team_id self._experts[lead_config.name] = lead self._lead_expert_name = lead_config.name # Create member Experts if member_configs: for config in member_configs: await self._add_expert_internal(config, team_context) # KTD6: 设置 PLANNING 状态(Lead Expert 即将分解任务为阶段) self._status = TeamStatus.PLANNING async def add_expert(self, config_or_template: ExpertConfig | str) -> Expert: """Add an Expert to the team dynamically. Args: config_or_template: ExpertConfig instance or template name to look up """ if isinstance(config_or_template, str): template = self._template_registry.get(config_or_template) if template is None: raise ValueError(f"ExpertTemplate '{config_or_template}' not found") config = template.config else: config = config_or_template # Safely get lead config — _lead_expert_name may be stale lead_config: ExpertConfig | None = None if self._lead_expert_name and self._lead_expert_name in self._experts: lead_config = self._experts[self._lead_expert_name].config team_context = self._build_team_context( lead_config, [e.config for e in self.active_experts], ) return await self._add_expert_internal(config, team_context) async def _add_expert_internal(self, config: ExpertConfig, team_context: str) -> Expert: """Internal method to add an Expert.""" if not self._pool: raise RuntimeError("AgentPool not configured") expert = await Expert.create( config=config, pool=self._pool, handoff_transport=self._handoff_transport, workspace=self._workspace, team_context=team_context, ) expert.team_id = self.team_id self._experts[config.name] = expert # Broadcast new expert joined await self._handoff_transport.send( self._team_channel, { "type": "expert_joined", "expert_name": config.name, "capabilities": expert.get_capabilities_summary(), }, ) return expert async def remove_expert(self, name: str) -> None: """Remove an Expert from the team.""" expert = self._experts.get(name) if not expert: return # Cannot remove Lead Expert — must reassign first if name == self._lead_expert_name: active = [e for e in self.active_experts if e.config.name != name] if active: # Reassign lead to first active expert new_lead = active[0] self._lead_expert_name = new_lead.config.name new_lead.config.is_lead = True else: self._lead_expert_name = None await expert.destroy(self._pool) del self._experts[name] # Broadcast expert left await self._handoff_transport.send( self._team_channel, { "type": "expert_left", "expert_name": name, }, ) async def broadcast_user_message(self, content: str) -> None: """Broadcast a user intervention message to all active Experts. Also enqueues the message to the intervention queue so TeamOrchestrator can consume it at phase boundaries (U4). """ message = { "type": "user_intervention", "content": content, "timestamp": time.time(), } await self._handoff_transport.send(self._team_channel, message) # U4: enqueue for orchestrator consumption (non-blocking; drop on full) try: self._interventions.put_nowait(content) except asyncio.QueueFull: logger.warning("Intervention queue full, dropping message") async def add_user_intervention(self, content: str) -> None: """Add a user intervention message for the orchestrator to consume. Broadcasts the message to the team channel and enqueues it. Used by WS/CLI handlers during team execution (U4). Args: content: User's intervention message (e.g. ``/debate ``, ``/stop``, or plain text to append to Lead context) """ await self.broadcast_user_message(content) def consume_user_interventions(self) -> list[str]: """Drain and return all pending user interventions. Called by TeamOrchestrator at phase boundaries (U4). Returns: List of intervention messages (oldest first). Empty if none. """ interventions: list[str] = [] while not self._interventions.empty(): try: interventions.append(self._interventions.get_nowait()) except asyncio.QueueEmpty: break return interventions async def get_shared_context(self) -> dict: """Get the team's shared context from SharedWorkspace. In hub-and-spoke mode, this returns preserved outputs only, not cross-phase state. """ context = {} keys = await self._workspace.list_keys() for key in keys: if key.startswith(f"team:{self.team_id}"): data = await self._workspace.read(key) if data: context[key] = data return context async def dissolve(self) -> None: """Dissolve the team. Temporary Experts are recycled, outputs preserved in SharedWorkspace.""" # Cancel ongoing orchestrator task if any if self._orchestrator_task and not self._orchestrator_task.done(): self._orchestrator_task.cancel() try: await self._orchestrator_task except asyncio.CancelledError: pass self._orchestrator_task = None for expert in self._experts.values(): if expert.is_active and self._pool: await expert.destroy(self._pool) self._experts.clear() self._lead_expert_name = None self._status = TeamStatus.DISSOLVED # Close handoff transport self._handoff_transport.close() def _build_team_context( self, lead_config: ExpertConfig | None, member_configs: list[ExpertConfig], ) -> str: """Build team context string for injection into Expert system prompts. In hub-and-spoke mode, the context emphasizes the Lead Expert's role as the hub and member experts' role as spokes. """ lines = ["You are part of an Expert Team (hub-and-spoke mode)."] if lead_config: lines.append( f"Lead Expert (hub): {lead_config.name} ({lead_config.persona}) — " f"decomposes tasks, dispatches subtasks, and synthesizes results." ) for config in member_configs: if lead_config and config.name == lead_config.name: continue lines.append( f"Team Member (spoke): {config.name} ({config.persona}), " f"Skills: {', '.join(config.bound_skills)} — " f"executes assigned subtasks independently." ) lines.append( "In hub-and-spoke mode: Lead Expert holds all state, " "subtasks are independent (depth=1, no further spawning), " "no inter-agent communication." ) return "\n".join(lines)