"""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}" )