- 引入AgentTeamOrchestrator支持多agent协同任务执行 - 增加第三方swarms库依赖并配置git协议替换以改善包管理 - 扩展DelegationManager支持团队任务调度和进度跟踪 - 实现中文bigram分词算法提升中文任务检索准确性 - 调整A2AClient和DelegationManager超时时间从30秒增至600秒 - 优化AgentRunResult状态判断逻辑增加有意义摘要检测 - 修改Dockerfile配置npm仓库镜像地址和git协议映射 - 更新CLI命令行接口支持网关端口配置传递 - 调整提供者超时配置机制增强请求稳定性 - 移除过时的support_group字段简化agent描述符结构 - 增强错误处理和进度事件报告机制改进用户体验
268 lines
12 KiB
Python
268 lines
12 KiB
Python
"""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
|