feat(agent): 添加对持久化子智能体的支持并增强委派管理

添加了持久化子智能体的完整生命周期管理功能,包括创建、更新、删除和查询API接口。
新增了子智能体的JSON-RPC通信协议支持,实现了远程调用和任务管理功能。

同时增强了委派管理器的功能:
- 添加了对本地委派、插件委派和本地回退的开关控制
- 实现了持久化子智能体任务的自动检测和本地执行保护
- 增加了对不同委派类型的权限验证机制

修改了智能体注册表以支持插件智能体的条件性包含,并更新了工具注册逻辑以支持可选工具。

BREAKING CHANGE: 委派管理器的构造函数签名已更改,添加了新的控制参数。
```
This commit is contained in:
2026-03-27 10:15:35 +08:00
parent bad1e16ab4
commit 29dfd14aa6
133 changed files with 11656 additions and 220 deletions

View File

@ -171,6 +171,8 @@ class AgentRegistry:
skills: SkillsLoader | None = None,
allow_skill_cards: bool = True,
allow_workspace_agents: bool = True,
include_local_fallback: bool = True,
include_plugin_agents: bool = True,
):
self.workspace = workspace
# 插件和技能加载器允许外部复用同一个实例,避免重复扫描磁盘。
@ -178,10 +180,14 @@ class AgentRegistry:
self.skills = skills or SkillsLoader(workspace, extra_dirs=self.plugins.get_skill_dirs())
self.allow_skill_cards = allow_skill_cards
self.allow_workspace_agents = allow_workspace_agents
self.include_local_fallback = include_local_fallback
self.include_plugin_agents = include_plugin_agents
self.workspace_store = WorkspaceAgentStore(workspace)
def list_agents(self, include_local_fallback: bool = True) -> list[AgentDescriptor]:
def list_agents(self, include_local_fallback: bool | None = None) -> list[AgentDescriptor]:
"""按统一格式列出当前可见 agent。"""
if include_local_fallback is None:
include_local_fallback = self.include_local_fallback
agents: list[AgentDescriptor] = []
if self.allow_workspace_agents:
@ -193,23 +199,24 @@ class AgentRegistry:
agents.append(agent)
# plugin agents 本质上是“带独立系统提示词的本地执行器”。
for plugin in self.plugins.plugins.values():
for agent in plugin.agents.values():
agents.append(
AgentDescriptor(
id=f"plugin:{agent.name}",
name=agent.name,
description=agent.description or agent.name,
source="plugin",
kind="local_prompt",
protocol=None,
plugin_name=agent.plugin_name,
model=agent.model,
system_prompt=agent.system_prompt,
aliases=[agent.name],
metadata={"plugin_name": agent.plugin_name},
if self.include_plugin_agents:
for plugin in self.plugins.plugins.values():
for agent in plugin.agents.values():
agents.append(
AgentDescriptor(
id=f"plugin:{agent.name}",
name=agent.name,
description=agent.description or agent.name,
source="plugin",
kind="local_prompt",
protocol=None,
plugin_name=agent.plugin_name,
model=agent.model,
system_prompt=agent.system_prompt,
aliases=[agent.name],
metadata={"plugin_name": agent.plugin_name},
)
)
)
if self.allow_skill_cards:
# skill 里声明的 card 视为远端 A2A agent 的静态入口。

View File

@ -10,6 +10,7 @@
from __future__ import annotations
import asyncio
import re
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
@ -68,6 +69,9 @@ class DelegationManager:
allowed_hosts: list[str] | None = None,
authz_config: Any | None = None,
backend_identity: Any | None = None,
allow_local_delegation: bool = True,
allow_plugin_delegation: bool = True,
allow_local_fallback: bool = True,
):
self.provider = provider
self.workspace = workspace
@ -76,6 +80,9 @@ class DelegationManager:
# local_executor 只负责“本地执行”,不再承担队列编排职责。
self.local_executor = local_executor
self.max_parallel_agents = max(1, max_parallel_agents)
self.allow_local_delegation = allow_local_delegation
self.allow_plugin_delegation = allow_plugin_delegation
self.allow_local_fallback = allow_local_fallback
# A2AClient 只处理远端协议细节,委派策略和公告统一放在本类。
self.a2a_client = A2AClient(
timeout_seconds=timeout_seconds,
@ -88,6 +95,19 @@ class DelegationManager:
self._running_tasks: dict[str, DelegationRun] = {}
self._direct_announcement_callback: DirectAnnouncementCallback | None = None
_PERSISTENT_SUBAGENT_PATTERNS = (
re.compile(r"\bsub[\s-]?agent\b", re.IGNORECASE),
re.compile(r"\bpersistent\b", re.IGNORECASE),
re.compile(r"\bagents\.json\b", re.IGNORECASE),
re.compile(r"\bregistry\.json\b", re.IGNORECASE),
re.compile(r"\bsubagentctl\b", re.IGNORECASE),
re.compile(r"/api/subagents", re.IGNORECASE),
re.compile(r"workspace/agents", re.IGNORECASE),
re.compile(r"子智能体"),
re.compile(r"子 agent", re.IGNORECASE),
re.compile(r"持久化"),
)
def set_direct_announcement_callback(
self,
callback: DirectAnnouncementCallback | None,
@ -160,6 +180,7 @@ class DelegationManager:
label: str,
*,
parent_run_id: str | None = None,
task: str | None = None,
) -> None:
# 单 agent 执行开始事件,供前端画执行树。
await emit_process_event(
@ -177,8 +198,21 @@ class DelegationManager:
"protocol": descriptor.protocol,
"support_group": descriptor.support_group,
"support_streaming": descriptor.support_streaming,
"delegated_task": task,
},
)
if task:
await emit_process_event(
"process_run_message",
run_id=run_id,
parent_run_id=parent_run_id,
actor_type="agent",
actor_id=descriptor.id,
actor_name=descriptor.name,
message_role="user",
text=task,
metadata={"source": "delegation_input"},
)
async def _emit_agent_finished(
self,
@ -386,7 +420,7 @@ class DelegationManager:
# 单 agent 场景先解析目标,再执行。
descriptor = self._resolve_single(task, target, strategy)
await self._emit_agent_started(run_id, descriptor, label)
await self._emit_agent_started(run_id, descriptor, label, task=task)
progress_callback = self._build_progress_callback(
origin,
descriptor,
@ -468,15 +502,20 @@ class DelegationManager:
descriptor = self.registry.get_agent(target)
if descriptor is None:
raise ValueError(f"Agent '{target}' not found")
self._ensure_descriptor_allowed(descriptor)
return descriptor
if strategy == "local":
if not self.allow_local_fallback:
raise ValueError("Local fallback delegation is disabled")
descriptor = self.registry.get_agent("local-subagent")
if descriptor is None:
raise ValueError("Local subagent is not available")
return descriptor
if strategy == "plugin":
if not self.allow_plugin_delegation:
raise ValueError("Plugin delegation is disabled")
suggestions = [
agent for agent in self.registry.suggest_agents(task)
if agent.kind == "local_prompt" and agent.source == "plugin"
@ -494,15 +533,46 @@ class DelegationManager:
return suggestions[0]
raise ValueError("No matching A2A agent found")
suggestions = self.registry.suggest_agents(task, limit=1)
# Persistent sub-agent 管理是本地工作区变更任务,必须留在本地执行,
# 不能自动委派给远端 A2A agent否则远端看不到本地规范和状态。
if self._is_persistent_subagent_task(task):
if not self.allow_local_fallback:
raise ValueError("Persistent sub-agent management requires local fallback delegation")
descriptor = self.registry.get_agent("local-subagent")
if descriptor is None:
raise ValueError("Local fallback agent is not available")
return descriptor
suggestions = [
agent for agent in self.registry.suggest_agents(task, limit=5)
if self._descriptor_allowed(agent)
]
if suggestions:
return suggestions[0]
# 自动路由一个都猜不到时,最后回到本地兜底 agent。
if not self.allow_local_fallback:
raise ValueError("No allowed agent found for delegation")
descriptor = self.registry.get_agent("local-subagent")
if descriptor is None:
raise ValueError("Local fallback agent is not available")
return descriptor
@classmethod
def _is_persistent_subagent_task(cls, task: str) -> bool:
text = (task or "").strip()
if not text:
return False
matched = sum(1 for pattern in cls._PERSISTENT_SUBAGENT_PATTERNS if pattern.search(text))
if matched >= 2:
return True
lowered = text.lower()
return (
("create" in lowered or "update" in lowered or "repair" in lowered or "fix" in lowered)
and ("subagent" in lowered or "sub-agent" in lowered)
)
async def _run_group(
self,
task: str,
@ -520,7 +590,10 @@ class DelegationManager:
resolved_targets.append(target)
if not resolved_targets:
# 未显式给出目标时,根据任务文本自动挑若干个候选 agent。
suggestions = self.registry.suggest_agents(task, limit=self.max_parallel_agents)
suggestions = [
agent for agent in self.registry.suggest_agents(task, limit=self.max_parallel_agents * 2)
if self._descriptor_allowed(agent)
]
resolved_targets = [agent.id for agent in suggestions]
if not resolved_targets:
raise ValueError("No agents available for group delegation")
@ -533,6 +606,7 @@ class DelegationManager:
if descriptor is None:
missing.append(item)
else:
self._ensure_descriptor_allowed(descriptor)
descriptors.append(descriptor)
if missing:
raise ValueError(f"Agent(s) not found: {', '.join(missing)}")
@ -544,7 +618,13 @@ class DelegationManager:
child_run_id = new_run_id("agent")
async with semaphore:
try:
await self._emit_agent_started(child_run_id, descriptor, label, parent_run_id=run_id)
await self._emit_agent_started(
child_run_id,
descriptor,
label,
parent_run_id=run_id,
task=task,
)
result = await self._execute_descriptor(
descriptor,
task,
@ -588,6 +668,12 @@ class DelegationManager:
"""根据 descriptor 类型执行具体 agent。"""
logger.info("Delegating '{}' to {}", label, descriptor.id)
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
) or (
descriptor.kind == "local_fallback" and not self.allow_local_fallback
):
raise ValueError(f"Delegation to '{descriptor.id}' is disabled")
# 本地执行时,把当前 run_id 写入上下文,便于更深层的 MCP/tool 事件挂父节点。
with process_run_context(process_run_id):
return await self.local_executor.run_local_task(
@ -611,6 +697,19 @@ class DelegationManager:
)
raise ValueError(f"Unsupported agent kind '{descriptor.kind}'")
def _descriptor_allowed(self, descriptor: AgentDescriptor) -> bool:
if descriptor.kind == "local_fallback":
return self.allow_local_fallback and self.allow_local_delegation
if descriptor.kind == "local_prompt":
return self.allow_local_delegation and self.allow_plugin_delegation
if descriptor.protocol == "a2a" or descriptor.kind == "a2a_remote":
return True
return False
def _ensure_descriptor_allowed(self, descriptor: AgentDescriptor) -> None:
if not self._descriptor_allowed(descriptor):
raise ValueError(f"Delegation to '{descriptor.id}' is disabled")
def _build_progress_callback(
self,
origin: dict[str, str],

View File

@ -76,6 +76,13 @@ class AgentLoop:
channels_config: ChannelsConfig | None = None,
authz_config: Any | None = None,
backend_identity: Any | None = None,
allow_spawn: bool = True,
allow_message: bool = True,
allow_cron: bool = True,
include_local_fallback: bool = True,
allow_local_delegation: bool = True,
allow_plugin_delegation: bool = True,
include_plugin_agents: bool = True,
):
from nanobot.config.schema import A2AConfig, ExecToolConfig
# 基础依赖与运行参数。
@ -95,6 +102,13 @@ class AgentLoop:
self.restrict_to_workspace = restrict_to_workspace
self.authz_config = authz_config
self.backend_identity = backend_identity
self.allow_spawn = allow_spawn
self.allow_message = allow_message
self.allow_cron = allow_cron
self.include_local_fallback = include_local_fallback
self.allow_local_delegation = allow_local_delegation
self.allow_plugin_delegation = allow_plugin_delegation
self.include_plugin_agents = include_plugin_agents
# 核心组件:上下文构建、会话管理、工具注册、子代理管理。
self.plugins = PluginLoader(workspace)
@ -106,6 +120,8 @@ class AgentLoop:
skills=self.skills,
allow_skill_cards=self.a2a_config.allow_skill_cards,
allow_workspace_agents=self.a2a_config.allow_workspace_agents,
include_local_fallback=self.include_local_fallback,
include_plugin_agents=self.include_plugin_agents,
)
self.context = ContextBuilder(
workspace,
@ -137,6 +153,9 @@ class AgentLoop:
allowed_hosts=self.a2a_config.allowed_hosts,
authz_config=self.authz_config,
backend_identity=self.backend_identity,
allow_local_delegation=self.allow_local_delegation,
allow_plugin_delegation=self.allow_plugin_delegation,
allow_local_fallback=self.include_local_fallback,
)
# 运行时状态位。
@ -193,11 +212,13 @@ class AgentLoop:
# 网络、消息、子代理工具按职责注册。
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
self.tools.register(WebFetchTool())
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
self.tools.register(SpawnTool(manager=self.delegation))
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))
# 只有注入 cron_service 时才暴露 cron 工具,避免空引用。
if self.cron_service:
if self.cron_service and self.allow_cron:
self.tools.register(CronTool(self.cron_service))
async def _connect_mcp(self) -> None:

View File

@ -236,4 +236,11 @@ You are a delegated agent spawned by the main agent to complete a specific task.
Your workspace is at: {self.workspace}
Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed)
## Special Workflow
- If the task is about creating, updating, repairing, or deleting a persistent local sub-agent, read `skills/subagent-manager/SKILL.md` before making changes.
- For persistent local sub-agents, follow only the canonical workflow from that skill.
- Do not manually create `workspace/agents/<id>/agent.json` as a substitute for a persistent sub-agent.
- Do not manually edit `workspace/agents/registry.json` to register a persistent sub-agent.
- A valid persistent sub-agent must be created through `subagentctl.py` or `/api/subagents` and must end up at `workspace/agents/<id>_agent/AGENTS.json`.
When you have completed the task, provide a clear summary of your findings or actions.{extra}"""

View File

@ -0,0 +1,259 @@
"""Persistent local sub-agent storage helpers."""
from __future__ import annotations
import json
import re
import shutil
from dataclasses import asdict, dataclass, field
from importlib.resources import files as pkg_files
from pathlib import Path
from typing import Any
from nanobot.config.schema import Config, MCPServerConfig
_INVALID_ID_RE = re.compile(r"[^a-z0-9-]+")
def normalize_subagent_id(value: str) -> str:
normalized = _INVALID_ID_RE.sub("-", str(value or "").strip().lower()).strip("-")
normalized = re.sub(r"-{2,}", "-", normalized)
if not normalized:
raise ValueError("Sub-agent id is required")
return normalized
@dataclass
class SubagentSpec:
id: str
name: str
description: str
enabled: bool = True
workspace: str = ""
system_prompt: str = ""
model: str | None = None
delegation_mode: str = "remote_a2a_only"
allow_mcp: bool = True
tags: list[str] = field(default_factory=list)
aliases: list[str] = field(default_factory=list)
mcp_servers: dict[str, dict[str, Any]] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls, payload: dict[str, Any], *, workspace_path: Path | None = None) -> "SubagentSpec":
agent_id = normalize_subagent_id(payload.get("id", ""))
name = str(payload.get("name") or agent_id).strip() or agent_id
description = str(payload.get("description") or name).strip() or name
workspace = str(payload.get("workspace") or "").strip()
if not workspace and workspace_path is not None:
workspace = str(workspace_path)
tags = [str(item).strip() for item in payload.get("tags", []) if str(item).strip()]
aliases = [str(item).strip() for item in payload.get("aliases", []) if str(item).strip()]
mcp_servers = payload.get("mcp_servers", {})
if not isinstance(mcp_servers, dict):
mcp_servers = {}
metadata = payload.get("metadata", {})
if not isinstance(metadata, dict):
metadata = {}
return cls(
id=agent_id,
name=name,
description=description,
enabled=bool(payload.get("enabled", True)),
workspace=workspace,
system_prompt=str(payload.get("system_prompt") or "").strip(),
model=(str(payload.get("model") or "").strip() or None),
delegation_mode=(str(payload.get("delegation_mode") or "remote_a2a_only").strip() or "remote_a2a_only"),
allow_mcp=bool(payload.get("allow_mcp", True)),
tags=tags,
aliases=aliases,
mcp_servers=mcp_servers,
metadata=metadata,
)
def to_dict(self) -> dict[str, Any]:
payload = asdict(self)
if not self.model:
payload["model"] = None
return payload
class LocalSubagentStore:
"""Persist sub-agent definitions under `<workspace>/agents/<id>_agent/`."""
def __init__(self, workspace: Path):
self.workspace = workspace.expanduser().resolve()
self.directory = self.workspace / "agents"
def list_subagents(self) -> list[SubagentSpec]:
if not self.directory.exists():
return []
result: list[SubagentSpec] = []
for child in sorted(self.directory.iterdir()):
agents_json = child / "AGENTS.json"
if not child.is_dir() or not agents_json.exists():
continue
try:
payload = json.loads(agents_json.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
continue
if not isinstance(payload, dict):
continue
result.append(SubagentSpec.from_dict(payload, workspace_path=child))
return result
def get_subagent(self, agent_id: str) -> SubagentSpec | None:
path = self.agents_json_path(agent_id)
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return None
if not isinstance(payload, dict):
return None
return SubagentSpec.from_dict(payload, workspace_path=self.subagent_dir(agent_id))
def upsert_subagent(self, payload: dict[str, Any], config: Config) -> SubagentSpec:
agent_id = normalize_subagent_id(payload.get("id", ""))
workspace_path = self.subagent_dir(agent_id)
spec = SubagentSpec.from_dict(payload, workspace_path=workspace_path)
self._ensure_workspace(workspace_path)
spec.workspace = str(workspace_path)
self._sync_agents_md(workspace_path, spec)
self.agents_json_path(agent_id).write_text(
json.dumps(spec.to_dict(), indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
from nanobot.agent.agent_registry import WorkspaceAgentStore
WorkspaceAgentStore(self.workspace).upsert_agent(self.build_registry_record(spec, config))
return spec
def delete_subagent(self, agent_id: str) -> bool:
agent_id = normalize_subagent_id(agent_id)
target = self.subagent_dir(agent_id)
if not target.exists():
return False
from nanobot.agent.agent_registry import WorkspaceAgentStore
WorkspaceAgentStore(self.workspace).delete_agent(agent_id)
shutil.rmtree(target)
return True
def subagent_dir(self, agent_id: str) -> Path:
return self.directory / f"{normalize_subagent_id(agent_id)}_agent"
def agents_json_path(self, agent_id: str) -> Path:
return self.subagent_dir(agent_id) / "AGENTS.json"
def local_base_url(self, config: Config, agent_id: str) -> str:
return f"http://127.0.0.1:{int(config.gateway.port)}/subagents/{normalize_subagent_id(agent_id)}"
def build_registry_record(self, spec: SubagentSpec, config: Config) -> dict[str, Any]:
base_url = self.local_base_url(config, spec.id)
card_url = f"{base_url}/.well-known/agent-card"
return {
"id": spec.id,
"name": spec.name,
"description": spec.description,
"protocol": "a2a",
"base_url": base_url,
"endpoint": f"{base_url}/rpc",
"card_url": card_url,
"enabled": spec.enabled,
"tags": sorted(set(["local-subagent", *spec.tags])),
"aliases": sorted(set([spec.name, *spec.aliases])),
"metadata": {
**spec.metadata,
"workspace": spec.workspace,
"managed_by": "subagent-manager",
"local_subagent": True,
},
"capabilities": {"streaming": False},
"support_group": False,
"support_streaming": False,
}
@staticmethod
def build_agent_card(spec: SubagentSpec, config: Config) -> dict[str, Any]:
base_url = f"http://127.0.0.1:{int(config.gateway.port)}/subagents/{spec.id}"
rpc_url = f"{base_url}/rpc"
return {
"id": spec.id,
"name": spec.name,
"description": spec.description,
"url": rpc_url,
"preferred_transport": "jsonrpc",
"interfaces": [{"transport": "jsonrpc", "url": rpc_url}],
"capabilities": {"streaming": False},
"tags": sorted(set(["local-subagent", *spec.tags])),
"metadata": {
"workspace": spec.workspace,
"managed_by": "subagent-manager",
},
}
@staticmethod
def coerce_mcp_servers(spec: SubagentSpec) -> dict[str, MCPServerConfig]:
if not spec.allow_mcp:
return {}
result: dict[str, MCPServerConfig] = {}
for name, payload in spec.mcp_servers.items():
if not isinstance(payload, dict):
continue
try:
result[name] = MCPServerConfig.model_validate(payload)
except Exception:
continue
return result
def _ensure_workspace(self, workspace_path: Path) -> None:
workspace_path.mkdir(parents=True, exist_ok=True)
templates_dir = pkg_files("nanobot") / "templates"
for item in templates_dir.iterdir():
if not item.name.endswith(".md") or item.name == "AGENTS.md":
continue
dest = workspace_path / item.name
if not dest.exists():
dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8")
memory_dir = workspace_path / "memory"
memory_dir.mkdir(exist_ok=True)
memory_template = templates_dir / "memory" / "MEMORY.md"
memory_file = memory_dir / "MEMORY.md"
if not memory_file.exists():
memory_file.write_text(memory_template.read_text(encoding="utf-8"), encoding="utf-8")
history_file = memory_dir / "HISTORY.md"
if not history_file.exists():
history_file.write_text("", encoding="utf-8")
(workspace_path / "skills").mkdir(exist_ok=True)
def _sync_agents_md(self, workspace_path: Path, spec: SubagentSpec) -> None:
content = self._render_agents_md(spec)
(workspace_path / "AGENTS.md").write_text(content, encoding="utf-8")
@staticmethod
def _render_agents_md(spec: SubagentSpec) -> str:
prompt = spec.system_prompt.strip() or "Complete delegated tasks accurately and concisely."
return f"""# {spec.name}
You are {spec.name}, a persistent local sub-agent managed by Boardware Genius.
## Role
{spec.description}
## System Prompt
{prompt}
## Constraints
- Work only inside this workspace.
- Respond only to delegated tasks.
- Delegate only to remote A2A agents when delegation is enabled.
- Do not create or manage local sub-agents.
- Do not message end users directly.
"""