186 lines
8.9 KiB
Python
186 lines
8.9 KiB
Python
"""Provision managed local A2A specialists for agent teams."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import os
|
||
import re
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from loguru import logger
|
||
|
||
from nanobot.agent.subagents import LocalSubagentStore, normalize_subagent_id
|
||
from nanobot.config.schema import Config
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SpecialistProvisionResult:
|
||
"""Result of ensuring a managed specialist exists."""
|
||
|
||
agent_id: str
|
||
created: bool
|
||
|
||
|
||
class ProvisioningManager:
|
||
"""Manage local specialists through LocalSubagentStore."""
|
||
|
||
def __init__(self, workspace: Path, *, gateway_port: int = 18790) -> None:
|
||
self.workspace = workspace
|
||
self.gateway_port = int(os.getenv("APP_BACKEND_PORT") or gateway_port)
|
||
self.store = LocalSubagentStore(workspace)
|
||
|
||
async def ensure_local_specialist_with_result(
|
||
self,
|
||
*,
|
||
role: str,
|
||
task: str,
|
||
skills: list[str] | None = None,
|
||
) -> SpecialistProvisionResult:
|
||
"""创建或刷新一个本地 specialist,并返回它是否是首次创建。"""
|
||
# role 可能来自上游 planner、用户输入或其他动态流程,这里先做兜底和规范化:
|
||
# 1. 空值时退回到通用角色 "general specialist"
|
||
# 2. 去掉首尾空白,避免生成不稳定的 agent 标识
|
||
# 这样可以保证后续 id、显示名、标签等字段都基于同一个干净的角色名生成。
|
||
role_name = str(role or "general specialist").strip() or "general specialist"
|
||
|
||
# agent_id 由“角色名 + 任务指纹”组成:
|
||
# - 同一角色处理同一任务时会命中同一个 id,从而实现刷新/复用
|
||
# - 同一角色处理不同任务时会得到不同 id,避免不同任务上下文互相污染
|
||
agent_id = self._specialist_id(role_name, task)
|
||
|
||
# display_name 主要用于人类可读展示;它不影响真正的唯一性,
|
||
# 唯一性仍由 agent_id 保证。
|
||
display_name = self._display_name(role_name)
|
||
|
||
# 为即将 upsert 的 subagent 构造运行时配置。
|
||
# 这里显式覆盖两个关键字段:
|
||
# - workspace:确保 specialist 和当前 agent team 运行在同一个工作目录
|
||
# - gateway.port:确保它连接到当前后端实例暴露的网关端口
|
||
# 这样新建/刷新出来的本地 specialist 才能在正确的环境里工作。
|
||
config = Config()
|
||
config.agents.defaults.workspace = str(self.workspace)
|
||
config.gateway.port = self.gateway_port
|
||
|
||
# payload 是写入 LocalSubagentStore 的完整声明式规格。
|
||
# store.upsert_subagent(...) 会根据这份规格创建或刷新 subagent。
|
||
payload = {
|
||
# 稳定唯一 id,用于判断“是否已存在”以及后续更新同一个 specialist。
|
||
"id": agent_id,
|
||
|
||
# 人类可读名称,便于在 UI、日志或调试信息中识别角色。
|
||
"name": display_name,
|
||
|
||
# 简短描述说明该 agent 的来源和用途:它是 agent team 自动托管的本地 A2A specialist。
|
||
"description": f"Managed local A2A specialist for {role_name}.",
|
||
|
||
# system_prompt 注入角色视角、原始任务以及本次要求携带的技能上下文,
|
||
# 是 specialist 实际行为边界和任务目标的核心输入。
|
||
"system_prompt": self._system_prompt(role_name, task, skills or []),
|
||
|
||
# 允许它进行完整委派;也就是说该 specialist 自己可以继续向下分派任务,
|
||
# 而不是被限制为只能本地直接回答。
|
||
"delegation_mode": "full",
|
||
|
||
# 允许访问 MCP,表示这个 specialist 在受外层权限控制的前提下可以使用 MCP 能力。
|
||
"allow_mcp": True,
|
||
|
||
# tags 用于分类、筛选和后续清理:
|
||
# - auto-provisioned / agent-team:标明它是系统自动创建的团队成员
|
||
# - role_name.replace(" ", "-"):保留一个角色维度标签,便于检索
|
||
# - skills:把本次技能要求也落到标签中,方便观测和调试
|
||
# 使用 set 去重、sorted 排序,保证结果稳定。
|
||
"tags": sorted(set(["auto-provisioned", "agent-team", role_name.replace(" ", "-")] + list(skills or []))),
|
||
|
||
# aliases 提供额外可匹配名称,既支持原始角色名,也支持格式化后的展示名。
|
||
"aliases": [role_name, display_name],
|
||
|
||
# metadata 存放程序消费的结构化信息:
|
||
# - managed_by:标记由哪个模块托管,后续 cleanup 时会用来判定是否允许删除
|
||
# - role:记录规范化后的角色名
|
||
# - task_fingerprint:记录任务指纹,便于追踪这个 specialist 绑定的是哪类任务上下文
|
||
"metadata": {
|
||
"managed_by": "agent_team_provisioning",
|
||
"role": role_name,
|
||
"task_fingerprint": self._fingerprint(task),
|
||
},
|
||
}
|
||
|
||
# 先读取一次已有记录,用于区分“首次创建”还是“刷新已有 specialist”。
|
||
# 注意:真正的写入动作由后面的 upsert 完成。
|
||
existing = self.store.get_subagent(agent_id)
|
||
|
||
# upsert 语义是:
|
||
# - 不存在则创建
|
||
# - 已存在则按新的 payload/config 刷新
|
||
# 这样调用方不需要区分 create / update 两条路径。
|
||
spec = self.store.upsert_subagent(payload, config)
|
||
|
||
# 日志区分 provisioned 和 refreshed,便于排查:
|
||
# - 为什么这次新建了一个 specialist
|
||
# - 或者为什么只是把旧的配置重新覆盖了一次
|
||
if existing is None:
|
||
logger.info("Provisioned local A2A specialist {} for role '{}'", spec.id, role_name)
|
||
else:
|
||
logger.info("Refreshed local A2A specialist {} for role '{}'", spec.id, role_name)
|
||
|
||
# 返回两类关键信息:
|
||
# - agent_id:供上游继续引用这个 specialist
|
||
# - created:明确告知这次是首次创建,还是命中了已有对象并完成刷新
|
||
return SpecialistProvisionResult(agent_id=spec.id, created=existing is None)
|
||
|
||
def cleanup_local_specialists(self, agent_ids: list[str]) -> list[str]:
|
||
"""Delete managed specialists and return the ids actually removed."""
|
||
deleted: list[str] = []
|
||
for agent_id in dict.fromkeys(str(item).strip() for item in agent_ids if str(item).strip()):
|
||
spec = self.store.get_subagent(agent_id)
|
||
if spec is None:
|
||
continue
|
||
if not self._is_managed_specialist(spec.metadata, spec.tags):
|
||
logger.warning("Skipping cleanup for unmanaged local specialist candidate {}", agent_id)
|
||
continue
|
||
if self.store.delete_subagent(agent_id):
|
||
deleted.append(agent_id)
|
||
logger.info("Cleaned up local A2A specialist {}", agent_id)
|
||
return deleted
|
||
|
||
@staticmethod
|
||
def _is_managed_specialist(metadata: dict[str, Any], tags: list[str]) -> bool:
|
||
return (
|
||
metadata.get("managed_by") == "agent_team_provisioning"
|
||
or "auto-provisioned" in tags
|
||
)
|
||
|
||
def _specialist_id(self, role: str, task: str) -> str:
|
||
base = normalize_subagent_id(role)
|
||
return normalize_subagent_id(f"{base}-{self._fingerprint(task)}")
|
||
|
||
@staticmethod
|
||
def _fingerprint(task: str) -> str:
|
||
return hashlib.sha1(str(task or "").encode("utf-8")).hexdigest()[:8]
|
||
|
||
@staticmethod
|
||
def _display_name(role: str) -> str:
|
||
return " ".join(part.capitalize() for part in re.split(r"[\s_-]+", role.strip()) if part)
|
||
|
||
def _system_prompt(self, role: str, task: str, skills: list[str]) -> str:
|
||
# skills 是本次 team run 要求携带的技能上下文;这里仅写入提示词,
|
||
# 真正的工具可用性和权限仍由外层 AgentLoop / tool registry 控制。
|
||
skills_text = ", ".join(skills) if skills else "none"
|
||
role_text = re.sub(r"\s+", " ", str(role or "").strip()) or "general specialist"
|
||
|
||
# 这里保持一套完全通用的提示模板:
|
||
# - 不对具体角色做领域特化
|
||
# - 不规定固定输出格式
|
||
# - 只强调“按该角色名称隐含的职责边界来贡献结果”
|
||
return (
|
||
f"你是 nanobot agent team 中的 {role_text}。\n\n"
|
||
"请围绕这个角色名称所隐含的职责边界处理原始团队任务。根据任务本身选择"
|
||
"合适的方法、工具、下游委派方式和输出格式,不要强行套用固定报告模板。"
|
||
"你的结果应该便于团队合并成最终答案;如果关键假设、阻塞点或风险会影响"
|
||
"结论,请明确指出。\n\n"
|
||
f"原始团队任务:\n{task}\n\n"
|
||
f"本次要求的技能:\n{skills_text}"
|
||
)
|