Files
beaver_project/app-instance/backend/nanobot/agent_team/target_resolver.py
steven_li cdfc222c9f feat: 添加swarms团队编排功能并优化agent委派系统
- 引入AgentTeamOrchestrator支持多agent协同任务执行
- 增加第三方swarms库依赖并配置git协议替换以改善包管理
- 扩展DelegationManager支持团队任务调度和进度跟踪
- 实现中文bigram分词算法提升中文任务检索准确性
- 调整A2AClient和DelegationManager超时时间从30秒增至600秒
- 优化AgentRunResult状态判断逻辑增加有意义摘要检测
- 修改Dockerfile配置npm仓库镜像地址和git协议映射
- 更新CLI命令行接口支持网关端口配置传递
- 调整提供者超时配置机制增强请求稳定性
- 移除过时的support_group字段简化agent描述符结构
- 增强错误处理和进度事件报告机制改进用户体验
2026-04-14 14:34:23 +08:00

268 lines
12 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.

"""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 直接选择最合适的已有 agentLLM 选不出来时才 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