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