feat(agent): 实现委派工具重构,支持子代理和代理团队模式
- 新增 spawn_subagent 和 spawn_agent_team 工具,替代原有的 spawn 工具 - 重构 DelegationManager 以支持单个子代理和代理团队两种委派模式 - 更新系统提示词中的委派策略说明,明确使用场景和区别 - 添加技能上下文传递功能,确保委派任务遵循指定技能 - 实现代理内部的受控下游委派机制,防止无限嵌套 - 更新工具注册和上下文设置逻辑以适配新架构
This commit is contained in:
@ -86,15 +86,12 @@ Skills with available="false" need dependencies installed first - you can try in
|
||||
{skills_summary}""")
|
||||
|
||||
if self.agent_registry:
|
||||
# 把可委派 agent 目录加入 system prompt,模型才知道 `spawn` 能调用谁。
|
||||
agents_summary = self.agent_registry.build_agents_summary()
|
||||
if agents_summary:
|
||||
parts.append(f"""# Available Agents
|
||||
parts.append("""# Delegation Tools
|
||||
|
||||
The following agents can be delegated to via the `spawn` tool.
|
||||
Use `target` for a single agent and `targets` for a group.
|
||||
|
||||
{agents_summary}""")
|
||||
Use `spawn_subagent` when the task should go to one delegated worker.
|
||||
Use `spawn_agent_team` when the task should be explored in parallel by multiple workers.
|
||||
At the top level, you do not need to choose concrete downstream agents.
|
||||
Use the `skills` argument when the delegated worker or team must follow specific skills.""")
|
||||
|
||||
if execution_context:
|
||||
# `execution_context` 用于 cron / system task 这类“不是普通用户消息”的额外运行说明。
|
||||
@ -142,6 +139,14 @@ Reply directly with text for conversations. Only use the 'message' tool to send
|
||||
- If a tool call fails, analyze the error before retrying with a different approach.
|
||||
- Do not write directly into `{workspace_path}/skills`; new or updated skills must go through the review flow before activation.
|
||||
|
||||
## Delegation Policy
|
||||
- Solve simple tasks yourself when the work is short, direct, and does not benefit from delegation.
|
||||
- Delegate only when the task is complex, multi-step, time-consuming, or benefits from specialized/parallel work.
|
||||
- Use `spawn_subagent` for one focused delegated worker when only the final result matters.
|
||||
- Use `spawn_agent_team` when multiple agents should explore the task in parallel, compare findings, or work across separate areas.
|
||||
- Do not delegate by default if you can complete the task reliably in the current turn.
|
||||
- Do not create or modify persistent local sub-agents unless the user explicitly asks for a reusable long-lived worker.
|
||||
|
||||
## Memory
|
||||
- Remember important facts: write to {workspace_path}/memory/MEMORY.md
|
||||
- Recall past events: grep {workspace_path}/memory/HISTORY.md"""
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -32,7 +32,7 @@ from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTo
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.agent.tools.spawn import DelegationTool, SpawnAgentTeamTool, SpawnSubagentTool
|
||||
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
@ -145,6 +145,7 @@ class AgentLoop:
|
||||
workspace=workspace,
|
||||
bus=bus,
|
||||
registry=self.agent_registry,
|
||||
skills_loader=self.skills,
|
||||
local_executor=self.subagents,
|
||||
timeout_seconds=self.a2a_config.timeout_seconds,
|
||||
poll_interval_seconds=self.a2a_config.poll_interval_seconds,
|
||||
@ -157,6 +158,7 @@ class AgentLoop:
|
||||
allow_plugin_delegation=self.allow_plugin_delegation,
|
||||
allow_local_fallback=self.include_local_fallback,
|
||||
)
|
||||
self.subagents.set_nested_delegate(self.delegation)
|
||||
|
||||
# 运行时状态位。
|
||||
self._running = False
|
||||
@ -209,13 +211,14 @@ class AgentLoop:
|
||||
protected_paths=protected_skill_paths,
|
||||
))
|
||||
|
||||
# 网络、消息、子代理工具按职责注册。
|
||||
# 网络、消息、委派工具按职责注册。
|
||||
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
|
||||
self.tools.register(WebFetchTool())
|
||||
if self.allow_message:
|
||||
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
|
||||
if self.allow_spawn:
|
||||
self.tools.register(SpawnTool(manager=self.delegation))
|
||||
self.tools.register(SpawnSubagentTool(manager=self.delegation))
|
||||
self.tools.register(SpawnAgentTeamTool(manager=self.delegation))
|
||||
|
||||
# 只有注入 cron_service 时才暴露 cron 工具,避免空引用。
|
||||
if self.cron_service and self.allow_cron:
|
||||
@ -347,11 +350,12 @@ class AgentLoop:
|
||||
if isinstance(message_tool, MessageTool):
|
||||
message_tool.set_context(channel, chat_id, message_id)
|
||||
|
||||
# spawn 工具:子代理完成后需要把结果回投到原会话,
|
||||
# 委派工具:后台任务完成后需要把结果回投到原会话,
|
||||
# 因此只需记住来源 channel/chat_id。
|
||||
if spawn_tool := self.tools.get("spawn"):
|
||||
if isinstance(spawn_tool, SpawnTool):
|
||||
spawn_tool.set_context(channel, chat_id, announce_via_bus=self._running)
|
||||
for tool_name in ("spawn_subagent", "spawn_agent_team"):
|
||||
if delegation_tool := self.tools.get(tool_name):
|
||||
if isinstance(delegation_tool, DelegationTool):
|
||||
delegation_tool.set_context(channel, chat_id, announce_via_bus=self._running)
|
||||
|
||||
# cron 工具:创建任务时会把 deliver 目标写入任务 payload,
|
||||
# 后续定时触发时才能把结果送回同一会话。
|
||||
|
||||
@ -19,10 +19,12 @@ from nanobot.agent.run_result import AgentRunResult
|
||||
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.agent.tools.spawn import NestedDelegateTool
|
||||
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.delegation import DelegationManager
|
||||
from nanobot.config.schema import ExecToolConfig
|
||||
|
||||
|
||||
@ -51,6 +53,11 @@ class SubagentManager:
|
||||
self.brave_api_key = brave_api_key
|
||||
self.exec_config = exec_config or ExecToolConfig()
|
||||
self.restrict_to_workspace = restrict_to_workspace
|
||||
self._nested_delegate: DelegationManager | None = None
|
||||
|
||||
def set_nested_delegate(self, manager: "DelegationManager | None") -> None:
|
||||
"""注入 delegated worker 可用的受控下游委派器。"""
|
||||
self._nested_delegate = manager
|
||||
|
||||
async def run_local_task(
|
||||
self,
|
||||
@ -61,14 +68,22 @@ class SubagentManager:
|
||||
system_prompt: str | None = None,
|
||||
model: str | None = None,
|
||||
progress_callback: Callable[..., Awaitable[None]] | None = None,
|
||||
allow_nested_delegation: bool = True,
|
||||
skill_context: str = "",
|
||||
skill_names: list[str] | None = None,
|
||||
) -> AgentRunResult:
|
||||
"""执行一次本地委派任务,并返回结构化结果。"""
|
||||
# 每次任务都新建一套局部工具注册表,避免不同任务之间共享临时状态。
|
||||
tools = self._build_local_tools()
|
||||
tools = self._build_local_tools(
|
||||
allow_nested_delegation=allow_nested_delegation,
|
||||
skill_names=skill_names,
|
||||
)
|
||||
prompt = self._build_subagent_prompt(
|
||||
task,
|
||||
agent_name=agent_name,
|
||||
custom_system_prompt=system_prompt,
|
||||
allow_nested_delegation=allow_nested_delegation,
|
||||
skill_context=skill_context,
|
||||
)
|
||||
# 本地委派不共享主会话历史,只带“专用 system prompt + 当前任务”。
|
||||
messages: list[dict[str, Any]] = [
|
||||
@ -143,7 +158,12 @@ class SubagentManager:
|
||||
summary=final_result,
|
||||
)
|
||||
|
||||
def _build_local_tools(self) -> ToolRegistry:
|
||||
def _build_local_tools(
|
||||
self,
|
||||
*,
|
||||
allow_nested_delegation: bool,
|
||||
skill_names: list[str] | None = None,
|
||||
) -> ToolRegistry:
|
||||
"""构建本地委派可用的受限工具集。"""
|
||||
tools = ToolRegistry()
|
||||
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
||||
@ -175,6 +195,8 @@ class SubagentManager:
|
||||
# 网络能力保持只读:搜索和抓取,不提供消息发送/再次委派等工具。
|
||||
tools.register(WebSearchTool(api_key=self.brave_api_key))
|
||||
tools.register(WebFetchTool())
|
||||
if allow_nested_delegation and self._nested_delegate is not None:
|
||||
tools.register(NestedDelegateTool(manager=self._nested_delegate, default_skills=skill_names))
|
||||
return tools
|
||||
|
||||
@staticmethod
|
||||
@ -201,12 +223,46 @@ class SubagentManager:
|
||||
task: str,
|
||||
agent_name: str = "Local Subagent",
|
||||
custom_system_prompt: str | None = None,
|
||||
allow_nested_delegation: bool = True,
|
||||
skill_context: str = "",
|
||||
) -> str:
|
||||
"""构建子代理专用 system prompt。"""
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
||||
tz = _time.strftime("%Z") or "UTC"
|
||||
# plugin agent 的自定义系统提示拼到末尾,保留通用约束,再叠加个性化指令。
|
||||
extra = f"\n\n## Agent Instructions\n{custom_system_prompt.strip()}" if custom_system_prompt else ""
|
||||
can_do_lines = [
|
||||
"- Read and write files in the workspace",
|
||||
"- Execute shell commands",
|
||||
"- Search the web and fetch web pages",
|
||||
"- Complete the task thoroughly",
|
||||
]
|
||||
cannot_do_lines = [
|
||||
"- Send messages directly to users (no message tool available)",
|
||||
"- Access the main agent's conversation history",
|
||||
]
|
||||
delegation_section = (
|
||||
"\n## Downstream Delegation\n"
|
||||
"- Do not delegate further. Complete the task yourself with the tools you have."
|
||||
)
|
||||
if allow_nested_delegation and self._nested_delegate is not None:
|
||||
can_do_lines.append(
|
||||
"- Use `delegate_task` for controlled downstream delegation when specialized help is required"
|
||||
)
|
||||
cannot_do_lines.append("- Do not start agent teams or use background delegation tools")
|
||||
nested_summary = self._nested_delegate.build_nested_agents_summary()
|
||||
summary_block = f"\n\n{nested_summary}" if nested_summary else ""
|
||||
delegation_section = (
|
||||
"\n## Downstream Delegation\n"
|
||||
"- Use `delegate_task` only when a specialized downstream worker is actually needed.\n"
|
||||
"- `strategy=\"a2a\"` delegates directly to an available A2A agent.\n"
|
||||
"- `strategy=\"ephemeral_subagent\"` runs a temporary local worker for this task only.\n"
|
||||
"- Never create, register, or persist a new local sub-agent through `subagentctl.py`, `/api/subagents`, or registry edits."
|
||||
f"{summary_block}"
|
||||
)
|
||||
else:
|
||||
cannot_do_lines.append("- Spawn other subagents or downstream workers")
|
||||
skill_section = f"\n## Required Skills\n{skill_context.strip()}" if skill_context.strip() else ""
|
||||
|
||||
return f"""# {agent_name}
|
||||
|
||||
@ -220,17 +276,17 @@ You are a delegated agent spawned by the main agent to complete a specific task.
|
||||
2. Your final response will be reported back to the main agent
|
||||
3. Do not initiate conversations or take on side tasks
|
||||
4. Be concise but informative in your findings
|
||||
5. Do not create or modify persistent local sub-agents unless the task explicitly requires that workflow
|
||||
|
||||
## What You Can Do
|
||||
- Read and write files in the workspace
|
||||
- Execute shell commands
|
||||
- Search the web and fetch web pages
|
||||
- Complete the task thoroughly
|
||||
{chr(10).join(can_do_lines)}
|
||||
|
||||
## What You Cannot Do
|
||||
- Send messages directly to users (no message tool available)
|
||||
- Spawn other subagents
|
||||
- Access the main agent's conversation history
|
||||
{chr(10).join(cannot_do_lines)}
|
||||
|
||||
{delegation_section}
|
||||
|
||||
{skill_section}
|
||||
|
||||
## Workspace
|
||||
Your workspace is at: {self.workspace}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""spawn 工具:用于把任务委派给后台 agent。"""
|
||||
"""委派工具:分别暴露 subagent 与 agent team 两种调用接口。"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@ -8,74 +8,178 @@ if TYPE_CHECKING:
|
||||
from nanobot.agent.delegation import DelegationManager
|
||||
|
||||
|
||||
class SpawnTool(Tool):
|
||||
"""
|
||||
后台委派工具。
|
||||
|
||||
作用:
|
||||
1. 把耗时/可并行的任务委派给 DelegationManager;
|
||||
2. 目标可以是本地 agent、A2A 远端 agent 或 agent group;
|
||||
3. 后台任务异步执行,不阻塞当前对话回合。
|
||||
"""
|
||||
class DelegationTool(Tool):
|
||||
"""委派类工具的公共上下文注入逻辑。"""
|
||||
|
||||
def __init__(self, manager: "DelegationManager"):
|
||||
# manager 负责真正创建 asyncio 后台任务并管理生命周期。
|
||||
self._manager = manager
|
||||
# 默认来源会话(CLI 直连场景)。实际会在每轮由 loop._set_tool_context 覆盖。
|
||||
self._origin_channel = "cli"
|
||||
self._origin_chat_id = "direct"
|
||||
self._announce_via_bus = True
|
||||
|
||||
def set_context(self, channel: str, chat_id: str, announce_via_bus: bool = True) -> None:
|
||||
"""设置后台委派结果回传的目标会话。"""
|
||||
# 委派任务完成后并不会直接给用户发消息,
|
||||
# 而是把结果发回这里记录的 origin(channel/chat_id)对应会话。
|
||||
self._origin_channel = channel
|
||||
self._origin_chat_id = chat_id
|
||||
self._announce_via_bus = announce_via_bus
|
||||
|
||||
|
||||
class SpawnSubagentTool(DelegationTool):
|
||||
"""把任务委派给单个 subagent。"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
# 暴露给 LLM 的工具名;模型会用这个名字发起 function call。
|
||||
return "spawn"
|
||||
return "spawn_subagent"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
# 给模型看的能力描述,强调“后台执行 + 完成后回报”语义。
|
||||
return (
|
||||
"Delegate a task to a background agent. "
|
||||
"Delegate a focused task to one background subagent. "
|
||||
"Use this for complex or time-consuming work that can run independently. "
|
||||
"You can target a specific agent, a group of agents, or let the system choose. "
|
||||
"The delegated agent(s) will report back when done."
|
||||
"You only provide the task and optional required skills; downstream routing decides the concrete agent. "
|
||||
"The subagent will report back when done."
|
||||
)
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
# OpenAI function schema:定义模型可传入的参数结构。
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "The task for the delegated agent to complete",
|
||||
"description": "The task for the delegated subagent to complete",
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Optional short label for the task (for display)",
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "Optional agent ID or name for a single target",
|
||||
},
|
||||
"targets": {
|
||||
"skills": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of agent IDs/names for a group task",
|
||||
"description": "Optional list of skill names the delegated worker must follow",
|
||||
},
|
||||
},
|
||||
"required": ["task"],
|
||||
}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
task: str,
|
||||
label: str | None = None,
|
||||
skills: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""创建并启动一个 subagent 后台任务。"""
|
||||
return await self._manager.dispatch_subagent(
|
||||
task=task,
|
||||
label=label,
|
||||
skills=skills,
|
||||
origin_channel=self._origin_channel,
|
||||
origin_chat_id=self._origin_chat_id,
|
||||
announce_via_bus=self._announce_via_bus,
|
||||
)
|
||||
|
||||
|
||||
class SpawnAgentTeamTool(DelegationTool):
|
||||
"""启动一个 agent team 任务。"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "spawn_agent_team"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Start an agent team for parallel exploration. "
|
||||
"Use this when multiple agents should investigate the task in parallel and return a combined result. "
|
||||
"You only provide the task and optional required skills; downstream routing selects the concrete members."
|
||||
)
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "The shared task for the agent team",
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Optional short label for the team task (for display)",
|
||||
},
|
||||
"skills": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of skill names the team must follow",
|
||||
},
|
||||
},
|
||||
"required": ["task"],
|
||||
}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
task: str,
|
||||
label: str | None = None,
|
||||
skills: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""创建并启动一个 agent team 后台任务。"""
|
||||
return await self._manager.dispatch_agent_team(
|
||||
task=task,
|
||||
label=label,
|
||||
skills=skills,
|
||||
origin_channel=self._origin_channel,
|
||||
origin_chat_id=self._origin_chat_id,
|
||||
announce_via_bus=self._announce_via_bus,
|
||||
)
|
||||
|
||||
|
||||
class NestedDelegateTool(Tool):
|
||||
"""供 delegated worker 使用的受控下游委派工具。"""
|
||||
|
||||
def __init__(self, manager: "DelegationManager", default_skills: list[str] | None = None):
|
||||
self._manager = manager
|
||||
self._default_skills = [str(item).strip() for item in (default_skills or []) if str(item).strip()]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "delegate_task"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Synchronously delegate a downstream task from a delegated worker. "
|
||||
"Use this only when specialized help is needed. "
|
||||
"It can route to an A2A agent or an ephemeral local subagent, but never creates a persistent subagent."
|
||||
)
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "The downstream task to delegate",
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Optional short label for the downstream task",
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "Optional agent ID or name for the downstream worker",
|
||||
},
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "local", "plugin", "a2a", "group"],
|
||||
"description": "Routing strategy. Default is auto.",
|
||||
"enum": ["auto", "a2a", "ephemeral_subagent"],
|
||||
"description": "Routing strategy for downstream delegation. Default is auto.",
|
||||
},
|
||||
"skills": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional required skills for the downstream delegate. Defaults to the current worker's required skills.",
|
||||
},
|
||||
},
|
||||
"required": ["task"],
|
||||
@ -86,20 +190,15 @@ class SpawnTool(Tool):
|
||||
task: str,
|
||||
label: str | None = None,
|
||||
target: str | None = None,
|
||||
targets: list[str] | None = None,
|
||||
strategy: str = "auto",
|
||||
skills: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""创建并启动一个后台委派任务。"""
|
||||
# 这里仅负责转发请求,不在本工具内执行实际任务逻辑。
|
||||
# 返回值是“已启动”状态文本,真正结果稍后通过主消息总线回传。
|
||||
return await self._manager.dispatch(
|
||||
"""同步执行一次受控下游委派,并把结果返回给当前 worker。"""
|
||||
return await self._manager.delegate_for_subagent(
|
||||
task=task,
|
||||
label=label,
|
||||
target=target,
|
||||
targets=targets,
|
||||
strategy=strategy,
|
||||
origin_channel=self._origin_channel,
|
||||
origin_chat_id=self._origin_chat_id,
|
||||
announce_via_bus=self._announce_via_bus,
|
||||
skills=skills if skills is not None else list(self._default_skills),
|
||||
)
|
||||
|
||||
@ -17,7 +17,7 @@ Only use this skill if Outlook MCP tools are available in the current tool list,
|
||||
## V1 routing rules
|
||||
|
||||
- Keep Outlook execution in the main agent turn.
|
||||
- Do not `spawn` a local subagent for Outlook work in V1, because the local delegated agent does not inherit MCP tools.
|
||||
- Do not use `spawn_subagent` for Outlook work in V1, because the local delegated agent does not inherit MCP tools.
|
||||
- Do not use the built-in `email` channel for Outlook mailbox operations. Outlook actions should go through MCP -> Outlook MCP server -> Microsoft Graph.
|
||||
|
||||
## Tool usage rules
|
||||
|
||||
Reference in New Issue
Block a user