feat(agent): 实现委派工具重构,支持子代理和代理团队模式
- 新增 spawn_subagent 和 spawn_agent_team 工具,替代原有的 spawn 工具 - 重构 DelegationManager 以支持单个子代理和代理团队两种委派模式 - 更新系统提示词中的委派策略说明,明确使用场景和区别 - 添加技能上下文传递功能,确保委派任务遵循指定技能 - 实现代理内部的受控下游委派机制,防止无限嵌套 - 更新工具注册和上下文设置逻辑以适配新架构
This commit is contained in:
@ -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("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user