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