feat(agent): 实现委派工具重构,支持子代理和代理团队模式

- 新增 spawn_subagent 和 spawn_agent_team 工具,替代原有的 spawn 工具
- 重构 DelegationManager 以支持单个子代理和代理团队两种委派模式
- 更新系统提示词中的委派策略说明,明确使用场景和区别
- 添加技能上下文传递功能,确保委派任务遵循指定技能
- 实现代理内部的受控下游委派机制,防止无限嵌套
- 更新工具注册和上下文设置逻辑以适配新架构
This commit is contained in:
2026-03-30 17:21:39 +08:00
parent 29dfd14aa6
commit fee9007da6
6 changed files with 490 additions and 90 deletions

View File

@ -1,7 +1,7 @@
"""统一委派管理器。
这是本次多 agent 改造的核心编排层,负责:
1. 根据目标 / 策略选择本地 agent、plugin agent、A2A 远端 agent 或 group
1. 根据接口语义选择单 subagent 或 agent team 路径
2. 跟踪每次后台委派的运行状态,支持取消;
3. 统一发出 bus 公告和结构化 process events
4. 在本地执行器和 A2A 客户端之间做协议桥接。
@ -15,7 +15,7 @@ import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
from loguru import logger
@ -32,6 +32,9 @@ from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
if TYPE_CHECKING:
from nanobot.agent.skills import SkillsLoader
DirectAnnouncementCallback = Callable[[str, dict[str, str], str, bool], Awaitable[None]]
@ -53,7 +56,7 @@ class DelegationRun:
class DelegationManager:
"""把任务分发到本地、插件、远端 A2A 或 agent group"""
"""把任务分发到单个 subagent 或 agent team"""
def __init__(
self,
@ -61,6 +64,7 @@ class DelegationManager:
workspace: Path,
bus: MessageBus,
registry: AgentRegistry,
skills_loader: "SkillsLoader | None",
local_executor: Any,
timeout_seconds: int = 30,
poll_interval_seconds: int = 2,
@ -77,6 +81,7 @@ class DelegationManager:
self.workspace = workspace
self.bus = bus
self.registry = registry
self.skills_loader = skills_loader
# local_executor 只负责“本地执行”,不再承担队列编排职责。
self.local_executor = local_executor
self.max_parallel_agents = max(1, max_parallel_agents)
@ -115,31 +120,140 @@ class DelegationManager:
"""注册直连模式下的本地公告处理器。"""
self._direct_announcement_callback = callback
async def dispatch(
async def delegate_for_subagent(
self,
task: str,
label: str | None = None,
target: str | None = None,
targets: list[str] | None = None,
strategy: str = "auto",
skills: list[str] | None = None,
) -> str:
"""供 delegated worker 使用的同步下游委派入口。"""
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
try:
descriptor = self._resolve_nested_delegate(task, target, strategy)
result = await self._execute_descriptor(
descriptor,
task,
display_label,
skill_names=skills,
allow_nested_delegation=False,
)
except Exception as exc:
return f"Error: Nested delegation failed: {exc}"
status_text = "completed successfully" if result.status == "ok" else result.status
return (
f"Nested delegation via {result.agent_name} ({result.agent_id}) {status_text}.\n\n"
f"Result:\n{result.summary}"
)
def build_nested_agents_summary(self) -> str:
"""构造 delegated worker 可见的下游 agent 摘要。"""
agents = [
agent
for agent in self.registry.list_agents()
if self._nested_descriptor_allowed(agent)
]
if not agents:
return ""
def esc(value: str) -> str:
return (
value.replace("&", "&")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
lines = ["<downstream-agents>"]
for agent in agents:
lines.append(" <agent>")
lines.append(f" <id>{esc(agent.id)}</id>")
lines.append(f" <name>{esc(agent.name)}</name>")
lines.append(f" <kind>{esc(agent.kind)}</kind>")
lines.append(f" <source>{esc(agent.source)}</source>")
lines.append(f" <description>{esc(agent.description)}</description>")
if agent.protocol:
lines.append(f" <protocol>{esc(agent.protocol)}</protocol>")
lines.append(" </agent>")
lines.append("</downstream-agents>")
return "\n".join(lines)
async def dispatch_subagent(
self,
task: str,
label: str | None = None,
skills: list[str] | None = None,
origin_channel: str = "cli",
origin_chat_id: str = "direct",
announce_via_bus: bool = True,
) -> str:
"""启动一个后台 subagent 委派任务,并立即返回已启动提示。"""
return await self._dispatch(
task=task,
label=label,
target=None,
targets=[],
strategy="auto",
skills=skills or [],
origin_channel=origin_channel,
origin_chat_id=origin_chat_id,
announce_via_bus=announce_via_bus,
mode="subagent",
)
async def dispatch_agent_team(
self,
task: str,
label: str | None = None,
skills: list[str] | None = None,
origin_channel: str = "cli",
origin_chat_id: str = "direct",
announce_via_bus: bool = True,
) -> str:
"""启动一个后台 agent team 任务,并立即返回已启动提示。"""
return await self._dispatch(
task=task,
label=label,
target=None,
targets=[],
strategy="group",
skills=skills or [],
origin_channel=origin_channel,
origin_chat_id=origin_chat_id,
announce_via_bus=announce_via_bus,
mode="agent_team",
)
async def _dispatch(
self,
task: str,
label: str | None,
target: str | None,
targets: list[str],
strategy: str,
skills: list[str],
origin_channel: str,
origin_chat_id: str,
announce_via_bus: bool,
mode: str,
) -> str:
"""启动一个后台委派任务,并立即返回已启动提示。"""
run_id = str(uuid.uuid4())[:8]
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
origin = {"channel": origin_channel, "chat_id": origin_chat_id}
# 真正执行逻辑放后台任务里,避免阻塞当前对话回合。
kind_label = "Agent team" if mode == "agent_team" else "Subagent"
bg_task = asyncio.create_task(
self._run_dispatch(
run_id=run_id,
task=task,
label=display_label,
target=target,
targets=targets or [],
strategy=(strategy or "auto").lower(),
targets=targets,
strategy=strategy,
skills=skills,
origin=origin,
mode=mode,
)
)
self._running_tasks[run_id] = DelegationRun(
@ -149,9 +263,9 @@ class DelegationManager:
announce_via_bus=announce_via_bus,
)
bg_task.add_done_callback(lambda _: self._running_tasks.pop(run_id, None))
logger.info("Delegation [{}] started: {}", run_id, display_label)
logger.info("{} [{}] started: {}", kind_label, run_id, display_label)
return (
f"Delegation [{display_label}] started (id: {run_id}). "
f"{kind_label} [{display_label}] started (id: {run_id}). "
"I'll notify you when it completes."
)
@ -249,27 +363,27 @@ class DelegationManager:
)
async def _emit_group_started(self, run_id: str, label: str, targets: list[str]) -> None:
"""发送 group delegation 开始事件。"""
"""发送 agent team 开始事件。"""
await emit_process_event(
"process_run_started",
run_id=run_id,
parent_run_id=None,
actor_type="system",
actor_id="agent-group",
actor_name="Agent Group",
actor_name="Agent Team",
title=label,
status="running",
metadata={"targets": targets},
)
async def _emit_group_finished(self, run_id: str, label: str, results: list[AgentRunResult]) -> None:
"""发送 group delegation 结束事件。"""
"""发送 agent team 结束事件。"""
await emit_process_event(
"process_run_finished",
run_id=run_id,
actor_type="system",
actor_id="agent-group",
actor_name="Agent Group",
actor_name="Agent Team",
status="done",
summary=f"{label}: {len(results)} member(s) finished",
metadata={
@ -382,27 +496,27 @@ class DelegationManager:
target: str | None,
targets: list[str],
strategy: str,
skills: list[str],
origin: dict[str, str],
mode: str,
) -> None:
"""后台委派主入口。"""
descriptor: AgentDescriptor | None = None
state = self._running_tasks.get(run_id)
# 某些极短生命周期场景下 state 可能已被移除,此时回落到默认 True。
announce_via_bus = True if state is None else state.announce_via_bus
is_group = len(targets) > 1 or strategy == "group"
is_group = mode == "agent_team"
try:
if is_group:
# group 场景允许同时传 `target` 和 `targets`,这里统一摊平成列表。
planned_targets = list(targets)
if target:
planned_targets.append(target)
await self._emit_group_started(run_id, label, planned_targets)
results = await self._run_group(
task,
label,
target,
None,
targets,
strategy,
skills,
origin=origin,
run_id=run_id,
announce_via_bus=announce_via_bus,
@ -432,6 +546,7 @@ class DelegationManager:
descriptor,
task,
label,
skill_names=skills,
event_callback=progress_callback,
task_callback=self._build_task_callback(run_id, descriptor),
process_run_id=run_id,
@ -453,7 +568,7 @@ class DelegationManager:
run_id=run_id,
actor_type="system",
actor_id="agent-group",
actor_name="Agent Group",
actor_name="Agent Team",
status="cancelled",
)
else:
@ -481,7 +596,7 @@ class DelegationManager:
run_id=run_id,
actor_type="system",
actor_id="agent-group",
actor_name="Agent Group",
actor_name="Agent Team",
status="error",
summary=f"Error: {exc}",
)
@ -557,6 +672,95 @@ class DelegationManager:
raise ValueError("Local fallback agent is not available")
return descriptor
def _resolve_nested_delegate(self, task: str, target: str | None, strategy: str) -> AgentDescriptor:
"""为 delegated worker 解析允许的下游目标。"""
probe = (strategy or "auto").strip().lower()
if target:
descriptor = self.registry.get_agent(target)
if descriptor is None:
raise ValueError(f"Agent '{target}' not found")
self._ensure_nested_descriptor_allowed(descriptor)
if probe == "a2a" and not self._is_nested_a2a_descriptor(descriptor):
raise ValueError(f"Agent '{target}' is not an allowed A2A downstream target")
if probe == "ephemeral_subagent" and not self._is_ephemeral_local_descriptor(descriptor):
raise ValueError(f"Agent '{target}' is not an allowed ephemeral downstream target")
return descriptor
if probe == "a2a":
suggestions = [
agent for agent in self.registry.suggest_agents(task, limit=5)
if self._is_nested_a2a_descriptor(agent)
]
if suggestions:
return suggestions[0]
raise ValueError("No matching downstream A2A agent found")
if probe == "ephemeral_subagent":
suggestions = [
agent for agent in self.registry.suggest_agents(task, limit=5)
if self._is_ephemeral_local_descriptor(agent)
]
if suggestions:
return suggestions[0]
descriptor = self.registry.get_agent("local-subagent")
if descriptor and self._is_ephemeral_local_descriptor(descriptor):
return descriptor
raise ValueError("No ephemeral local subagent is available")
a2a_suggestions = [
agent for agent in self.registry.suggest_agents(task, limit=5)
if self._is_nested_a2a_descriptor(agent)
]
if a2a_suggestions:
return a2a_suggestions[0]
local_suggestions = [
agent for agent in self.registry.suggest_agents(task, limit=5)
if self._is_ephemeral_local_descriptor(agent)
]
if local_suggestions:
return local_suggestions[0]
descriptor = self.registry.get_agent("local-subagent")
if descriptor and self._nested_descriptor_allowed(descriptor):
return descriptor
raise ValueError("No allowed downstream agent found")
@staticmethod
def _normalize_skill_names(skill_names: list[str] | None) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for item in skill_names or []:
name = str(item or "").strip()
if not name:
continue
key = name.lower()
if key in seen:
continue
seen.add(key)
result.append(name)
return result
def _build_skill_context(self, skill_names: list[str] | None) -> str:
names = self._normalize_skill_names(skill_names)
if not names:
return ""
header = "Required skills: " + ", ".join(names)
if self.skills_loader is None:
return header
content = self.skills_loader.load_skills_for_context(names).strip()
if not content:
return header
return f"{header}\n\n{content}"
def _augment_task_with_skills(self, task: str, skill_names: list[str] | None) -> str:
skill_context = self._build_skill_context(skill_names)
if not skill_context:
return task
return (
f"{task}\n\n"
"You must follow the required skills below while completing this delegated work.\n\n"
f"{skill_context}"
)
@classmethod
def _is_persistent_subagent_task(cls, task: str) -> bool:
text = (task or "").strip()
@ -580,6 +784,7 @@ class DelegationManager:
target: str | None,
targets: list[str],
strategy: str,
skills: list[str],
origin: dict[str, str],
run_id: str,
announce_via_bus: bool,
@ -595,6 +800,10 @@ class DelegationManager:
if self._descriptor_allowed(agent)
]
resolved_targets = [agent.id for agent in suggestions]
if not resolved_targets:
descriptor = self.registry.get_agent("local-subagent")
if descriptor and self._descriptor_allowed(descriptor):
resolved_targets = [descriptor.id]
if not resolved_targets:
raise ValueError("No agents available for group delegation")
resolved_targets = list(dict.fromkeys(resolved_targets))
@ -629,6 +838,7 @@ class DelegationManager:
descriptor,
task,
label,
skill_names=skills,
event_callback=self._build_progress_callback(
origin,
descriptor,
@ -661,12 +871,15 @@ class DelegationManager:
descriptor: AgentDescriptor,
task: str,
label: str,
skill_names: list[str] | None = None,
event_callback=None,
task_callback=None,
process_run_id: str | None = None,
allow_nested_delegation: bool = True,
) -> AgentRunResult:
"""根据 descriptor 类型执行具体 agent。"""
logger.info("Delegating '{}' to {}", label, descriptor.id)
skill_context = self._build_skill_context(skill_names)
if descriptor.kind in {"local_fallback", "local_prompt"}:
if not self.allow_local_delegation or (
descriptor.kind == "local_prompt" and not self.allow_plugin_delegation
@ -684,13 +897,16 @@ class DelegationManager:
system_prompt=descriptor.system_prompt,
model=descriptor.model,
progress_callback=event_callback,
allow_nested_delegation=allow_nested_delegation,
skill_context=skill_context,
skill_names=self._normalize_skill_names(skill_names),
)
if descriptor.kind == "a2a_remote" or descriptor.protocol == "a2a":
# 远端执行交给 A2AClient委派层只负责传递事件回调和 task_callback。
with process_run_context(process_run_id):
return await self.a2a_client.run_task(
descriptor,
task=task,
task=self._augment_task_with_skills(task, skill_names),
label=label,
event_callback=event_callback,
task_callback=task_callback,
@ -706,10 +922,30 @@ class DelegationManager:
return True
return False
@staticmethod
def _is_persistent_local_subagent_descriptor(descriptor: AgentDescriptor) -> bool:
return bool(descriptor.metadata.get("local_subagent"))
def _is_ephemeral_local_descriptor(self, descriptor: AgentDescriptor) -> bool:
return descriptor.kind in {"local_fallback", "local_prompt"} and self._descriptor_allowed(descriptor)
def _is_nested_a2a_descriptor(self, descriptor: AgentDescriptor) -> bool:
return (
(descriptor.protocol == "a2a" or descriptor.kind == "a2a_remote")
and not self._is_persistent_local_subagent_descriptor(descriptor)
)
def _nested_descriptor_allowed(self, descriptor: AgentDescriptor) -> bool:
return self._is_ephemeral_local_descriptor(descriptor) or self._is_nested_a2a_descriptor(descriptor)
def _ensure_descriptor_allowed(self, descriptor: AgentDescriptor) -> None:
if not self._descriptor_allowed(descriptor):
raise ValueError(f"Delegation to '{descriptor.id}' is disabled")
def _ensure_nested_descriptor_allowed(self, descriptor: AgentDescriptor) -> None:
if not self._nested_descriptor_allowed(descriptor):
raise ValueError(f"Delegation to '{descriptor.id}' is not allowed for delegated workers")
def _build_progress_callback(
self,
origin: dict[str, str],
@ -938,8 +1174,8 @@ class DelegationManager:
*,
announce_via_bus: bool,
) -> None:
"""公告 group delegation 汇总结果。"""
lines = [f"[Agent group '{label}' completed]", "", f"Task: {task}", "", "Members:"]
"""公告 agent team 汇总结果。"""
lines = [f"[Agent team '{label}' completed]", "", f"Task: {task}", "", "Members:"]
for result in results:
lines.append(f"- {result.agent_name} ({result.agent_id}): {result.status}")
lines.extend(["", "Results:"])
@ -965,9 +1201,9 @@ class DelegationManager:
)
await self._emit_direct_user_message(
summary,
"多 agent 协作已完成,请查看各 agent 的结果与最终结论。",
"Agent team 已完成,请查看各 agent 的结果与最终结论。",
)
logger.debug("Delegation group [{}] announced result", run_id)
logger.debug("Agent team [{}] announced result", run_id)
async def _publish_announcement(
self,