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

@ -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"""

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,

View File

@ -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
# 后续定时触发时才能把结果送回同一会话。

View File

@ -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}

View File

@ -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:
"""设置后台委派结果回传的目标会话。"""
# 委派任务完成后并不会直接给用户发消息,
# 而是把结果发回这里记录的 originchannel/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),
)

View File

@ -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