"""Agent 路由器 — 决策层(司马懿建议 1:Router/Dispatcher 分层) 三种路由模式: Mode A: LLM 路由(中心化)— 首次分配、异常升级 Mode B: Agent 声明式交接(去中心化)— 执行者声明下一步需要什么 Mode C: Agent 自主领活 — 未来演进 Router 只做决策,不做 spawn。返回 RouteDecision。 """ from __future__ import annotations import asyncio import json import logging import time from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Set logger = logging.getLogger("moziplus-v2.router") @dataclass class RouteDecision: """路由决策结果""" agent_id: str reason: str mode: str # "deterministic" | "agent_handoff" | "llm_route" | "fallback" confidence: float = 1.0 model: Optional[str] = None # Mode A 时记录使用的模型 latency_ms: int = 0 @dataclass class AgentProfile: """Agent 能力画像""" agent_id: str capabilities: List[str] = field(default_factory=list) can_review: bool = False max_concurrent: int = 1 is_fallback: bool = False # 已知 capability 白名单(司马懿 BUG-2:next_capability 校验) KNOWN_CAPABILITIES = frozenset({ "coding", "implementation", "scripting", "review", "quality_check", "debate", "deploy", "infrastructure", "docker", "vnpy", "risk", "compliance", "position_check", "data", "acquisition", "cleaning", "verification", "planning", "coordination", "escalation", "strategy", }) class LLMDriver: """Mode A: bMAS Control Unit — 轻量 LLM 路由决策 一次 LLM API 调用,~300 token prompt,~1-2s,返回路由决策。 不是 spawn Agent session,是 API 调用。 """ def __init__(self, model: str, api_base: str = "", api_key: str = "", timeout: float = 5.0, max_tokens: int = 200, temperature: float = 0.1): self.model = model self.api_base = api_base self.api_key = api_key self.timeout = timeout self.max_tokens = max_tokens self.temperature = temperature self._client = None def _get_client(self): """延迟初始化 OpenAI client""" if self._client is not None: return self._client try: from openai import OpenAI kwargs = {} if self.api_base: kwargs["base_url"] = self.api_base if self.api_key: kwargs["api_key"] = self.api_key # 如果都没配,走 OpenClaw Gateway 的默认环境变量 self._client = OpenAI(**kwargs) if kwargs else OpenAI() except ImportError: logger.warning("openai package not installed, LLM routing disabled") self._client = None return self._client def route(self, task_info: Dict[str, Any], agent_profiles: Dict[str, AgentProfile], active_agents: Dict[str, int]) -> RouteDecision: """LLM 路由决策""" start = time.monotonic() client = self._get_client() if client is None: return RouteDecision( agent_id="pangtong-fujunshi", reason="LLM driver unavailable, fallback", mode="fallback", confidence=0.0, ) prompt = self._build_prompt(task_info, agent_profiles, active_agents) try: response = client.chat.completions.create( model=self.model, messages=[{"role": "user", "content": prompt}], response_format={"type": "json_object"}, max_tokens=self.max_tokens, temperature=self.temperature, timeout=self.timeout, ) raw = response.choices[0].message.content result = json.loads(raw) latency = int((time.monotonic() - start) * 1000) agent_id = result.get("agent_id", "") reason = result.get("reason", "LLM routing") confidence = float(result.get("confidence", 0.5)) return RouteDecision( agent_id=agent_id, reason=reason, mode="llm_route", confidence=confidence, model=self.model, latency_ms=latency, ) except json.JSONDecodeError: latency = int((time.monotonic() - start) * 1000) return RouteDecision( agent_id="pangtong-fujunshi", reason="LLM returned non-JSON, fallback", mode="fallback", confidence=0.0, model=self.model, latency_ms=latency, ) except Exception as e: latency = int((time.monotonic() - start) * 1000) logger.warning("LLM routing failed: %s", e) return RouteDecision( agent_id="pangtong-fujunshi", reason=f"LLM routing error: {e}, fallback", mode="fallback", confidence=0.0, model=self.model, latency_ms=latency, ) def _build_prompt(self, task_info: Dict[str, Any], agent_profiles: Dict[str, AgentProfile], active_agents: Dict[str, int]) -> str: """构建路由 prompt""" agents_block = [] for aid, prof in agent_profiles.items(): load = active_agents.get(aid, 0) agents_block.append( f"- {aid}: capabilities={prof.capabilities}, " f"can_review={prof.can_review}, load={load}" ) agents_str = "\n".join(agents_block) return f"""你是任务路由器。根据任务需求和 Agent 能力,选择最合适的 Agent。 ## 当前任务 - ID: {task_info.get('id', '')} - 标题: {task_info.get('title', '')} - 状态: {task_info.get('status', '')} - 类型: {task_info.get('task_type', '')} - 描述: {task_info.get('description', '')} - 当前执行者: {task_info.get('current_agent', '无')} ## 可用 Agent {agents_str} ## 约束 1. review/quality_check 不能选当前执行者 2. 同等能力优先选负载最低的 3. 必须匹配任务所需能力 4. agent_id 必须是上面列出的 Agent 之一 ## 输出(JSON) {{"agent_id": "...", "reason": "...", "confidence": 0.0-1.0}}""" class AgentRouter: """Agent 路由器 — 决策层 三种路由模式按场景自动选择: - 确定性快速路径:机械检查、retry、已知 assignee - Mode B(Agent Handoff):执行者声明 next_capability - Mode A(LLM 路由):首次分配、异常场景 """ # 确定性 action type(不需要路由) LOCAL_ACTIONS = frozenset({ "L1_guardrail", "format_check", "file_exists_check", "dependency_advance", }) # 生命周期流转的状态-能力映射 LIFECYCLE_CAPABILITY = { "review": {"capability": "review", "exclude_current": True}, "failed": {"capability": "escalation", "exclude_current": False}, } FALLBACK_AGENT = "pangtong-fujunshi" def __init__( self, agent_profiles: Optional[Dict[str, AgentProfile]] = None, llm_driver: Optional[LLMDriver] = None, counter: Optional[Any] = None, ): self.agent_profiles = agent_profiles or {} self.llm_driver = llm_driver self.counter = counter # 构建 known capability 集合(从 profiles 中提取) self._known_capabilities: Set[str] = set() for prof in self.agent_profiles.values(): self._known_capabilities.update(prof.capabilities) def route(self, task_info: Dict[str, Any], action_type: str = "") -> RouteDecision: """路由决策入口 Args: task_info: 任务信息字典(需含 id, title, status, assignee, current_agent, next_capability, task_type, description) action_type: 动作类型 Returns: RouteDecision """ start = time.monotonic() # ── 快速路径 1: 本地执行 ── if action_type in self.LOCAL_ACTIONS: return RouteDecision( agent_id="daemon", reason=f"Local action: {action_type}", mode="deterministic", latency_ms=int((time.monotonic() - start) * 1000), ) # ── 快速路径 2: retry → 原执行者 ── if action_type == "retry": current = task_info.get("current_agent") or task_info.get("assignee") if current and current in self.agent_profiles: return RouteDecision( agent_id=current, reason="Retry original executor", mode="deterministic", latency_ms=int((time.monotonic() - start) * 1000), ) # ── Mode B: Agent 声明式交接 ── next_cap = task_info.get("next_capability") if next_cap and self._validate_capability(next_cap): current = task_info.get("current_agent") or task_info.get("assignee") exclude = {current} if current else set() matched = self._match_capability(next_cap, exclude) if matched: return RouteDecision( agent_id=matched, reason=f"Agent handoff: needs {next_cap}", mode="agent_handoff", latency_ms=int((time.monotonic() - start) * 1000), ) # next_capability 合法但无匹配 → 降级 Mode A logger.info("next_capability '%s' no match, falling back to LLM", next_cap) # ── 快速路径 3: 生命周期流转查表 ── lifecycle = self.LIFECYCLE_CAPABILITY.get(action_type) if not lifecycle: # review 以外的 action 也可能走生命周期 lifecycle = self.LIFECYCLE_CAPABILITY.get(task_info.get("status")) if lifecycle: cap = lifecycle["capability"] exclude_current = lifecycle.get("exclude_current", False) exclude = set() if exclude_current: current = task_info.get("current_agent") or task_info.get("assignee") if current: exclude.add(current) matched = self._match_capability(cap, exclude) if matched: return RouteDecision( agent_id=matched, reason=f"Lifecycle: {action_type or task_info.get('status')} needs {cap}", mode="deterministic", latency_ms=int((time.monotonic() - start) * 1000), ) # ── 快速路径 4: 有 assignee 且非生命周期流转 ── assignee = task_info.get("assignee") if assignee and assignee in self.agent_profiles and action_type not in ("review", "escalation"): return RouteDecision( agent_id=assignee, reason=f"Direct assignee: {assignee}", mode="deterministic", latency_ms=int((time.monotonic() - start) * 1000), ) # ── Mode A: LLM 路由 ── if self.llm_driver: decision = self.llm_driver.route(task_info, self.agent_profiles, self._get_active_agents()) # 合法性校验 if decision.agent_id not in self.agent_profiles: latency = decision.latency_ms or int((time.monotonic() - start) * 1000) return RouteDecision( agent_id=self.FALLBACK_AGENT, reason=f"LLM returned invalid agent '{decision.agent_id}', fallback", mode="fallback", confidence=0.0, model=decision.model, latency_ms=latency, ) if decision.confidence < 0.7: latency = decision.latency_ms or int((time.monotonic() - start) * 1000) return RouteDecision( agent_id=self.FALLBACK_AGENT, reason=f"LLM low confidence ({decision.confidence}): {decision.reason}", mode="fallback", confidence=decision.confidence, model=decision.model, latency_ms=latency, ) # 补充 latency if not decision.latency_ms: decision.latency_ms = int((time.monotonic() - start) * 1000) return decision # ── 最终 fallback ── return RouteDecision( agent_id=self.FALLBACK_AGENT, reason="No routing mode available, fallback to coordinator", mode="fallback", latency_ms=int((time.monotonic() - start) * 1000), ) def _validate_capability(self, capability: str) -> bool: """BUG-2 修复:校验 next_capability 在已知集合内""" return capability in self._known_capabilities def _match_capability(self, capability: str, exclude: Optional[Set[str]] = None) -> Optional[str]: """从能力画像中匹配 Agent""" candidates = [] for aid, prof in self.agent_profiles.items(): if aid in (exclude or set()): continue if capability in prof.capabilities: candidates.append(aid) if not candidates: return None if len(candidates) == 1: return candidates[0] # 多候选:选负载最低的 if self.counter: return min(candidates, key=lambda a: self.counter.active_agents.get(a, 0)) return candidates[0] def _get_active_agents(self) -> Dict[str, int]: """获取当前活跃 Agent 负载""" if self.counter: return self.counter.active_agents return {}