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

@ -0,0 +1,82 @@
---
name: subagent-manager
description: Create, inspect, update, and remove persistent local A2A sub-agents. Use when the user wants Boardware Genius to manage sub-agents with their own workspace under ~/.nanobot/workspace/agents/<id>_agent, their own AGENTS.json and AGENTS.md, and local A2A visibility in the agent list.
---
# Subagent Manager
Use this skill when the user wants to create or manage a persistent local sub-agent.
## Required Rules
- Persistent sub-agents must be created and updated only through `subagentctl.py` or `/api/subagents`.
- Treat `~/.nanobot/workspace/agents/<id>_agent/AGENTS.json` as the source of truth.
- Do not create a sub-agent by manually editing `workspace/agents/registry.json`.
- Do not create ad-hoc layouts such as `workspace/agents/<id>/agent.json`, `main.py`, or `README.md` as a substitute for a persistent sub-agent.
- Do not write `protocol: "local"` registry records for persistent sub-agents. A valid persistent sub-agent is registered automatically as local A2A with `protocol: "a2a"`.
- Prefer the bundled script over hand-editing JSON files, because the script keeps `AGENTS.json`, `AGENTS.md`, and the registry entry consistent.
## Workflow
1. Inspect the current sub-agents first:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py list`
2. Create or update the sub-agent with:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py create ...`
3. Verify the generated workspace:
`~/.nanobot/workspace/agents/<id>_agent/`
4. Verify the agent registry entry exists by checking `/api/agents` or `workspace/agents/registry.json`.
5. If the user wants custom skills, edit files under:
`~/.nanobot/workspace/agents/<id>_agent/skills/`
## Creation Standard
When the user asks for a new specialized sub-agent, always:
1. Choose a stable kebab-case id.
2. Create it with `subagentctl.py create` or `POST /api/subagents`.
3. Confirm the generated workspace is `~/.nanobot/workspace/agents/<id>_agent/`.
4. Confirm `AGENTS.json` exists in that directory.
5. Confirm the unified agent list shows the same id as a managed sub-agent entry.
If the user asks for "an agent for X", interpret that as a persistent sub-agent when they want a reusable local worker with its own prompt, memory, skills, or MCP setup.
## Repair Standard
If you find a malformed "sub-agent" created through the wrong path, repair it instead of reusing the broken layout:
1. Read any existing metadata that is useful, such as id, name, description, prompt, tags, aliases, or MCP config.
2. Recreate the agent through `subagentctl.py create` or `/api/subagents`.
3. Verify the new canonical directory `~/.nanobot/workspace/agents/<id>_agent/AGENTS.json`.
4. Remove the malformed directory or stale registry entry only after the canonical sub-agent exists.
Malformed examples include:
- `workspace/agents/<id>/agent.json`
- registry entries with `protocol: "local"`
- agent folders that do not contain `AGENTS.json`
## Commands
- Create:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py create --id research-agent --name "Research Agent" --description "Research-focused local A2A sub-agent" --system-prompt "Focus on research tasks and be concise."`
- Show:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py show research-agent`
- Delete:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py delete research-agent`
- Set system prompt:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py set-system-prompt research-agent --text "New prompt"`
- Add HTTP MCP:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py add-mcp-http research-agent --server-id docs --url http://127.0.0.1:9000/mcp`
- Add stdio MCP:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py add-mcp-stdio research-agent --server-id localtools --command npx --arg -y --arg @modelcontextprotocol/server-filesystem`
- Remove MCP:
`uv run python nanobot/skills/subagent-manager/scripts/subagentctl.py remove-mcp research-agent --server-id docs`
## Notes
- `AGENTS.json` is the machine-readable source of truth for the sub-agent.
- `AGENTS.md` is regenerated from `AGENTS.json` when the script updates the sub-agent.
- Builtin skills remain available automatically. Workspace-specific skills live under the sub-agent workspace `skills/` directory.
- This MVP exposes the sub-agent through local A2A `message/send` only.
- New sub-agents default to `delegation_mode="remote_a2a_only"`: they can delegate outward only to remote A2A agents, not to local fallback or plugin agents.
- A valid persistent sub-agent should appear in both `/api/subagents` and `/api/agents`.

View File

@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""Manage persistent local sub-agents."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
from typing import Any
ROOT = Path(__file__).resolve().parents[4]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from nanobot.agent.subagents import LocalSubagentStore, SubagentSpec
from nanobot.config.loader import load_config
def _store():
config = load_config()
return config, LocalSubagentStore(config.workspace_path)
def _print_json(payload: Any) -> None:
print(json.dumps(payload, indent=2, ensure_ascii=False))
def _load_spec_or_die(store: LocalSubagentStore, agent_id: str) -> SubagentSpec:
spec = store.get_subagent(agent_id)
if spec is None:
raise SystemExit(f"Sub-agent not found: {agent_id}")
return spec
def _parse_key_values(items: list[str]) -> dict[str, str]:
result: dict[str, str] = {}
for item in items:
if "=" not in item:
raise SystemExit(f"Expected KEY=VALUE, got: {item}")
key, value = item.split("=", 1)
key = key.strip()
if not key:
raise SystemExit(f"Invalid empty key in: {item}")
result[key] = value
return result
def cmd_list(_: argparse.Namespace) -> None:
_, store = _store()
_print_json([spec.to_dict() for spec in store.list_subagents()])
def cmd_show(args: argparse.Namespace) -> None:
_, store = _store()
spec = _load_spec_or_die(store, args.agent_id)
_print_json(spec.to_dict())
def cmd_create(args: argparse.Namespace) -> None:
config, store = _store()
current = store.get_subagent(args.agent_id)
payload = current.to_dict() if current is not None else {"id": args.agent_id}
payload.update({
"id": args.agent_id,
"name": args.name or payload.get("name") or args.agent_id,
"description": args.description or payload.get("description") or args.name or args.agent_id,
"enabled": not args.disabled,
"delegation_mode": payload.get("delegation_mode") or "remote_a2a_only",
})
if args.system_prompt:
payload["system_prompt"] = args.system_prompt
if args.model:
payload["model"] = args.model
spec = store.upsert_subagent(payload, config)
_print_json(spec.to_dict())
def cmd_delete(args: argparse.Namespace) -> None:
_, store = _store()
deleted = store.delete_subagent(args.agent_id)
if not deleted:
raise SystemExit(f"Sub-agent not found: {args.agent_id}")
_print_json({"ok": True, "id": args.agent_id})
def cmd_set_system_prompt(args: argparse.Namespace) -> None:
config, store = _store()
spec = _load_spec_or_die(store, args.agent_id)
payload = spec.to_dict()
payload["system_prompt"] = args.text
updated = store.upsert_subagent(payload, config)
_print_json(updated.to_dict())
def cmd_add_mcp_http(args: argparse.Namespace) -> None:
config, store = _store()
spec = _load_spec_or_die(store, args.agent_id)
payload = spec.to_dict()
payload.setdefault("mcp_servers", {})
payload["mcp_servers"][args.server_id] = {
"url": args.url,
"headers": _parse_key_values(args.header),
"auth_mode": args.auth_mode,
"auth_audience": args.auth_audience,
"auth_scopes": list(args.auth_scope),
"tool_timeout": args.tool_timeout,
"sensitive": args.sensitive,
}
updated = store.upsert_subagent(payload, config)
_print_json(updated.to_dict())
def cmd_add_mcp_stdio(args: argparse.Namespace) -> None:
config, store = _store()
spec = _load_spec_or_die(store, args.agent_id)
payload = spec.to_dict()
payload.setdefault("mcp_servers", {})
payload["mcp_servers"][args.server_id] = {
"command": args.command,
"args": list(args.arg),
"env": _parse_key_values(args.env),
"auth_mode": args.auth_mode,
"auth_audience": args.auth_audience,
"auth_scopes": list(args.auth_scope),
"tool_timeout": args.tool_timeout,
"sensitive": args.sensitive,
}
updated = store.upsert_subagent(payload, config)
_print_json(updated.to_dict())
def cmd_remove_mcp(args: argparse.Namespace) -> None:
config, store = _store()
spec = _load_spec_or_die(store, args.agent_id)
payload = spec.to_dict()
mcp_servers = payload.setdefault("mcp_servers", {})
mcp_servers.pop(args.server_id, None)
updated = store.upsert_subagent(payload, config)
_print_json(updated.to_dict())
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Manage persistent local sub-agents")
sub = parser.add_subparsers(dest="command", required=True)
list_parser = sub.add_parser("list", help="List sub-agents")
list_parser.set_defaults(func=cmd_list)
show_parser = sub.add_parser("show", help="Show one sub-agent")
show_parser.add_argument("agent_id")
show_parser.set_defaults(func=cmd_show)
create_parser = sub.add_parser("create", help="Create or update a sub-agent")
create_parser.add_argument("--id", dest="agent_id", required=True)
create_parser.add_argument("--name", default="")
create_parser.add_argument("--description", default="")
create_parser.add_argument("--system-prompt", default="")
create_parser.add_argument("--model", default="")
create_parser.add_argument("--disabled", action="store_true")
create_parser.set_defaults(func=cmd_create)
delete_parser = sub.add_parser("delete", help="Delete a sub-agent")
delete_parser.add_argument("agent_id")
delete_parser.set_defaults(func=cmd_delete)
prompt_parser = sub.add_parser("set-system-prompt", help="Update the system prompt")
prompt_parser.add_argument("agent_id")
prompt_parser.add_argument("--text", required=True)
prompt_parser.set_defaults(func=cmd_set_system_prompt)
http_parser = sub.add_parser("add-mcp-http", help="Add an HTTP MCP server")
http_parser.add_argument("agent_id")
http_parser.add_argument("--server-id", required=True)
http_parser.add_argument("--url", required=True)
http_parser.add_argument("--header", action="append", default=[])
http_parser.add_argument("--auth-mode", default="none")
http_parser.add_argument("--auth-audience", default="")
http_parser.add_argument("--auth-scope", action="append", default=[])
http_parser.add_argument("--tool-timeout", type=int, default=30)
http_parser.add_argument("--sensitive", action="store_true")
http_parser.set_defaults(func=cmd_add_mcp_http)
stdio_parser = sub.add_parser("add-mcp-stdio", help="Add a stdio MCP server")
stdio_parser.add_argument("agent_id")
stdio_parser.add_argument("--server-id", required=True)
stdio_parser.add_argument("--command", required=True)
stdio_parser.add_argument("--arg", action="append", default=[])
stdio_parser.add_argument("--env", action="append", default=[])
stdio_parser.add_argument("--auth-mode", default="none")
stdio_parser.add_argument("--auth-audience", default="")
stdio_parser.add_argument("--auth-scope", action="append", default=[])
stdio_parser.add_argument("--tool-timeout", type=int, default=30)
stdio_parser.add_argument("--sensitive", action="store_true")
stdio_parser.set_defaults(func=cmd_add_mcp_stdio)
remove_mcp = sub.add_parser("remove-mcp", help="Remove an MCP server")
remove_mcp.add_argument("agent_id")
remove_mcp.add_argument("--server-id", required=True)
remove_mcp.set_defaults(func=cmd_remove_mcp)
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()