"""Resolve and provision team targets before execution. 该模块负责在真正启动 agent-team / swarms 执行前,把“任务需要哪些角色” 转换成一组可执行的 agent id。它优先复用 registry 里已有的 agent;当没有合适 agent 覆盖某个角色时,再通过 ProvisioningManager 在本地创建 A2A specialist。 """ from __future__ import annotations from pathlib import Path from loguru import logger from nanobot.agent.agent_registry import AgentDescriptor, AgentRegistry from nanobot.agent_team.provisioning import ProvisioningManager from nanobot.agent_team.types import ResolvedTeamPlan from nanobot.providers.base import LLMProvider class TargetResolver: """把任务级的 specialist 需求解析成最终可执行的 agent id 列表。 解析策略分两层: 1. 先读取当前 registry 里所有可见 agent,并过滤掉 router/planner 等 不适合作为群聊工作成员的 agent。 2. 如果调用方明确给出 required_specialists,则把 role 和候选 agent 交给 LLM 直接选择最合适的已有 agent;LLM 选不出来时才 provision 本地 specialist。没有明确角色时,则直接使用过滤后的已有 agent;若为空再 兜底创建 general specialist。 """ def __init__( self, *, workspace: Path, registry: AgentRegistry, provider: LLMProvider, model: str | None = None, max_parallel_agents: int = 16, gateway_port: int = 18790, provisioning: ProvisioningManager | None = None, ) -> None: # max_parallel_agents 同时限制“最多尝试的角色数”和“最终返回的 agent 数”, # 避免一次 team run 生成过多并行成员。 self.workspace = workspace self.registry = registry self.provider = provider self.model = model or provider.get_default_model() self.max_parallel_agents = max(1, max_parallel_agents) self.provisioning = provisioning or ProvisioningManager(workspace, gateway_port=gateway_port) async def resolve_team_targets( self, *, task: str, skills: list[str] | None = None, required_specialists: list[str] | None = None, ) -> ResolvedTeamPlan: """解析一次 team run 的目标 agent。 Args: task: 用户原始任务,用于 LLM 选 agent 和 specialist provision prompt。 skills: 本次任务要求携带的技能列表,会传给新 provision 的 specialist。 required_specialists: 上游 planner 推导出的角色需求。例如来自 AutoSwarmBuilder config 的 agent description,或 skills 的简单映射。 Returns: ResolvedTeamPlan: 包含已复用 agent、已 provision agent、最终执行目标、 选择理由和审计 metadata。 """ # 清理空字符串/空白角色,避免后续创建出没有意义的 specialist。 required = [item for item in (required_specialists or []) if str(item).strip()] # 直接读取 registry 当前所有可见 agent,再过滤掉 router、planner、 # local-subagent 这类不适合作为 swarms/group worker 的 agent。 suggestions = [ agent for agent in self.registry.list_agents(include_local_fallback=False) if self._is_group_worker_candidate(agent) ] # selected: 从 registry 复用的已有 agent id。 # covered_roles: 哪些 required role 已经被已有 agent 覆盖,用于 metadata。 # provisioned: 为缺失角色新建/确保存在的本地 specialist id。 # created_provisioned: 本次 run 真正新建出来的 specialist id;后续自动清理只看它, # 避免把之前已经存在、只是被刷新/复用的 specialist 误删。 # actions: provision 审计记录,方便上层解释“为什么创建了某个 agent”。 selected: list[str] = [] covered_roles: list[str] = [] provisioned: list[str] = [] created_provisioned: list[str] = [] actions: list[dict[str, str]] = [] if required: # 调用方给出了明确角色时,不再做本地词法规则匹配,而是直接把 # role + task + 候选 agent 交给 LLM 判断最适合复用哪个已有 agent。 # 这里切片是为了遵守 max_parallel_agents 上限。 for role in required[: self.max_parallel_agents]: existing = await self._select_existing_for_role_with_llm( task=task, role=role, suggestions=suggestions, selected=selected, ) if existing is not None: selected.append(existing.id) covered_roles.append(role) continue provision_result = await self.provisioning.ensure_local_specialist_with_result( role=role, task=task, skills=skills or [], ) agent_id = provision_result.agent_id provisioned.append(agent_id) if provision_result.created: created_provisioned.append(agent_id) actions.append({ "action": "ensure_local_specialist", "role": role, "agent_id": agent_id, "created": str(provision_result.created).lower(), }) else: # 没有明确角色需求时,直接使用当前可见的已有 agent,最多取并行上限。 selected = [agent.id for agent in suggestions[: self.max_parallel_agents]] if not selected: # 当前 registry 没有可用 worker 时,创建一个通用 specialist 作为最低可执行兜底。 provision_result = await self.provisioning.ensure_local_specialist_with_result( role="general specialist", task=task, skills=skills or [], ) agent_id = provision_result.agent_id provisioned.append(agent_id) if provision_result.created: created_provisioned.append(agent_id) actions.append({ "action": "ensure_local_specialist", "role": "general specialist", "agent_id": agent_id, "created": str(provision_result.created).lower(), }) # 合并已有 agent 和新 provision 的 agent: # - dict.fromkeys 保留顺序并去重,避免同一个 agent 被重复加入; # - 最后再次截断,防止 selected + provisioned 总数超过并行上限。 final_targets = list(dict.fromkeys([*selected, *provisioned]))[: self.max_parallel_agents] # selection_reason 是给上层/日志展示的粗粒度解释,metadata 里会保留更细的明细。 reason = ( "已选择现有 registry agent。" if selected and not provisioned else "已选择现有 registry agent,并为缺失角色补充了 specialist。" if selected and provisioned else "没有匹配到合适的现有 agent,已补充本地 A2A specialist。" if provisioned else "没有匹配到合适的现有 agent,且未补充任何 specialist。" ) logger.info( "Resolved agent-team targets selected={} provisioned={} final={}", selected, provisioned, final_targets, ) # ResolvedTeamPlan 是后续 orchestrator/swarms planner 使用的稳定边界: # final_targets 用于实际执行,selected/provisioned/actions/metadata 用于解释和调试。 return ResolvedTeamPlan( selected_existing_targets=selected, provisioned_targets=provisioned, created_provisioned_targets=created_provisioned, final_targets=final_targets, selection_reason=reason, provision_actions=actions, metadata={ "required_specialists": required, "available_agent_count": len(suggestions), "covered_roles": covered_roles, "created_provisioned_targets": created_provisioned, "max_parallel_agents": self.max_parallel_agents, }, ) @staticmethod def _is_group_worker_candidate(agent: AgentDescriptor) -> bool: """判断一个 registry agent 是否适合作为 team/group worker。 router/planner 类 agent 通常负责调度,不应被当作普通成员加入 GroupChat 或 swarms worker 列表;local-subagent 是通用本地代理入口,也避免在这里重复选中。 """ probe = " ".join([ agent.id, agent.name, agent.description, " ".join(agent.tags), " ".join(agent.aliases), ]).lower() if agent.id == "local-subagent": return False return not any(marker in probe for marker in ("chat-router", "router", "planner")) async def _select_existing_for_role_with_llm( self, *, task: str, role: str, suggestions: list[AgentDescriptor], selected: list[str], ) -> AgentDescriptor | None: """让 LLM 从已有候选 agent 中为 role 选择最合适的一个。""" candidates = [agent for agent in suggestions if agent.id not in selected] if not candidates: return None if len(candidates) == 1: return candidates[0] lines = [] for agent in candidates: tags = ", ".join(agent.tags) if agent.tags else "none" aliases = ", ".join(agent.aliases) if agent.aliases else "none" lines.append( f"- id: {agent.id}\n" f" name: {agent.name}\n" f" description: {agent.description}\n" f" tags: {tags}\n" f" aliases: {aliases}" ) try: response = await self.provider.chat( messages=[ { "role": "system", "content": ( "You select one existing agent for a required team role.\n" "Return exactly one agent id from the candidate list, or NONE.\n" "Do not explain your reasoning." ), }, { "role": "user", "content": ( f"Task:\n{task}\n\n" f"Required role:\n{role}\n\n" "Candidates:\n" f"{chr(10).join(lines)}\n\n" "Return exactly one candidate id, or NONE if none of them clearly fits." ), }, ], model=self.model, temperature=0, max_tokens=32, ) except Exception as exc: logger.warning("LLM role selection failed for role '{}': {}", role, exc) return None raw = str(response.content or "").strip() choice = raw.splitlines()[0].strip().strip("`'\"") if raw else "" candidate_map = {agent.id: agent for agent in candidates} if choice in candidate_map: return candidate_map[choice] if choice.upper() not in {"", "NONE"}: logger.info("LLM role selection returned unknown agent id '{}' for role '{}'", choice, role) return None