375 lines
13 KiB
Python
375 lines
13 KiB
Python
"""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 <topic>``,
|
||
``/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)
|