```
feat(agent): 添加对持久化子智能体的支持并增强委派管理 添加了持久化子智能体的完整生命周期管理功能,包括创建、更新、删除和查询API接口。 新增了子智能体的JSON-RPC通信协议支持,实现了远程调用和任务管理功能。 同时增强了委派管理器的功能: - 添加了对本地委派、插件委派和本地回退的开关控制 - 实现了持久化子智能体任务的自动检测和本地执行保护 - 增加了对不同委派类型的权限验证机制 修改了智能体注册表以支持插件智能体的条件性包含,并更新了工具注册逻辑以支持可选工具。 BREAKING CHANGE: 委派管理器的构造函数签名已更改,添加了新的控制参数。 ```
This commit is contained in:
@ -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`.
|
||||
@ -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()
|
||||
Reference in New Issue
Block a user