fischer-agentkit/src/agentkit/experts/team.py

375 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)