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

@ -11,6 +11,7 @@ import secrets
import shlex
import shutil
import time
import uuid
import zipfile
from pathlib import Path
from typing import TYPE_CHECKING, Any
@ -436,6 +437,21 @@ class MCPServerRequest(BaseModel):
sensitive: bool = False
class SubagentRequest(BaseModel):
id: str
name: str | None = None
description: str | None = None
system_prompt: str = ""
model: str | None = None
enabled: bool = True
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)
class OutlookConnectionRequest(BaseModel):
email: str
password: str
@ -733,6 +749,7 @@ def create_app(
app.state.auth_tokens: dict[str, str] = {}
app.state.handoff_codes: dict[str, dict[str, Any]] = {}
app.state.auth_file = _get_auth_file_path()
app.state.subagent_tasks: dict[str, dict[str, Any]] = {}
_register_routes(app)
return app
@ -1083,6 +1100,157 @@ def _register_routes(app: FastAPI) -> None:
backend_identity=config.backend_identity,
)
def _jsonrpc_error(payload_id: Any, code: int, message: str) -> JSONResponse:
return JSONResponse(
status_code=200,
content={
"jsonrpc": "2.0",
"id": payload_id,
"error": {"code": code, "message": message},
},
)
def _extract_subagent_task(params: dict[str, Any]) -> str:
message = params.get("message")
if not isinstance(message, dict):
raise ValueError("Missing 'message' object")
parts = message.get("parts")
if isinstance(parts, list):
for part in parts:
if not isinstance(part, dict):
continue
text = str(part.get("text") or "").strip()
if text:
return text
content = message.get("content")
if isinstance(content, list):
for item in content:
if not isinstance(item, dict):
continue
text = str(item.get("text") or "").strip()
if text:
return text
raise ValueError("A2A message does not contain text content")
async def _run_subagent_task(agent_id: str, task: str) -> str:
from nanobot.agent.loop import AgentLoop
from nanobot.agent.subagents import LocalSubagentStore
config: Config = app.state.config
store = LocalSubagentStore(config.workspace_path)
spec = store.get_subagent(agent_id)
if spec is None or not spec.enabled:
raise HTTPException(status_code=404, detail="Sub-agent not found")
delegation_mode = (spec.delegation_mode or "remote_a2a_only").strip().lower()
allow_spawn = delegation_mode in {"remote_a2a_only", "full"}
allow_local = delegation_mode == "full"
provider = _make_provider(config)
loop = AgentLoop(
bus=app.state.bus,
provider=provider,
workspace=Path(spec.workspace),
model=spec.model or config.agents.defaults.model,
max_iterations=config.agents.defaults.max_tool_iterations,
temperature=config.agents.defaults.temperature,
max_tokens=config.agents.defaults.max_tokens,
memory_window=config.agents.defaults.memory_window,
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
a2a_config=config.tools.a2a,
cron_service=None,
restrict_to_workspace=True,
session_manager=SessionManager(Path(spec.workspace)),
mcp_servers=LocalSubagentStore.coerce_mcp_servers(spec),
authz_config=config.authz,
backend_identity=config.backend_identity,
allow_spawn=allow_spawn,
allow_message=False,
allow_cron=False,
include_local_fallback=allow_local,
allow_local_delegation=allow_local,
allow_plugin_delegation=allow_local,
include_plugin_agents=allow_local,
)
try:
return await loop.process_direct(
task,
session_key=f"a2a:{spec.id}",
channel="system",
chat_id=spec.id,
)
finally:
await loop.close_mcp()
def _subagent_task_result(task_id: str) -> dict[str, Any] | None:
payload = app.state.subagent_tasks.get(task_id)
if not isinstance(payload, dict):
return None
result = {
"id": task_id,
"status": payload.get("status", "submitted"),
}
error = str(payload.get("error") or "").strip()
summary = str(payload.get("summary") or "").strip()
if summary:
result["summary"] = summary
if error:
result["summary"] = error
metadata = payload.get("metadata")
if isinstance(metadata, dict) and metadata:
result["metadata"] = metadata
return result
def _cancel_subagent_task(task_id: str) -> dict[str, Any] | None:
payload = app.state.subagent_tasks.get(task_id)
if not isinstance(payload, dict):
return None
task = payload.get("asyncio_task")
if isinstance(task, asyncio.Task) and not task.done():
task.cancel()
payload["status"] = "cancelled"
payload["error"] = ""
payload.setdefault("summary", "Task cancelled")
return _subagent_task_result(task_id)
def _start_subagent_task(agent_id: str, task: str) -> dict[str, Any]:
task_id = str(uuid.uuid4())
app.state.subagent_tasks[task_id] = {
"agent_id": agent_id,
"task": task,
"status": "submitted",
}
async def _runner() -> None:
app.state.subagent_tasks[task_id]["status"] = "working"
try:
summary = await _run_subagent_task(agent_id, task)
app.state.subagent_tasks[task_id]["status"] = "completed"
app.state.subagent_tasks[task_id]["summary"] = summary
except asyncio.CancelledError:
app.state.subagent_tasks[task_id]["status"] = "cancelled"
app.state.subagent_tasks[task_id].setdefault("summary", "Task cancelled")
raise
except Exception as exc: # noqa: BLE001
app.state.subagent_tasks[task_id]["status"] = "error"
app.state.subagent_tasks[task_id]["error"] = str(exc)
app.state.subagent_tasks[task_id]["asyncio_task"] = asyncio.create_task(_runner())
return _subagent_task_result(task_id) or {"id": task_id, "status": "submitted"}
def _serialize_subagent(spec: Any, config: Config) -> dict[str, Any]:
from nanobot.agent.subagents import LocalSubagentStore
payload = spec.to_dict()
base_url = LocalSubagentStore(config.workspace_path).local_base_url(config, spec.id)
payload["base_url"] = base_url
payload["endpoint"] = f"{base_url}/rpc"
payload["card_url"] = f"{base_url}/.well-known/agent-card"
return payload
def _require_authenticated_user(authorization: str | None = Header(default=None)) -> str:
return _require_web_user(app, authorization)
@ -1125,6 +1293,98 @@ def _register_routes(app: FastAPI) -> None:
frontend_netloc = f"{frontend_host}:{frontend_port}" if frontend_port else frontend_host
return urlunsplit((api_parts.scheme or "http", frontend_netloc, "", "", "")).rstrip("/")
@app.get("/subagents/{agent_id}/.well-known/agent-card")
@app.get("/subagents/{agent_id}/.well-known/agent-card.json")
@app.get("/subagents/{agent_id}/.well-known/agent.json")
async def get_subagent_card(agent_id: str):
from nanobot.agent.subagents import LocalSubagentStore
config: Config = app.state.config
store = LocalSubagentStore(config.workspace_path)
spec = store.get_subagent(agent_id)
if spec is None or not spec.enabled:
raise HTTPException(status_code=404, detail="Sub-agent not found")
return LocalSubagentStore.build_agent_card(spec, config)
@app.post("/subagents/{agent_id}/rpc")
async def subagent_rpc(agent_id: str, payload: dict[str, Any]):
payload_id = payload.get("id")
method = str(payload.get("method") or "").strip()
params = payload.get("params")
if not isinstance(params, dict):
return _jsonrpc_error(payload_id, -32602, "Invalid params")
if method == "tasks/get":
task_id = str(params.get("id") or "").strip()
if not task_id:
return _jsonrpc_error(payload_id, -32602, "Missing task id")
result = _subagent_task_result(task_id)
if result is None:
return _jsonrpc_error(payload_id, -32602, "Unknown task id")
return {
"jsonrpc": "2.0",
"id": payload_id,
"result": {"task": result},
}
if method == "tasks/cancel":
task_id = str(params.get("id") or "").strip()
if not task_id:
return _jsonrpc_error(payload_id, -32602, "Missing task id")
result = _cancel_subagent_task(task_id)
if result is None:
return _jsonrpc_error(payload_id, -32602, "Unknown task id")
return {
"jsonrpc": "2.0",
"id": payload_id,
"result": {"task": result},
}
if method == "tasks/send":
try:
task = _extract_subagent_task(params)
except ValueError as exc:
return _jsonrpc_error(payload_id, -32602, str(exc))
result = _start_subagent_task(agent_id, task)
return {
"jsonrpc": "2.0",
"id": payload_id,
"result": {"task": result},
}
if method != "message/send":
return _jsonrpc_error(payload_id, -32601, f"Method '{method}' not found")
try:
task = _extract_subagent_task(params)
except ValueError as exc:
return _jsonrpc_error(payload_id, -32602, str(exc))
try:
response = await _run_subagent_task(agent_id, task)
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
logger.exception("Sub-agent RPC failed for {}", agent_id)
return _jsonrpc_error(payload_id, -32000, str(exc))
return {
"jsonrpc": "2.0",
"id": payload_id,
"result": {
"message": {
"role": "agent",
"parts": [
{
"type": "text",
"kind": "text",
"text": response,
}
],
}
},
}
def _local_backend_view(config: Config) -> dict[str, Any]:
return {
"backend_id": config.backend_identity.backend_id,
@ -2473,6 +2733,55 @@ def _register_routes(app: FastAPI) -> None:
})
return result
@app.get("/api/subagents")
async def list_subagents():
"""List persistent local sub-agents."""
from nanobot.agent.subagents import LocalSubagentStore
config: Config = app.state.config
store = LocalSubagentStore(config.workspace_path)
return [_serialize_subagent(spec, config) for spec in store.list_subagents()]
@app.get("/api/subagents/{agent_id}")
async def get_subagent(agent_id: str):
"""Get one persistent local sub-agent."""
from nanobot.agent.subagents import LocalSubagentStore
config: Config = app.state.config
store = LocalSubagentStore(config.workspace_path)
spec = store.get_subagent(agent_id)
if spec is None:
raise HTTPException(status_code=404, detail="Sub-agent not found")
return _serialize_subagent(spec, config)
@app.post("/api/subagents")
async def create_subagent(req: SubagentRequest):
"""Create or replace a persistent local sub-agent."""
from nanobot.agent.subagents import LocalSubagentStore
config: Config = app.state.config
store = LocalSubagentStore(config.workspace_path)
spec = store.upsert_subagent(req.model_dump(), config)
return _serialize_subagent(spec, config)
@app.put("/api/subagents/{agent_id}")
async def update_subagent(agent_id: str, req: SubagentRequest):
"""Update a persistent local sub-agent."""
if agent_id != req.id:
raise HTTPException(status_code=400, detail="Path id must match body id")
return await create_subagent(req)
@app.delete("/api/subagents/{agent_id}")
async def delete_subagent(agent_id: str):
"""Delete a persistent local sub-agent."""
from nanobot.agent.subagents import LocalSubagentStore
config: Config = app.state.config
store = LocalSubagentStore(config.workspace_path)
if store.delete_subagent(agent_id):
return {"ok": True, "id": agent_id}
raise HTTPException(status_code=404, detail="Sub-agent not found")
@app.get("/api/agents")
async def list_agents():
"""List unified agents from workspace, plugins, skills, and local fallback."""