593 lines
26 KiB
Python
593 lines
26 KiB
Python
"""TeamOrchestrator - 流水线模式专家团队执行引擎.
|
||
|
||
Lead 分解任务为阶段(PlanPhase),按依赖拓扑排序执行:同层并行,层间串行。
|
||
每阶段独立 ConfigDrivenAgent(KTD3 上下文隔离),数据经 SharedWorkspace 传递。
|
||
生命周期:FORMING→PLANNING→EXECUTING→SYNTHESIZING→COMPLETED。
|
||
|
||
U2 重构:按职责拆分为 7 个 mixin,主类保留 execute/_run_pipeline/resume/
|
||
_decompose_task/_parse_phases + 共享状态 + LLM/broadcast 辅助方法。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import re
|
||
from typing import Any
|
||
|
||
from agentkit.llm.gateway import LLMGateway
|
||
|
||
from ._debate_runner import DebateRunnerMixin
|
||
from ._divergence_detector import DivergenceDetectorMixin
|
||
from ._intervention_handler import InterventionHandlerMixin
|
||
from ._phase_executor import PhaseExecutorMixin
|
||
from ._review_gate import ReviewGateMixin
|
||
from ._rollback_handler import RollbackHandlerMixin
|
||
from ._synthesizer import SynthesizerMixin
|
||
from .expert import Expert
|
||
from .plan import (
|
||
CollaborationContract,
|
||
PhaseStatus,
|
||
PhaseType,
|
||
PlanPhase,
|
||
PlanStatus,
|
||
TeamPlan,
|
||
)
|
||
from .team import ExpertTeam, TeamStatus
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 专家名校验正则(与 router.py / board_router.py 保持一致)
|
||
_EXPERT_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
|
||
|
||
|
||
class TeamOrchestrator(
|
||
PhaseExecutorMixin,
|
||
DebateRunnerMixin,
|
||
ReviewGateMixin,
|
||
DivergenceDetectorMixin,
|
||
RollbackHandlerMixin,
|
||
SynthesizerMixin,
|
||
InterventionHandlerMixin,
|
||
):
|
||
"""Pipeline orchestration engine. Lead decomposes task into phases with
|
||
dependencies, executed in topological order (same-layer parallel, layers
|
||
sequential). U2: 方法体拆分到 7 个 mixin,主类保留骨架 + 共享状态。"""
|
||
|
||
MAX_PHASES = 10 # Maximum phases Lead Expert can decompose
|
||
MAX_RETRIES = 1 # Retry once on phase failure before marking failed
|
||
MAX_REWORKS = 2 # 返工次数上限,超过则标记阶段失败
|
||
MAX_RISK_FLAGS = 10 # 风险标记数量上限,防止 UI 洪泛
|
||
MAX_DEBATE_ROUNDS = 4 # Hard cap on debate rounds per phase
|
||
MAX_DEBATES = 3 # Hard cap on auto-inserted debate phases per execution
|
||
DEFAULT_MAX_CONCURRENT_PHASES = 3 # 同层最大并发阶段数,避免 LLM 限流洪峰
|
||
STOP_COMMANDS = frozenset({"/stop", "停止", "stop", "结束"})
|
||
# G9/U4: RollbackExecutor default timeout for validation_command / rollback_command.
|
||
# Override via constructor `rollback_timeout` from `rollback.default_timeout` config.
|
||
DEFAULT_ROLLBACK_TIMEOUT = 30.0
|
||
|
||
def __init__(
|
||
self,
|
||
team: ExpertTeam,
|
||
max_concurrent_phases: int | None = None,
|
||
checkpoint: Any = None,
|
||
workspace_root: str | None = None,
|
||
rollback_timeout: float | None = None,
|
||
) -> None:
|
||
self._team = team
|
||
# Track temporary agent names created for context isolation (KTD3)
|
||
# Maps phase_id -> temp_agent_name for cleanup
|
||
self._temp_agents: dict[str, str] = {}
|
||
# Count of auto-inserted debate phases (bounded by MAX_DEBATES)
|
||
self._debate_count = 0
|
||
# U4: User context accumulated from plain-text interventions.
|
||
# Appended to Lead's synthesis prompt so user guidance influences result.
|
||
self._user_context: list[str] = []
|
||
# U2: 并发限制 — 同层并行阶段加 Semaphore,避免 LLM 限流洪峰
|
||
limit = max_concurrent_phases or self.DEFAULT_MAX_CONCURRENT_PHASES
|
||
self._phase_semaphore = asyncio.Semaphore(limit)
|
||
# U7: Pipeline checkpoint for crash recovery
|
||
self._checkpoint = checkpoint
|
||
# G9/U4: workspace_root drives RollbackExecutor cwd; rollback_timeout drives its timeout.
|
||
# Both default to no-op-friendly values so existing call sites behave identically.
|
||
self._workspace_root = workspace_root
|
||
self._rollback_timeout = rollback_timeout or self.DEFAULT_ROLLBACK_TIMEOUT
|
||
|
||
async def execute(self, task: str) -> dict[str, Any]:
|
||
"""Execute a task in pipeline mode. Lead decomposes → topological sort →
|
||
execute layers (parallel within layer) → synthesize. Returns dict with
|
||
status/result/phase_results/plan."""
|
||
lead = self._team.lead_expert
|
||
if not lead or not lead.is_active:
|
||
active = self._team.active_experts
|
||
if not active:
|
||
return {
|
||
"status": "failed",
|
||
"result": None,
|
||
"phase_results": {},
|
||
"error": "No active expert available",
|
||
}
|
||
lead = active[0]
|
||
logger.warning(f"Lead expert not available, falling back to '{lead.config.name}'")
|
||
|
||
plan = TeamPlan(
|
||
task=task,
|
||
lead_expert=lead.config.name,
|
||
status=PlanStatus.EXECUTING,
|
||
)
|
||
|
||
# 1. Emit team_formed event
|
||
# Send experts as IExpertInfo-compatible dicts + plan_phases: [] to match frontend contract
|
||
await self._broadcast_event(
|
||
"team_formed",
|
||
{
|
||
"team_id": self._team.team_id,
|
||
"status": self._team.status.value,
|
||
"lead_expert": lead.config.name,
|
||
"experts": [
|
||
{
|
||
"id": e.config.name,
|
||
"name": e.config.name,
|
||
"persona": e.config.persona,
|
||
"avatar": e.config.avatar,
|
||
"color": e.config.color,
|
||
"is_lead": e.config.name == lead.config.name,
|
||
"bound_skills": list(e.config.bound_skills),
|
||
"status": "active",
|
||
}
|
||
for e in self._team.active_experts
|
||
],
|
||
"plan_phases": [],
|
||
},
|
||
)
|
||
|
||
# 2. Set PLANNING status, Lead decomposes task into phases
|
||
self._team.set_status(TeamStatus.PLANNING)
|
||
phases = await self._decompose_task(lead, task)
|
||
if not phases:
|
||
logger.warning("Task decomposition returned no phases, executing as single phase")
|
||
phases = [
|
||
PlanPhase(name="执行", assigned_expert=lead.config.name, task_description=task)
|
||
]
|
||
|
||
plan.phases = phases[: self.MAX_PHASES]
|
||
|
||
# U3: Optionally add plan review debate before execution
|
||
await self._maybe_add_plan_review_debate(lead, plan, task)
|
||
|
||
# 3. Emit plan_update with phase list
|
||
await self._broadcast_event(
|
||
"plan_update",
|
||
{
|
||
"plan_id": plan.id,
|
||
"plan_phases": [ph.to_dict() for ph in plan.phases],
|
||
},
|
||
)
|
||
|
||
# U7: Save plan for potential resume (before execution starts)
|
||
if self._checkpoint is not None:
|
||
try:
|
||
await self._checkpoint.save_plan(plan)
|
||
except Exception as e:
|
||
logger.warning(f"Checkpoint save_plan failed: {e}")
|
||
|
||
# 4. Set EXECUTING status, execute phases
|
||
self._team.set_status(TeamStatus.EXECUTING)
|
||
phase_results: dict[str, dict[str, Any]] = {}
|
||
|
||
return await self._run_pipeline(lead, plan, phase_results, task)
|
||
|
||
async def _run_pipeline(
|
||
self,
|
||
lead: Expert,
|
||
plan: TeamPlan,
|
||
phase_results: dict[str, dict[str, Any]],
|
||
task: str,
|
||
) -> dict[str, Any]:
|
||
"""Execute the pipeline loop: run pending phases, synthesize, return result.
|
||
|
||
Shared by execute() and resume(). phase_results may be pre-populated
|
||
by resume() with completed phase outputs.
|
||
"""
|
||
try:
|
||
# Execute layers sequentially, phases within layer in parallel.
|
||
# U3: while-loop re-computes topological_sort each iteration so
|
||
# dynamically inserted DEBATE phases (from divergence detection)
|
||
# are picked up correctly.
|
||
while True:
|
||
layers = plan.topological_sort()
|
||
# Find the next layer that still has PENDING phases
|
||
current_layer: list[PlanPhase] | None = None
|
||
for layer in layers:
|
||
if any(ph.status == PhaseStatus.PENDING for ph in layer):
|
||
current_layer = layer
|
||
break
|
||
if current_layer is None:
|
||
break # No more pending phases — done
|
||
|
||
ready = [ph for ph in current_layer if ph.status == PhaseStatus.PENDING]
|
||
if not ready:
|
||
continue
|
||
|
||
# U4: Process user interventions at phase boundary.
|
||
# /stop → terminate execution; /debate <topic> → insert DEBATE;
|
||
# plain text → accumulate as user context for Lead synthesis.
|
||
stop_requested = await self._process_interventions(lead, plan)
|
||
if stop_requested:
|
||
logger.info("Execution stopped by user intervention")
|
||
break
|
||
|
||
# Execute all phases in this layer in parallel (with concurrency limit)
|
||
async def _bounded_phase(ph: PlanPhase) -> dict[str, Any]:
|
||
async with self._phase_semaphore:
|
||
return await self._execute_phase(ph, plan)
|
||
|
||
results = await asyncio.gather(
|
||
*[_bounded_phase(ph) for ph in ready],
|
||
return_exceptions=True,
|
||
)
|
||
|
||
for ph, result in zip(ready, results):
|
||
if isinstance(result, (Exception, asyncio.CancelledError)):
|
||
logger.error(f"Phase {ph.id} ({ph.name}) failed: {result}")
|
||
plan.update_phase_status(ph.id, PhaseStatus.FAILED, {"error": str(result)})
|
||
phase_results[ph.id] = {"error": str(result)}
|
||
# Emit phase_failed event
|
||
await self._broadcast_event(
|
||
"phase_failed",
|
||
{
|
||
"phase_id": ph.id,
|
||
"phase_name": ph.name,
|
||
"error": str(result),
|
||
},
|
||
)
|
||
# Mark dependent phases as failed
|
||
await self._mark_dependents_failed(ph.id, plan, phase_results)
|
||
else:
|
||
phase_results[ph.id] = result
|
||
|
||
# G9/U4: opt-in rollback (KTD6) + checkpoint ordering (R21).
|
||
# When phase configures both validation_command and rollback_command:
|
||
# 1. run validation_command — if it passes, treat phase as recoverable, save checkpoint
|
||
# 2. if validation fails, run rollback_command
|
||
# 3. if rollback passes (exit 0), save checkpoint
|
||
# 4. if rollback fails, skip checkpoint (R21 — avoid persisting broken state)
|
||
# When neither command is set, behavior is unchanged (existing save).
|
||
should_save_checkpoint = True
|
||
if (
|
||
ph.validation_command
|
||
and ph.rollback_command
|
||
and isinstance(result, (Exception, asyncio.CancelledError))
|
||
):
|
||
should_save_checkpoint = await self._run_phase_rollback(plan, ph)
|
||
|
||
# U7: Save checkpoint after phase finalizes (success or failure)
|
||
if should_save_checkpoint and self._checkpoint is not None:
|
||
try:
|
||
await self._checkpoint.save(plan.id, ph, plan.status.value)
|
||
except Exception as e:
|
||
logger.warning(f"Checkpoint save failed for phase {ph.id}: {e}")
|
||
|
||
# U3: Divergence detection — check completed phases for conflicts
|
||
# and dynamically insert DEBATE phases if needed
|
||
if self._debate_count < self.MAX_DEBATES:
|
||
completed_now = [ph for ph in ready if ph.status == PhaseStatus.COMPLETED]
|
||
if completed_now:
|
||
await self._check_divergence_and_insert_debates(lead, plan, completed_now)
|
||
|
||
# 5. Check if all phases failed
|
||
completed = plan.completed_phases
|
||
if not completed:
|
||
logger.warning("All phases failed, falling back to single agent")
|
||
return await self._fallback_to_single_agent(task, plan, phase_results)
|
||
|
||
# 6. Lead Expert synthesizes results (BEST strategy)
|
||
self._team.set_status(TeamStatus.SYNTHESIZING)
|
||
plan.status = PlanStatus.COMPLETED
|
||
|
||
final_result = await self._synthesize_results(lead, task, completed)
|
||
|
||
self._team.set_status(TeamStatus.COMPLETED)
|
||
|
||
# 7. Emit team_synthesis event
|
||
await self._broadcast_event(
|
||
"team_synthesis",
|
||
{
|
||
"content": final_result.get("content", ""),
|
||
"phases_completed": len(completed),
|
||
"phases_total": len(plan.phases),
|
||
},
|
||
)
|
||
|
||
# 8. Emit team_dissolved event
|
||
await self._broadcast_event(
|
||
"team_dissolved",
|
||
{"team_id": self._team.team_id},
|
||
)
|
||
|
||
# P2 #13: Clean up checkpoints after successful completion
|
||
if self._checkpoint is not None:
|
||
try:
|
||
await self._checkpoint.clear(plan.id)
|
||
except Exception as e:
|
||
logger.warning(f"Checkpoint clear failed: {e}")
|
||
|
||
return {
|
||
"status": "completed",
|
||
"result": final_result,
|
||
"phase_results": phase_results,
|
||
"plan": plan,
|
||
}
|
||
|
||
except ValueError as e:
|
||
# Circular dependency or invalid reference from topological_sort
|
||
logger.error(f"Pipeline execution failed (invalid plan): {e}")
|
||
plan.status = PlanStatus.FAILED
|
||
await self._broadcast_event("team_dissolved", {"team_id": self._team.team_id})
|
||
return await self._fallback_to_single_agent(task, plan, phase_results)
|
||
except Exception as e:
|
||
logger.error(f"Pipeline execution failed: {e}")
|
||
plan.status = PlanStatus.FAILED
|
||
await self._broadcast_event("team_dissolved", {"team_id": self._team.team_id})
|
||
return await self._fallback_to_single_agent(task, plan, phase_results)
|
||
|
||
async def resume(self, plan_id: str) -> dict[str, Any]:
|
||
"""Resume from last checkpoint: load plan, restore completed/failed phases,
|
||
continue via _run_pipeline. Returns same dict shape as execute()."""
|
||
if self._checkpoint is None:
|
||
return {
|
||
"status": "failed",
|
||
"result": None,
|
||
"phase_results": {},
|
||
"error": "No checkpoint manager configured",
|
||
}
|
||
|
||
# 1. Load plan
|
||
plan_dict = await self._checkpoint.load_plan(plan_id)
|
||
if plan_dict is None:
|
||
return {
|
||
"status": "failed",
|
||
"result": None,
|
||
"phase_results": {},
|
||
"error": f"No checkpoint found for plan '{plan_id}'",
|
||
}
|
||
|
||
# 2. Reconstruct TeamPlan
|
||
plan = TeamPlan.from_dict(plan_dict)
|
||
task = plan.task
|
||
|
||
# 3. Load checkpoints, mark completed phases
|
||
checkpoints = await self._checkpoint.list_checkpoints(plan_id)
|
||
phase_results: dict[str, dict[str, Any]] = {}
|
||
completed_phase_ids: set[str] = set()
|
||
failed_phase_ids: set[str] = set()
|
||
|
||
for cp in checkpoints:
|
||
if cp.phase_status == "completed":
|
||
completed_phase_ids.add(cp.phase_id)
|
||
# Restore phase result from checkpoint
|
||
if cp.phase_result:
|
||
phase_results[cp.phase_id] = cp.phase_result
|
||
elif cp.phase_status == "failed":
|
||
# P2 #11: Restore FAILED status so they aren't re-executed
|
||
failed_phase_ids.add(cp.phase_id)
|
||
|
||
# Apply checkpoint state to plan phases
|
||
for ph in plan.phases:
|
||
if ph.id in completed_phase_ids:
|
||
ph.status = PhaseStatus.COMPLETED
|
||
if ph.id in phase_results and phase_results[ph.id]:
|
||
ph.result = phase_results[ph.id]
|
||
elif ph.id in failed_phase_ids:
|
||
ph.status = PhaseStatus.FAILED
|
||
# PENDING phases remain PENDING — will be executed by _run_pipeline
|
||
|
||
# P2 #8: Restore debate count so MAX_DEBATES limit holds after resume
|
||
self._debate_count = sum(1 for ph in plan.phases if ph.phase_type == PhaseType.DEBATE)
|
||
|
||
logger.info(
|
||
f"Resuming plan {plan_id}: {len(completed_phase_ids)} completed, "
|
||
f"{len(failed_phase_ids)} failed, "
|
||
f"{len(plan.phases) - len(completed_phase_ids) - len(failed_phase_ids)} pending"
|
||
)
|
||
|
||
# 4. Get lead expert
|
||
lead = self._team.lead_expert
|
||
if not lead or not lead.is_active:
|
||
active = self._team.active_experts
|
||
if not active:
|
||
return {
|
||
"status": "failed",
|
||
"result": None,
|
||
"phase_results": phase_results,
|
||
"error": "No active expert available",
|
||
}
|
||
lead = active[0]
|
||
|
||
# 5. Resume execution
|
||
self._team.set_status(TeamStatus.EXECUTING)
|
||
return await self._run_pipeline(lead, plan, phase_results, task)
|
||
|
||
async def _decompose_task(self, lead: Expert, task: str) -> list[PlanPhase]:
|
||
"""Lead Expert decomposes task into phases using LLM.
|
||
|
||
Returns a list of PlanPhase instances. If LLM decomposition fails,
|
||
returns a single phase with the original task.
|
||
"""
|
||
gateway = self._get_llm_gateway(lead)
|
||
if not gateway:
|
||
logger.warning("No LLM gateway available, treating task as single phase")
|
||
return [PlanPhase(name="执行", assigned_expert=lead.config.name, task_description=task)]
|
||
|
||
member_names = [
|
||
e.config.name for e in self._team.active_experts if e.config.name != lead.config.name
|
||
]
|
||
available_experts = member_names if member_names else [lead.config.name]
|
||
|
||
prompt = (
|
||
f"You are the Lead Expert in a pipeline team. Decompose the following task into "
|
||
f"at most {self.MAX_PHASES} phases with dependencies.\n\n"
|
||
f"Task: {task}\n\n"
|
||
f"Available experts: {', '.join(available_experts)}\n\n"
|
||
f"Return a JSON array of phase objects, each with:\n"
|
||
f'- "name": phase name (e.g., "规划", "前端", "后端", "QA", "评审")\n'
|
||
f'- "assigned_expert": name of the expert to assign '
|
||
f"(must be one of: {', '.join(available_experts)})\n"
|
||
f'- "task_description": clear phase task description\n'
|
||
f'- "depends_on": array of phase names this phase depends on (empty array if none)\n'
|
||
f'- "collaboration_contracts": 数组,定义该阶段的协作契约,每个契约包含:\n'
|
||
f' - "from_expert": 提供内容的专家名称\n'
|
||
f' - "to_expert": 接收内容的专家名称\n'
|
||
f' - "content_description": 协作内容描述\n'
|
||
f' 例如:[{{"from_expert":"backend","to_expert":"frontend",'
|
||
f'"content_description":"API 定义"}}]\n\n'
|
||
f"Example:\n"
|
||
f'[{{"name":"规划","assigned_expert":"tech_lead",'
|
||
f'"task_description":"设计架构","depends_on":[],"collaboration_contracts":[]}},'
|
||
f'{{"name":"后端","assigned_expert":"backend",'
|
||
f'"task_description":"实现API","depends_on":["规划"],'
|
||
f'"collaboration_contracts":[{{"from_expert":"backend",'
|
||
f'"to_expert":"frontend","content_description":"API 定义"}}]}},'
|
||
f'{{"name":"前端","assigned_expert":"frontend",'
|
||
f'"task_description":"实现UI","depends_on":["后端"],"collaboration_contracts":[]}}]\n\n'
|
||
f"Return ONLY the JSON array, no other text."
|
||
)
|
||
|
||
try:
|
||
response = await gateway.chat(
|
||
messages=[{"role": "user", "content": prompt}],
|
||
model=self._get_model(lead),
|
||
)
|
||
phases = self._parse_phases(response.content, available_experts, lead.config.name)
|
||
if phases:
|
||
return phases
|
||
logger.warning("LLM decomposition returned no valid phases")
|
||
except Exception as e:
|
||
logger.warning(f"LLM task decomposition failed: {e}")
|
||
|
||
return [PlanPhase(name="执行", assigned_expert=lead.config.name, task_description=task)]
|
||
|
||
@staticmethod
|
||
def _parse_phases(
|
||
content: str, available_experts: list[str], lead_name: str
|
||
) -> list[PlanPhase]:
|
||
"""Parse LLM response into PlanPhase list. Extracts JSON array, resolves
|
||
depends_on names→IDs, validates assigned_expert."""
|
||
# Try to extract JSON array from the response
|
||
json_match = re.search(r"\[.*\]", content, re.DOTALL)
|
||
if not json_match:
|
||
return []
|
||
|
||
try:
|
||
items = json.loads(json_match.group(0))
|
||
except json.JSONDecodeError:
|
||
return []
|
||
|
||
if not isinstance(items, list):
|
||
return []
|
||
|
||
# First pass: create phases with IDs, build name->id mapping
|
||
name_to_id: dict[str, str] = {}
|
||
raw_phases: list[dict[str, Any]] = []
|
||
|
||
for item in items:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
name = item.get("name", "").strip()
|
||
if not name:
|
||
continue
|
||
assigned = item.get("assigned_expert", "").strip()
|
||
# Validate assigned expert; fall back to lead if invalid
|
||
if assigned not in available_experts:
|
||
assigned = lead_name
|
||
task_desc = item.get("task_description", "").strip() or name
|
||
depends_on_names = item.get("depends_on", [])
|
||
if not isinstance(depends_on_names, list):
|
||
depends_on_names = []
|
||
|
||
# 解析协作契约(LLM 返回格式不正确时优雅降级为空列表)
|
||
contracts_data = item.get("collaboration_contracts", [])
|
||
if not isinstance(contracts_data, list):
|
||
contracts_data = []
|
||
contracts: list[CollaborationContract] = []
|
||
for c in contracts_data:
|
||
if not isinstance(c, dict):
|
||
contracts.append(CollaborationContract())
|
||
continue
|
||
contract = CollaborationContract.from_dict(c)
|
||
# P1: 校验契约字段 — from_expert/to_expert 必须符合专家名规范
|
||
# 不合法则清空,避免注入或引用不存在的专家
|
||
if contract.from_expert and not _EXPERT_NAME_RE.match(contract.from_expert):
|
||
logger.warning(
|
||
f"Invalid from_expert '{contract.from_expert}' in contract, clearing"
|
||
)
|
||
contract.from_expert = ""
|
||
if contract.to_expert and not _EXPERT_NAME_RE.match(contract.to_expert):
|
||
logger.warning(
|
||
f"Invalid to_expert '{contract.to_expert}' in contract, clearing"
|
||
)
|
||
contract.to_expert = ""
|
||
contracts.append(contract)
|
||
|
||
phase = PlanPhase(
|
||
name=name,
|
||
assigned_expert=assigned,
|
||
task_description=task_desc,
|
||
depends_on=[], # Will resolve to IDs in second pass
|
||
collaboration_contracts=contracts,
|
||
)
|
||
raw_phases.append({"phase": phase, "depends_on_names": depends_on_names})
|
||
name_to_id[name] = phase.id
|
||
|
||
# Second pass: resolve depends_on from names to IDs
|
||
phases: list[PlanPhase] = []
|
||
for entry in raw_phases:
|
||
phase = entry["phase"]
|
||
for dep_name in entry["depends_on_names"]:
|
||
dep_id = name_to_id.get(dep_name)
|
||
if dep_id:
|
||
phase.depends_on.append(dep_id)
|
||
else:
|
||
logger.warning(
|
||
f"Phase '{phase.name}' depends on unknown phase '{dep_name}', ignoring"
|
||
)
|
||
phases.append(phase)
|
||
|
||
return phases
|
||
|
||
def _get_model(self, expert: Expert | None = None) -> str:
|
||
"""Get LLM model name from expert.config.llm, fallback to "default"."""
|
||
target = expert or self._team.lead_expert
|
||
if target and target.config.llm:
|
||
return target.config.llm.get("model", "default")
|
||
return "default"
|
||
|
||
def _get_llm_gateway(self, expert: Expert | None = None) -> LLMGateway | None:
|
||
"""Get LLM gateway from the given expert or the lead expert's agent.
|
||
|
||
Falls back to other active experts if the primary target has no gateway.
|
||
"""
|
||
target = expert or self._team.lead_expert
|
||
if target and hasattr(target, "agent") and hasattr(target.agent, "_llm_gateway"):
|
||
gateway = target.agent._llm_gateway
|
||
if gateway is not None:
|
||
return gateway
|
||
# Fallback: try first active expert with a gateway
|
||
for exp in self._team.active_experts:
|
||
if hasattr(exp, "agent") and hasattr(exp.agent, "_llm_gateway"):
|
||
gateway = exp.agent._llm_gateway
|
||
if gateway is not None:
|
||
return gateway
|
||
return None
|
||
|
||
async def _broadcast_event(self, event_type: str, data: dict[str, Any]) -> None:
|
||
"""Broadcast an orchestration event to the team channel via handoff_transport."""
|
||
if self._team.handoff_transport:
|
||
try:
|
||
await self._team.handoff_transport.send(
|
||
self._team.team_channel, {"type": event_type, **data}
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"Failed to broadcast event '{event_type}': {e}")
|