修改了nanobot,往Hermes agent的风格走,进度1/3

This commit is contained in:
2026-04-20 18:11:14 +08:00
parent cdfc222c9f
commit 36882a7d7b
261 changed files with 12659 additions and 604 deletions

View File

@ -0,0 +1,35 @@
"""agent 核心模块导出入口。
这里刻意改成懒加载导出:
1. 避免 `nanobot.agent` 被导入时立即拉起一整串重量级依赖;
2. 降低循环导入概率,特别是 `loop/context/skills` 之间的交叉引用;
3. 保持对外 API 不变,调用方仍然可以 `from nanobot.agent import AgentLoop`。
"""
from __future__ import annotations
from typing import Any
__all__ = ["AgentLoop", "ContextBuilder", "MemoryStore", "SkillsLoader"]
def __getattr__(name: str) -> Any:
# 只有访问某个导出符号时才真正 import 对应模块,避免 import-time 副作用。
if name == "AgentLoop":
from nanobot.agent.loop import AgentLoop
return AgentLoop
if name == "ContextBuilder":
from nanobot.agent.context import ContextBuilder
return ContextBuilder
if name == "MemoryStore":
from nanobot.agent.memory import MemoryStore
return MemoryStore
if name == "SkillsLoader":
from nanobot.agent.skills import SkillsLoader
return SkillsLoader
# 交给 Python 默认语义处理不存在的导出名。
raise AttributeError(name)

View File

@ -0,0 +1,419 @@
"""统一 agent 注册表。
这个模块把当前工作区里“可被委派”的执行体统一抽象成 `AgentDescriptor`
1. workspace 手工登记的远端 A2A agent
2. plugin 提供的本地 prompt agent
3. skill 元数据里声明的 agent cards
4. 内置 local fallback agent。
上层委派逻辑只和 `AgentDescriptor` 打交道,不需要关心来源细节。
"""
from __future__ import annotations
import json
import re
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
from nanobot.agent.plugins import PluginLoader
from nanobot.agent.skills import SkillsLoader
_TOKEN_RE = re.compile(r"[a-z0-9_-]+")
_CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
@dataclass
class AgentDescriptor:
"""委派层使用的统一 agent 描述对象。"""
# 稳定 ID供路由、持久化和精确匹配使用。
id: str
# 面向 UI/日志的展示名。
name: str
# 简短说明,主要供模型和前端展示。
description: str
# 来源类型builtin / plugin / skill / workspace。
source: str
# 运行方式local_prompt / local_fallback / a2a_remote 等。
kind: str
# 底层协议,目前主要是 a2a 或 None。
protocol: str | None = None
plugin_name: str | None = None
skill_name: str | None = None
model: str | None = None
system_prompt: str | None = None
endpoint: str | None = None
base_url: str | None = None
card_url: str | None = None
auth_env: str | None = None
auth_mode: str = "none"
auth_audience: str | None = None
auth_scopes: list[str] = field(default_factory=list)
enabled: bool = True
tags: list[str] = field(default_factory=list)
aliases: list[str] = field(default_factory=list)
capabilities: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
support_streaming: bool = False
def matches(self, target: str) -> bool:
"""判断给定目标字符串是否命中当前 agent。"""
probe = (target or "").strip().lower()
if not probe:
return False
# 同时支持按 id / name / alias 命中,方便模型用自然语言近似引用。
candidates = {self.id.lower(), self.name.lower()}
candidates.update(alias.lower() for alias in self.aliases if alias)
return probe in candidates
def searchable_text(self) -> str:
"""构造一段用于简单相关性匹配的可搜索文本。"""
fields = [
self.id,
self.name,
self.description,
" ".join(self.tags),
" ".join(self.aliases),
self.plugin_name or "",
self.skill_name or "",
]
return " ".join(part for part in fields if part).lower()
def public_dict(self) -> dict[str, Any]:
"""导出给前端使用的安全字典。"""
data = asdict(self)
# system_prompt 属于内部实现细节,不应默认暴露给前端。
data.pop("system_prompt", None)
return data
class WorkspaceAgentStore:
"""workspace 级 agent 存储。
这里保存的是用户在 Web UI 或本地配置里手工登记的 agent
文件位置固定为 `<workspace>/agents/registry.json`。
"""
def __init__(self, workspace: Path):
self.workspace = workspace
# 单独放到 `agents/` 目录,便于和 skills / memory / files 等目录职责分离。
self.directory = workspace / "agents"
self.path = self.directory / "registry.json"
def list_agents(self) -> list[dict[str, Any]]:
"""读取并返回所有手工登记 agent。"""
if not self.path.exists():
return []
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
# 存储损坏时不抛异常拖垮主流程,直接视为空。
return []
if not isinstance(raw, list):
return []
result: list[dict[str, Any]] = []
for item in raw:
# 仅接受带 id 的对象,保证后续 registry 至少有稳定主键。
if isinstance(item, dict) and item.get("id"):
result.append(item)
return result
def save_agents(self, agents: list[dict[str, Any]]) -> None:
"""将 agent 列表完整覆写到 registry 文件。"""
self.directory.mkdir(parents=True, exist_ok=True)
self.path.write_text(
json.dumps(agents, indent=2, ensure_ascii=False),
encoding="utf-8",
)
def upsert_agent(self, agent: dict[str, Any]) -> dict[str, Any]:
"""按 id 新增或更新一个 agent 记录。"""
record = dict(agent)
agent_id = str(record.get("id", "")).strip()
if not agent_id:
raise ValueError("Agent id is required")
record["id"] = agent_id
# 对基础展示字段做最小兜底,避免后续 UI 或提示词出现空值。
record.setdefault("name", agent_id)
record.setdefault("description", record["name"])
record.setdefault("protocol", "a2a")
record.setdefault("enabled", True)
record.setdefault("tags", [])
# 先剔除旧记录再 append最后统一排序保持存储文件稳定可读。
agents = [a for a in self.list_agents() if a.get("id") != agent_id]
agents.append(record)
agents.sort(key=lambda item: item.get("id", "").lower())
self.save_agents(agents)
return record
def delete_agent(self, agent_id: str) -> bool:
"""按 id 删除一个 agent删除成功返回 True。"""
target = agent_id.strip()
if not target:
return False
agents = self.list_agents()
filtered = [a for a in agents if a.get("id") != target]
if len(filtered) == len(agents):
return False
self.save_agents(filtered)
return True
class AgentRegistry:
"""构建并查询当前可委派 agent 集合。"""
def __init__(
self,
workspace: Path,
plugins: PluginLoader | None = None,
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
# 插件和技能加载器允许外部复用同一个实例,避免重复扫描磁盘。
self.plugins = plugins or PluginLoader(workspace)
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 | 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:
for record in self.workspace_store.list_agents():
if not record.get("enabled", True):
continue
agent = self._workspace_record_to_descriptor(record)
if agent:
agents.append(agent)
# plugin agents 本质上是“带独立系统提示词的本地执行器”。
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 的静态入口。
for card in self.skills.list_skill_agent_cards():
agent = self._skill_card_to_descriptor(card)
if agent:
agents.append(agent)
if include_local_fallback:
# 永远保留一个本地兜底执行器,确保自动路由时至少有可执行目标。
agents.append(
AgentDescriptor(
id="local-subagent",
name="Local Subagent",
description="Local fallback agent that can use files, shell, and web tools.",
source="builtin",
kind="local_fallback",
protocol=None,
aliases=["subagent", "local"],
)
)
seen: set[str] = set()
result: list[AgentDescriptor] = []
for agent in agents:
# 去重规则按 id 小写匹配,优先保留先出现的来源。
key = agent.id.lower()
if key in seen:
continue
seen.add(key)
result.append(agent)
return result
def get_agent(self, target: str) -> AgentDescriptor | None:
"""按 id / name / alias 获取单个 agent。"""
probe = (target or "").strip()
if not probe:
return None
for agent in self.list_agents():
if agent.matches(probe):
return agent
return None
def suggest_agents(self, query: str, limit: int = 5) -> list[AgentDescriptor]:
"""基于简单词项打分为一段任务文本推荐 agent。"""
query_text = query or ""
query_lower = query_text.lower()
tokens = {token for token in _TOKEN_RE.findall(query_lower) if len(token) > 2}
query_cjk_bigrams = self._cjk_bigrams(query_text)
scored: list[tuple[int, AgentDescriptor]] = []
for agent in self.list_agents(include_local_fallback=False):
haystack = agent.searchable_text()
haystack_cjk_bigrams = self._cjk_bigrams(haystack)
score = 0
for token in tokens:
# token 命中一次给基础分。
if token in haystack:
score += 2
# 如果查询里直接出现了 agent 名或 id再给更高权重。
if agent.name.lower() in query_lower or agent.id.lower() in query_lower:
score += 5
for phrase in [agent.name, agent.id, *agent.tags, *agent.aliases]:
phrase_text = str(phrase or "").strip()
if not phrase_text:
continue
if phrase_text.lower() in query_lower or phrase_text in query_text:
score += 3
if query_cjk_bigrams and haystack_cjk_bigrams:
# 中文任务没有空格分词,先用 bigram overlap 做粗粒度召回。
score += min(6, len(query_cjk_bigrams & haystack_cjk_bigrams))
if score > 0:
scored.append((score, agent))
scored.sort(key=lambda item: (-item[0], item[1].name.lower()))
return [agent for _, agent in scored[:limit]]
@staticmethod
def _cjk_bigrams(text: str) -> set[str]:
"""提取中文 bigram用于中文任务的轻量召回。"""
chunks = _CJK_RE.findall(str(text or ""))
result: set[str] = set()
for chunk in chunks:
if len(chunk) == 1:
result.add(chunk)
continue
for index in range(len(chunk) - 1):
result.add(chunk[index:index + 2])
return result
def build_agents_summary(self) -> str:
"""把 agent 列表格式化成 prompt 可直接嵌入的 XML 片段。"""
agents = self.list_agents()
if not agents:
return ""
def esc(value: str) -> str:
# 这里手工转义最基础的 XML 特殊字符,避免描述文本破坏结构。
return (
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
lines = ["<agents>"]
for agent in agents:
lines.append(" <agent>")
lines.append(f" <id>{esc(agent.id)}</id>")
lines.append(f" <name>{esc(agent.name)}</name>")
lines.append(f" <source>{esc(agent.source)}</source>")
lines.append(f" <kind>{esc(agent.kind)}</kind>")
lines.append(f" <description>{esc(agent.description)}</description>")
if agent.protocol:
lines.append(f" <protocol>{esc(agent.protocol)}</protocol>")
if agent.tags:
lines.append(f" <tags>{esc(', '.join(agent.tags))}</tags>")
lines.append(" </agent>")
lines.append("</agents>")
return "\n".join(lines)
def list_public_agents(self) -> list[dict[str, Any]]:
"""列出脱敏后的 agent 结构,供 Web API 使用。"""
return [agent.public_dict() for agent in self.list_agents()]
def _workspace_record_to_descriptor(self, record: dict[str, Any]) -> AgentDescriptor | None:
"""把 workspace registry 里的原始记录转成统一描述对象。"""
protocol = str(record.get("protocol") or "a2a").lower()
if protocol != "a2a":
# 当前仅支持把 workspace 记录解释成 A2A agent。
return None
agent_id = str(record.get("id", "")).strip()
if not agent_id:
return None
name = str(record.get("name") or agent_id)
return AgentDescriptor(
id=agent_id,
name=name,
description=str(record.get("description") or name),
source="workspace",
kind="a2a_remote",
protocol="a2a",
endpoint=record.get("endpoint") or record.get("base_url"),
base_url=record.get("base_url") or record.get("endpoint"),
card_url=record.get("card_url"),
auth_env=record.get("auth_env"),
auth_mode=str(record.get("auth_mode") or "none").strip().lower() or "none",
auth_audience=(str(record.get("auth_audience") or "").strip() or None),
auth_scopes=[
str(scope).strip()
for scope in record.get("auth_scopes", [])
if str(scope).strip()
],
enabled=bool(record.get("enabled", True)),
tags=[str(tag) for tag in record.get("tags", []) if str(tag).strip()],
aliases=[
alias
for alias in [record.get("name"), *record.get("aliases", [])]
if isinstance(alias, str) and alias.strip()
],
capabilities=record.get("capabilities", {}) if isinstance(record.get("capabilities"), dict) else {},
metadata=record.get("metadata", {}) if isinstance(record.get("metadata"), dict) else {},
support_streaming=bool(record.get("support_streaming", False)),
)
def _skill_card_to_descriptor(self, card: dict[str, Any]) -> AgentDescriptor | None:
"""把 skill frontmatter 中的 agent card 转成统一描述对象。"""
card_id = str(card.get("id") or "").strip()
skill_name = str(card.get("skill_name") or "").strip()
if not card_id:
return None
name = str(card.get("name") or card_id)
return AgentDescriptor(
id=card_id,
name=name,
description=str(card.get("description") or name),
source="skill",
kind="a2a_remote",
protocol="a2a",
skill_name=skill_name or None,
endpoint=card.get("endpoint") or card.get("base_url"),
base_url=card.get("base_url") or card.get("endpoint"),
card_url=card.get("url") or card.get("card_url"),
auth_env=card.get("auth_env"),
auth_mode=str(card.get("auth_mode") or "none").strip().lower() or "none",
auth_audience=(str(card.get("auth_audience") or "").strip() or None),
auth_scopes=[
str(scope).strip()
for scope in card.get("auth_scopes", [])
if str(scope).strip()
],
tags=[str(tag) for tag in card.get("tags", []) if str(tag).strip()],
aliases=[
alias
for alias in [card.get("name"), *card.get("aliases", [])]
if isinstance(alias, str) and alias.strip()
],
capabilities=card.get("capabilities", {}) if isinstance(card.get("capabilities"), dict) else {},
metadata=card.get("metadata", {}) if isinstance(card.get("metadata"), dict) else {},
support_streaming=bool(card.get("support_streaming", False)),
)

View File

@ -0,0 +1,257 @@
"""上下文构建器:负责为每次 LLM 调用组装完整消息上下文。
本模块主要做三件事:
1. 生成 system prompt身份、运行时信息、bootstrap 文件、记忆、技能摘要);
2. 将历史消息与当前用户输入拼接成模型可消费的 messages
3. 在工具调用循环中追加 assistant/tool 消息,维持对话状态连续性。
"""
import base64
import mimetypes
import platform
from pathlib import Path
from typing import Any
from nanobot.agent.agent_registry import AgentRegistry
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader
class ContextBuilder:
"""
Agent 上下文装配器。
设计目标:
- 把“静态配置”AGENTS/USER/TOOLS 等)与“动态上下文”(时间、会话、历史)统一拼装;
- 保持 prompt 结构稳定,降低模型行为波动;
- 让工具调用前后的消息追加逻辑集中在一个位置,便于维护。
"""
# bootstrap 文件按此顺序加载并拼接,顺序会影响最终提示词语义优先级。
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
def __init__(
self,
workspace: Path,
skills_loader: SkillsLoader | None = None,
agent_registry: AgentRegistry | None = None,
):
self.workspace = workspace
# 记忆与技能都按 workspace 维度隔离,避免跨项目污染。
self.memory = MemoryStore(workspace)
# 若上层已构造好 SkillsLoader / AgentRegistry则复用避免重复扫描磁盘。
self.skills = skills_loader or SkillsLoader(workspace)
# agent_registry 可选:只有支持多 agent 委派时才会把可用 agent 摘要塞进 prompt。
self.agent_registry = agent_registry
def build_system_prompt(
self,
skill_names: list[str] | None = None,
execution_context: str | None = None,
) -> str:
"""构建 system prompt身份 + 配置 + 记忆 + 技能信息)。"""
# skill_names 目前作为接口预留,便于未来按需只加载指定技能。
parts = []
# 1) 核心身份段:包含当前时间、系统环境、工作区路径等动态信息。
parts.append(self._get_identity())
# 2) workspace 里的 bootstrap 文件(若存在)按顺序拼接。
bootstrap = self._load_bootstrap_files()
if bootstrap:
parts.append(bootstrap)
# 3) 长期记忆上下文(来自 memory/MEMORY.md 等)。
memory = self.memory.get_memory_context()
if memory:
parts.append(f"# Memory\n\n{memory}")
# 4) 技能采用“渐进加载”策略。
# 4.1 always 技能:直接把完整内容塞进当前 prompt。
always_skills = self.skills.get_always_skills()
if always_skills:
always_content = self.skills.load_skills_for_context(always_skills)
if always_content:
parts.append(f"# Active Skills\n\n{always_content}")
# 4.2 可用技能:只放摘要,具体内容让 agent 运行时按需 read_file。
# 这样可以控制 token 体积,避免把所有技能全文塞入上下文。
skills_summary = self.skills.build_skills_summary()
if skills_summary:
parts.append(f"""# Skills
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
{skills_summary}""")
if self.agent_registry:
parts.append("""# Delegation Tools
Use `spawn_subagent` when the task should go to one delegated worker.
Use `spawn_agent_team` when the task should be explored in parallel by multiple workers.
At the top level, you do not need to choose concrete downstream agents.
Use the `skills` argument when the delegated worker or team must follow specific skills.""")
if execution_context:
# `execution_context` 用于 cron / system task 这类“不是普通用户消息”的额外运行说明。
parts.append(f"# Execution Context\n\n{execution_context.strip()}")
# 各块之间用分隔线拼接,提升提示词可读性与结构稳定性。
return "\n\n---\n\n".join(parts)
def _get_identity(self) -> str:
"""生成核心身份段。"""
import time as _time
from datetime import datetime
# 时间与时区在 system prompt 中显式给出,减少模型对“当前时间”的猜测。
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
tz = _time.strftime("%Z") or "UTC"
# 固化绝对工作区路径,帮助模型生成更准确的文件操作指令。
workspace_path = str(self.workspace.expanduser().resolve())
# 运行时信息可帮助模型在跨平台命令选择时更稳健(如 macOS/Linux 差异)。
system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
return f"""# Boardware Genius
You are Boardware Genius, a helpful AI assistant.
## Current Time
{now} ({tz})
## Runtime
{runtime}
## Workspace
Your workspace is at: {workspace_path}
- Long-term memory: {workspace_path}/memory/MEMORY.md
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel.
## Tool Call Guidelines
- Before calling tools, you may briefly state your intent (e.g. "Let me check that"), but NEVER predict or describe the expected result before receiving it.
- Before modifying a file, read it first to confirm its current content.
- Do not assume a file or directory exists — use list_dir or read_file to verify.
- After writing or editing a file, re-read it if accuracy matters.
- If a tool call fails, analyze the error before retrying with a different approach.
- Do not write directly into `{workspace_path}/skills`; new or updated skills must go through the review flow before activation.
## Delegation Policy
- Solve simple tasks yourself when the work is short, direct, and does not benefit from delegation.
- Delegate only when the task is complex, multi-step, time-consuming, or benefits from specialized/parallel work.
- Use `spawn_subagent` for one focused delegated worker when only the final result matters.
- Use `spawn_agent_team` when multiple agents should explore the task in parallel, compare findings, or work across separate areas.
- Do not delegate by default if you can complete the task reliably in the current turn.
- Do not create or modify persistent local sub-agents unless the user explicitly asks for a reusable long-lived worker.
## Memory
- Remember important facts: write to {workspace_path}/memory/MEMORY.md
- Recall past events: grep {workspace_path}/memory/HISTORY.md"""
def _load_bootstrap_files(self) -> str:
"""从 workspace 读取 bootstrap 文件并拼接。"""
parts = []
for filename in self.BOOTSTRAP_FILES:
file_path = self.workspace / filename
if file_path.exists():
# 缺失文件时静默跳过,保持默认可用。
content = file_path.read_text(encoding="utf-8")
parts.append(f"## {filename}\n\n{content}")
return "\n\n".join(parts) if parts else ""
def build_messages(
self,
history: list[dict[str, Any]],
current_message: str,
skill_names: list[str] | None = None,
execution_context: str | None = None,
media: list[str] | None = None,
channel: str | None = None,
chat_id: str | None = None,
) -> list[dict[str, Any]]:
"""构建一次 LLM 调用的完整 messages 数组。"""
messages = []
# 第 1 条固定是 system prompt。
system_prompt = self.build_system_prompt(skill_names, execution_context=execution_context)
if channel and chat_id:
# 把当前会话路由信息也写入系统提示,便于模型做跨渠道决策。
system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}"
messages.append({"role": "system", "content": system_prompt})
# 追加历史消息(通常已由 SessionManager 做窗口与清洗)。
messages.extend(history)
# 追加当前用户输入;若带图片则转换为多模态 content 结构。
user_content = self._build_user_content(current_message, media)
messages.append({"role": "user", "content": user_content})
return messages
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
"""构建 user content支持文本或“文本+图片”多模态格式。"""
# 无媒体时直接走纯文本,保持最简单路径。
if not media:
return text
images = []
for path in media:
p = Path(path)
mime, _ = mimetypes.guess_type(path)
# 仅接收本地图片文件,其他媒体类型暂不注入到模型内容。
if not p.is_file() or not mime or not mime.startswith("image/"):
continue
# 按 data URL 形式内联图片,兼容支持 image_url 的 provider 接口。
b64 = base64.b64encode(p.read_bytes()).decode()
images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
# 没有合法图片时回退纯文本,避免传空数组导致模型侧解析异常。
if not images:
return text
# 多模态结构中把图片放前、文本放后,便于模型先“看图”再读文字指令。
return images + [{"type": "text", "text": text}]
def add_tool_result(
self,
messages: list[dict[str, Any]],
tool_call_id: str,
tool_name: str,
result: str
) -> list[dict[str, Any]]:
"""把工具执行结果追加到 messages。"""
messages.append({
"role": "tool",
"tool_call_id": tool_call_id,
"name": tool_name,
"content": result
})
return messages
def add_assistant_message(
self,
messages: list[dict[str, Any]],
content: str | None,
tool_calls: list[dict[str, Any]] | None = None,
reasoning_content: str | None = None,
) -> list[dict[str, Any]]:
"""把 assistant 消息追加到 messages可携带 tool_calls/reasoning"""
msg: dict[str, Any] = {"role": "assistant"}
# 始终写入 content 键:
# 部分 provider 在 key 缺失时会拒绝请求(即使值是 None 也要有该键)。
msg["content"] = content
if tool_calls:
msg["tool_calls"] = tool_calls
# reasoning_content 是“思考模型”专用字段,仅在有值时附加。
if reasoning_content is not None:
msg["reasoning_content"] = reasoning_content
messages.append(msg)
return messages

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,813 @@
"""Agent 主循环Boardware Genius 的核心处理引擎。
职责概览:
1. 从消息总线读取入站消息;
2. 结合会话历史、记忆与工作区上下文构建提示词;
3. 调用 LLM 并迭代执行工具调用;
4. 将结果写回会话并发布出站消息;
5. 在后台处理记忆归档与 MCP 工具连接生命周期。
"""
from __future__ import annotations
import asyncio
import json
import re
from contextlib import AsyncExitStack
from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger
from nanobot.agent.agent_registry import AgentRegistry
from nanobot.agent.context import ContextBuilder
from nanobot.agent.delegation import DelegationManager
from nanobot.agent.memory import MemoryStore
from nanobot.agent.plugins import PluginLoader
from nanobot.agent.process_events import process_event_sink
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.spawn import DelegationTool, SpawnAgentTeamTool, SpawnSubagentTool
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
from nanobot.session.manager import Session, SessionManager
if TYPE_CHECKING:
from nanobot.config.schema import A2AConfig, ChannelsConfig, ExecToolConfig
from nanobot.cron.service import CronService
class AgentLoop:
"""
AgentLoop 是 Boardware Genius 运行时的“对话编排器”。
一次标准处理链路:
1. 接收入站消息(来自 CLI 或外部渠道);
2. 恢复对应会话并构建当前轮上下文;
3. 调用模型,解析工具调用并执行;
4. 将本轮新增消息写入会话;
5. 输出最终回复(或由消息工具自行发送)。
"""
def __init__(
self,
bus: MessageBus,
provider: LLMProvider,
workspace: Path,
model: str | None = None,
max_iterations: int = 40,
temperature: float = 0.1,
max_tokens: int = 4096,
memory_window: int = 100,
brave_api_key: str | None = None,
exec_config: ExecToolConfig | None = None,
a2a_config: "A2AConfig | None" = None,
cron_service: CronService | None = None,
restrict_to_workspace: bool = False,
session_manager: SessionManager | None = None,
mcp_servers: dict | None = None,
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,
gateway_port: int = 18790,
):
from nanobot.config.schema import A2AConfig, ExecToolConfig
# 基础依赖与运行参数。
self.bus = bus
self.channels_config = channels_config
self.provider = provider
self.workspace = workspace
self.model = model or provider.get_default_model()
self.max_iterations = max_iterations
self.temperature = temperature
self.max_tokens = max_tokens
self.memory_window = memory_window
self.brave_api_key = brave_api_key
self.exec_config = exec_config or ExecToolConfig()
self.a2a_config = a2a_config or A2AConfig()
self.cron_service = cron_service
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)
# SkillsLoader 需要感知 plugin 附带的 skill 目录,因此单独抽到 helper 构建。
self.skills = self._build_skills_loader()
self.agent_registry = AgentRegistry(
workspace,
plugins=self.plugins,
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,
skills_loader=self.skills,
agent_registry=self.agent_registry,
)
self.sessions = session_manager or SessionManager(workspace)
self.tools = ToolRegistry()
self.subagents = SubagentManager(
provider=provider,
workspace=workspace,
model=self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
brave_api_key=brave_api_key,
exec_config=self.exec_config,
restrict_to_workspace=restrict_to_workspace,
)
self.delegation = DelegationManager(
provider=provider,
model=self.model,
workspace=workspace,
bus=bus,
registry=self.agent_registry,
skills_loader=self.skills,
local_executor=self.subagents,
timeout_seconds=self.a2a_config.timeout_seconds,
poll_interval_seconds=self.a2a_config.poll_interval_seconds,
card_cache_ttl_seconds=self.a2a_config.card_cache_ttl_seconds,
max_parallel_agents=self.a2a_config.max_parallel_agents,
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,
gateway_port=gateway_port,
)
self.subagents.set_nested_delegate(self.delegation)
# 运行时状态位。
self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False
self._mcp_connecting = False
# `_mcp_report` 保存最近一次连接结果,供 Web API 展示状态和错误信息。
self._mcp_report: dict[str, dict[str, Any]] = {}
# 会话级记忆归档控制:避免同一会话并发归档。
self._consolidating: set[str] = set() # Session keys with consolidation in progress
self._consolidation_tasks: set[asyncio.Task] = set() # Strong refs to in-flight tasks
self._consolidation_locks: dict[str, asyncio.Lock] = {}
self._register_default_tools()
def apply_runtime_config(self, *, authz_config: Any | None, backend_identity: Any | None) -> None:
"""同步运行中 loop 的鉴权上下文,避免变更后必须重启。"""
self.authz_config = authz_config
self.backend_identity = backend_identity
self.delegation.a2a_client.authz_config = authz_config
self.delegation.a2a_client.backend_identity = backend_identity
def _register_default_tools(self) -> None:
"""注册默认工具集合。"""
# 启用工作区限制时,文件读写工具仅允许访问 workspace 目录树。
allowed_dir = self.workspace if self.restrict_to_workspace else None
protected_skill_paths = [self.workspace / "skills"]
self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(
WriteFileTool(
workspace=self.workspace,
allowed_dir=allowed_dir,
protected_paths=protected_skill_paths,
)
)
self.tools.register(
EditFileTool(
workspace=self.workspace,
allowed_dir=allowed_dir,
protected_paths=protected_skill_paths,
)
)
# Shell 工具独立配置超时与目录约束。
self.tools.register(ExecTool(
working_dir=str(self.workspace),
timeout=self.exec_config.timeout,
restrict_to_workspace=self.restrict_to_workspace,
protected_paths=protected_skill_paths,
))
# 网络、消息、委派工具按职责注册。
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
self.tools.register(WebFetchTool())
if self.allow_message:
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
if self.allow_spawn:
self.tools.register(SpawnSubagentTool(manager=self.delegation))
self.tools.register(SpawnAgentTeamTool(manager=self.delegation))
# 只有注入 cron_service 时才暴露 cron 工具,避免空引用。
if self.cron_service and self.allow_cron:
self.tools.register(CronTool(self.cron_service))
async def _connect_mcp(self) -> None:
"""懒加载连接 MCP 服务器(单次连接,失败可重试)。"""
# 已连接 / 正在连接 / 未配置时直接返回。
if self._mcp_connected or self._mcp_connecting or not self._mcp_servers:
return
self._mcp_connecting = True
from nanobot.agent.tools.mcp import connect_mcp_servers
try:
# 用 AsyncExitStack 统一托管各 MCP 连接的退出清理。
self._mcp_stack = AsyncExitStack()
await self._mcp_stack.__aenter__()
self._mcp_report = await connect_mcp_servers(
self._mcp_servers,
self.tools,
self._mcp_stack,
authz_config=self.authz_config,
backend_identity=self.backend_identity,
)
self._mcp_connected = any(item.get("status") == "connected" for item in self._mcp_report.values())
except Exception as e:
# 失败后保留可重试能力:释放已建立资源,下一条消息再尝试连接。
logger.error("Failed to connect MCP servers (will retry next message): {}", e)
if self._mcp_stack:
try:
await self._mcp_stack.aclose()
except Exception:
pass
self._mcp_stack = None
self._mcp_report = {
name: {
"status": "error",
"last_error": str(e),
"tool_names": [],
"tool_count": 0,
"transport": "stdio" if getattr(cfg, "command", "") else "http",
}
for name, cfg in self._mcp_servers.items()
}
finally:
self._mcp_connecting = False
def _clear_mcp_tools(self) -> None:
"""移除当前 registry 里所有 MCP 工具包装器。"""
for tool_name in list(self.tools.tool_names):
if tool_name.startswith("mcp_"):
self.tools.unregister(tool_name)
async def reload_mcp_servers(self, mcp_servers: dict | None) -> None:
"""替换 MCP 配置并按新配置重新连接。"""
# 先彻底关闭旧连接并移除旧工具,避免新旧配置混杂。
await self.close_mcp()
self._clear_mcp_tools()
self._mcp_servers = mcp_servers or {}
self._mcp_connected = False
self._mcp_connecting = False
self._mcp_report = {}
if self._mcp_servers:
await self._connect_mcp()
def get_mcp_servers_view(self) -> list[dict[str, Any]]:
"""返回 MCP 静态配置与运行态状态合并后的视图。"""
result: list[dict[str, Any]] = []
for name in sorted(self._mcp_servers):
cfg = self._mcp_servers[name]
report = self._mcp_report.get(name, {})
sensitive = bool(getattr(cfg, "sensitive", False))
tool_names = report.get("tool_names")
if not isinstance(tool_names, list):
# 若当前 report 不完整,则退化为扫描已注册工具名进行推断。
tool_names = [
item
for item in self.tools.tool_names
if item.startswith(f"mcp_{name}_")
]
result.append({
"id": name,
"name": name,
"transport": "stdio" if getattr(cfg, "command", "") else "http",
"url": getattr(cfg, "url", "") or None,
"command": getattr(cfg, "command", "") or None,
"args": list(getattr(cfg, "args", []) or []),
"auth_mode": getattr(cfg, "auth_mode", "none") or "none",
"auth_audience": getattr(cfg, "auth_audience", "") or None,
"auth_scopes": [str(item) for item in list(getattr(cfg, "auth_scopes", []) or [])],
"headers": (
{key: "***" for key in dict(getattr(cfg, "headers", {}) or {})}
if sensitive
else dict(getattr(cfg, "headers", {}) or {})
),
"env": (
{key: "***" for key in dict(getattr(cfg, "env", {}) or {})}
if sensitive
else dict(getattr(cfg, "env", {}) or {})
),
"tool_timeout": int(getattr(cfg, "tool_timeout", 30)),
"sensitive": sensitive,
"enabled": True,
"status": report.get("status", "disconnected"),
"tool_count": int(report.get("tool_count", len(tool_names))),
"tool_names": tool_names,
"last_error": report.get("last_error"),
})
return result
def _set_tool_context(
self,
channel: str,
chat_id: str,
message_id: str | None = None,
session_key: str | None = None,
) -> None:
"""把当前请求的路由上下文写入各工具的默认目标。
设计目的:
1. 工具调用参数里不一定每次都显式传 `channel/chat_id`
2. 通过这里预注入默认值,工具可自动回落到“当前会话”;
3. 每条消息处理前都调用一次,避免沿用上一轮残留上下文。
"""
# message 工具:需要 channel/chat_id 才能发消息;
# message_id 在支持线程回复/引用回复的渠道里可用于“回这条消息”。
if message_tool := self.tools.get("message"):
# ToolRegistry.get() 返回通用 Tool | None
# 用 isinstance 确认具体类型后再调用专有 set_context()。
if isinstance(message_tool, MessageTool):
message_tool.set_context(channel, chat_id, message_id)
# 委派工具:后台任务完成后需要把结果回投到原会话,
# 因此只需记住来源 channel/chat_id。
for tool_name in ("spawn_subagent", "spawn_agent_team"):
if delegation_tool := self.tools.get(tool_name):
if isinstance(delegation_tool, DelegationTool):
delegation_tool.set_context(channel, chat_id, announce_via_bus=self._running)
# cron 工具:创建任务时会把 deliver 目标写入任务 payload
# 后续定时触发时才能把结果送回同一会话。
if cron_tool := self.tools.get("cron"):
if isinstance(cron_tool, CronTool):
cron_tool.set_context(channel, chat_id, session_key=session_key)
def _build_skills_loader(self):
"""构造可感知 plugin skill 目录的 SkillsLoader。"""
from nanobot.agent.skills import SkillsLoader
return SkillsLoader(self.workspace, extra_dirs=self.plugins.get_skill_dirs())
@staticmethod
def _strip_think(text: str | None) -> str | None:
"""去除模型输出中的 `<think>...</think>` 推理块。"""
# 某些模型会把思考内容混入最终文本,这里统一做显示层清洗。
if not text:
return None
return re.sub(r"<think>[\s\S]*?</think>", "", text).strip() or None
@staticmethod
def _tool_hint(tool_calls: list) -> str:
"""把工具调用格式化为简短提示,如 `web_search("query")`。"""
def _fmt(tc):
val = next(iter(tc.arguments.values()), None) if tc.arguments else None
if not isinstance(val, str):
return tc.name
return f'{tc.name}("{val[:40]}")' if len(val) > 40 else f'{tc.name}("{val}")'
return ", ".join(_fmt(tc) for tc in tool_calls)
async def _run_agent_loop(
self,
initial_messages: list[dict],
on_progress: Callable[..., Awaitable[None]] | None = None,
tool_registry: ToolRegistry | None = None,
) -> tuple[str | None, list[str], list[dict]]:
"""执行 agent 迭代循环。
返回:
- final_content: 最终可回复文本(无则为 None
- tools_used: 本轮调用过的工具名列表
- messages: 迭代结束后的完整消息数组(含 tool 结果)
"""
messages = initial_messages
tools = tool_registry or self.tools
iteration = 0
final_content = None
tools_used: list[str] = []
# 循环直到拿到最终回复,或达到最大迭代次数。
while iteration < self.max_iterations:
iteration += 1
# 每一轮都带上当前消息状态与工具定义,让模型决定是否继续调工具。
response = await self.provider.chat(
messages=messages,
tools=tools.get_definitions(),
model=self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
)
if response.has_tool_calls:
# 进度回调用于 CLI/渠道侧实时展示:先输出正文片段,再输出工具提示。
if on_progress:
clean = self._strip_think(response.content)
if clean:
await on_progress(clean)
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
tool_call_dicts = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments, ensure_ascii=False)
}
}
for tc in response.tool_calls
]
# 把 assistant 的“工具调用意图”写入对话,再逐个执行工具。
messages = self.context.add_assistant_message(
messages, response.content, tool_call_dicts,
reasoning_content=response.reasoning_content,
)
for tool_call in response.tool_calls:
tools_used.append(tool_call.name)
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
logger.info("Tool call: {}({})", tool_call.name, args_str[:200])
result = await tools.execute(tool_call.name, tool_call.arguments)
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
else:
# 无工具调用即视为本轮收敛,输出最终内容。
final_content = self._strip_think(response.content)
# 将最终 assistant 回复写入消息链,确保会话可持久化回放。
# 对于空/None 内容,回退到原始 content或空串避免丢失一轮回复。
persist_content = final_content if final_content is not None else (response.content or "")
messages = self.context.add_assistant_message(
messages,
persist_content,
reasoning_content=response.reasoning_content,
)
break
if final_content is None and iteration >= self.max_iterations:
# 兜底提示:防止模型反复调工具导致“无终止回复”。
logger.warning("Max iterations ({}) reached", self.max_iterations)
final_content = (
f"I reached the maximum number of tool call iterations ({self.max_iterations}) "
"without completing the task. You can try breaking the task into smaller steps."
)
# 将兜底回复也写入会话,避免刷新后看不到最终结论。
messages = self.context.add_assistant_message(messages, final_content)
return final_content, tools_used, messages
async def run(self) -> None:
"""启动常驻循环:持续消费入站消息并发布出站消息。"""
self._running = True
await self._connect_mcp()
logger.info("Agent loop started")
while self._running:
try:
# 用短超时轮询,便于 stop() 后快速退出循环。
msg = await asyncio.wait_for(
self.bus.consume_inbound(),
timeout=1.0
)
try:
response = await self._process_message(msg)
if response is not None:
await self.bus.publish_outbound(response)
elif msg.channel == "cli":
# CLI 下若消息工具已代发,仍回一个空结束包通知“本轮结束”。
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content="", metadata=msg.metadata or {},
))
except Exception as e:
# 单条消息失败不影响主循环存活。
logger.error("Error processing message: {}", e)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=f"Sorry, I encountered an error: {str(e)}"
))
except asyncio.TimeoutError:
continue
async def close_mcp(self) -> None:
"""关闭 MCP 连接并释放退出栈。"""
if self._mcp_stack:
try:
await self._mcp_stack.aclose()
except (RuntimeError, BaseExceptionGroup):
# MCP SDK 在取消清理阶段可能抛出噪声异常,这里忽略即可。
pass
self._mcp_stack = None
self._mcp_connected = False
self._mcp_connecting = False
def stop(self) -> None:
"""请求停止主循环。"""
self._running = False
logger.info("Agent loop stopping")
def _get_consolidation_lock(self, session_key: str) -> asyncio.Lock:
"""获取会话级归档锁;不存在则创建。"""
lock = self._consolidation_locks.get(session_key)
if lock is None:
lock = asyncio.Lock()
self._consolidation_locks[session_key] = lock
return lock
def _prune_consolidation_lock(self, session_key: str, lock: asyncio.Lock) -> None:
"""在锁空闲时清理缓存,避免锁字典无限增长。"""
if not lock.locked():
self._consolidation_locks.pop(session_key, None)
async def _process_message(
self,
msg: InboundMessage,
session_key: str | None = None,
on_progress: Callable[[str], Awaitable[None]] | None = None,
execution_context: str | None = None,
extra_tools: list[Tool] | None = None,
) -> OutboundMessage | None:
"""处理单条入站消息并返回出站消息(或 None"""
# system 通道用于内部任务(如 cron/heartbeat来源路由编码在 chat_id。
if msg.channel == "system":
channel, chat_id = (msg.chat_id.split(":", 1) if ":" in msg.chat_id
else ("cli", msg.chat_id))
logger.info("Processing system message from {}", msg.sender_id)
key = f"{channel}:{chat_id}"
session = self.sessions.get_or_create(key)
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"), session_key=key)
history = session.get_history(max_messages=self.memory_window)
messages = self.context.build_messages(
history=history,
current_message=msg.content,
execution_context=execution_context,
channel=channel,
chat_id=chat_id,
)
final_content, _, all_msgs = await self._run_agent_loop(messages)
self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session)
return OutboundMessage(channel=channel, chat_id=chat_id,
content=final_content or "Background task completed.")
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
key = session_key or msg.session_key
session = self.sessions.get_or_create(key)
# 内建斜杠命令:在进入模型前优先处理。
cmd = msg.content.strip().lower()
if cmd == "/new":
# `/new` 的语义是“开启新会话”,但在真正清空前要先做一次强制归档:
# - 把尚未沉淀的消息写入 MEMORY/HISTORY
# - 若归档失败则直接返回,不执行清空,避免用户上下文丢失。
# 取会话级锁并标记 consolidating防止与后台自动归档并发执行。
# (同一会话同时归档可能导致重复写入或状态错乱)
lock = self._get_consolidation_lock(session.key)
self._consolidating.add(session.key)
try:
async with lock:
# 只处理“未归档尾部”消息:
# [0:last_consolidated] 视为已经落入长期记忆,
# [last_consolidated:] 才是本次需要补归档的增量。
snapshot = session.messages[session.last_consolidated:]
if snapshot:
# 用临时 Session 包装快照,再传给 consolidate
# 1) 不污染当前 live session 对象;
# 2) 即便归档失败,也不会提前改动原会话结构。
temp = Session(key=session.key)
temp.messages = list(snapshot)
# archive_all=True对这个临时快照做“全量归档”
# 确保 /new 前的上下文尽可能完整地写入记忆文件。
if not await self._consolidate_memory(temp, archive_all=True):
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="Memory archival failed, session not cleared. Please try again.",
)
except Exception:
# 归档过程任何异常都视为失败,保持原会话不动并给出明确提示。
logger.exception("/new archival failed for {}", session.key)
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="Memory archival failed, session not cleared. Please try again.",
)
finally:
# 无论成功/失败都要撤销 in-progress 标记并清理空闲锁缓存,
# 避免会话长期卡在 consolidating 状态。
self._consolidating.discard(session.key)
self._prune_consolidation_lock(session.key, lock)
# 走到这里说明归档已成功(或本就无增量可归档),才执行真正清空。
session.clear()
# clear 后立即落盘,保证重启后状态一致。
self.sessions.save(session)
# 使内存缓存失效,后续读取将基于磁盘中的“新空会话”重新构建。
self.sessions.invalidate(session.key)
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="New session started.")
if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="Boardware Genius commands:\n/new — Start a new conversation\n/help — Show available commands")
# 异步触发记忆归档:达到窗口阈值时在后台执行,不阻塞当前回复。
unconsolidated = len(session.messages) - session.last_consolidated
if (unconsolidated >= self.memory_window and session.key not in self._consolidating):
self._consolidating.add(session.key)
lock = self._get_consolidation_lock(session.key)
async def _consolidate_and_unlock():
try:
async with lock:
await self._consolidate_memory(session)
finally:
# 无论成功失败都要解注册状态,避免会话长期卡在 consolidating。
self._consolidating.discard(session.key)
self._prune_consolidation_lock(session.key, lock)
_task = asyncio.current_task()
if _task is not None:
self._consolidation_tasks.discard(_task)
_task = asyncio.create_task(_consolidate_and_unlock())
self._consolidation_tasks.add(_task)
# 每轮处理前刷新工具上下文,并重置 message 工具的“本轮已发送”状态。
self._set_tool_context(
msg.channel,
msg.chat_id,
msg.metadata.get("message_id"),
session_key=key,
)
if message_tool := self.tools.get("message"):
if isinstance(message_tool, MessageTool):
message_tool.start_turn()
active_tools = self.tools
if extra_tools:
active_tools = self.tools.clone()
for tool in extra_tools:
active_tools.register(tool)
# 从会话中截取有限历史,避免上下文无限膨胀。
history = session.get_history(max_messages=self.memory_window)
# 组装本轮发给模型的初始消息:
# - history: 会话历史(已按窗口裁剪)
# - current_message: 用户本轮输入
# - media: 可选多模态附件(如图片)
# - channel/chat_id: 当前会话路由信息(写入 system prompt 供工具决策)
initial_messages = self.context.build_messages(
history=history,
current_message=msg.content,
execution_context=execution_context,
media=msg.media if msg.media else None,
channel=msg.channel, chat_id=msg.chat_id,
)
async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:
# `_bus_progress` 是“默认进度回调”:
# - 当 _run_agent_loop 里出现中间文本/工具提示时被调用;
# - 不走最终回复通道,而是作为“中间态事件”发到 outbound。
#
# 这样做的好处:
# 1) CLI/渠道可以实时显示“正在做什么”,而不是一直静默等待;
# 2) 进度消息与最终答复共用同一队列,但可通过 metadata 区分。
meta = dict(msg.metadata or {})
# `_progress=True`:标记这是进度事件,消费端可选择轻量渲染。
meta["_progress"] = True
# `_tool_hint=True`:标记这是工具调用提示(例如 web_search(...))。
# 消费端可按配置独立开关send_tool_hints来显示/隐藏。
meta["_tool_hint"] = tool_hint
# 进度消息仍沿用原始 channel/chat_id保证路由到当前会话。
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta,
))
# 执行核心 agent 迭代:
# - 可能多轮“模型 -> 工具 -> 模型”
# - on_progress 若外部未传,则默认走 `_bus_progress` 输出中间态
final_content, _, all_msgs = await self._run_agent_loop(
initial_messages,
on_progress=on_progress or _bus_progress,
tool_registry=active_tools,
)
if final_content is None:
# 极少数情况下模型未给出最终文本(例如异常边界),这里兜底避免空回复。
final_content = "I've completed processing but have no response to give."
# 日志只打印预览,避免超长内容污染日志输出。
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
# 把本轮新增消息assistant/tool/final写回会话并持久化到磁盘。
# `1 + len(history)` 用于跳过本轮前已存在的 system+history 部分。
self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session)
if message_tool := self.tools.get("message"):
if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
# 去重保护:
# 若本轮 agent 已通过 message 工具主动发过消息,
# 再返回 OutboundMessage 会导致渠道侧“同内容重复发送”。
# 因此返回 None交给上层按“已发过”路径结束本轮。
return None
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=final_content,
metadata=msg.metadata or {},
)
_TOOL_RESULT_MAX_CHARS = 500
def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None:
"""保存本轮新增消息到会话,并截断过长工具输出。"""
from datetime import datetime
for m in messages[skip:]:
# 不持久化 reasoning_content避免会话文件冗长且混入思考文本。
entry = {k: v for k, v in m.items() if k != "reasoning_content"}
if entry.get("role") == "tool" and isinstance(entry.get("content"), str):
content = entry["content"]
if len(content) > self._TOOL_RESULT_MAX_CHARS:
# 大工具结果只保留前缀,兼顾可读性与存储体积。
entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
entry.setdefault("timestamp", datetime.now().isoformat())
session.messages.append(entry)
session.updated_at = datetime.now()
async def _consolidate_memory(self, session, archive_all: bool = False) -> bool:
"""调用 MemoryStore 做记忆归档;成功返回 True。"""
return await MemoryStore(self.workspace).consolidate(
session, self.provider, self.model,
archive_all=archive_all, memory_window=self.memory_window,
)
async def process_system_announcement(
self,
content: str,
*,
origin_channel: str,
origin_chat_id: str,
sender_id: str = "delegation",
) -> str:
"""在无常驻 run() 的场景下,本地处理一条 system 公告。"""
await self._connect_mcp()
msg = InboundMessage(
channel="system",
sender_id=sender_id,
chat_id=f"{origin_channel}:{origin_chat_id}",
content=content,
)
response = await self._process_message(msg)
return response.content if response else ""
async def process_direct(
self,
content: str,
session_key: str = "cli:direct",
channel: str = "cli",
chat_id: str = "direct",
on_progress: Callable[[str], Awaitable[None]] | None = None,
process_event_callback: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
execution_context: str | None = None,
extra_tools: list[Tool] | None = None,
) -> str:
"""直接处理一条消息(用于 CLI 单轮或 cron 触发)。"""
# 直连模式不依赖 run() 主循环,但仍需确保 MCP 可用。
await self._connect_mcp()
msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
# process_event_sink 只在当前调用链内生效,因此不会污染其他并发请求。
with process_event_sink(process_event_callback):
response = await self._process_message(
msg,
session_key=session_key,
on_progress=on_progress,
# execution_context / extra_tools 主要服务于 cron 和其他系统触发场景。
execution_context=execution_context,
extra_tools=extra_tools,
)
return response.content if response else ""

View File

@ -0,0 +1,582 @@
"""Marketplace manager for Boardware Genius — discover, install, and manage plugin marketplaces."""
from __future__ import annotations
import json
import shutil
import subprocess
import tempfile
from dataclasses import asdict, dataclass
from pathlib import Path
from loguru import logger
@dataclass
class MarketplaceEntry:
"""A registered marketplace source."""
name: str
source: str
type: str # "local" or "git"
@dataclass
class MarketplacePluginInfo:
"""A plugin available in a marketplace."""
name: str
description: str
source_path: str # Relative path inside the marketplace (e.g. "./claude-plugins/data-toolkit")
marketplace_name: str
installed: bool
class MarketplaceManager:
"""
Manages plugin marketplaces: register/remove marketplace sources, discover
available plugins, and install/uninstall them into ``~/.nanobot/plugins/``.
Marketplace sources can be local directories or git repositories. Each
marketplace root must contain ``.claude-plugin/marketplace.json`` with the
manifest listing available plugins.
Config is persisted in ``~/.nanobot/marketplaces.json``.
Git repos are cached in ``~/.nanobot/marketplace-cache/<name>/``.
Installed plugins land in ``~/.nanobot/plugins/<plugin-name>/``.
"""
CONFIG_PATH = Path.home() / ".nanobot" / "marketplaces.json"
CACHE_DIR = Path.home() / ".nanobot" / "marketplace-cache"
PLUGINS_DIR = Path.home() / ".nanobot" / "plugins"
GIT_TIMEOUT = 60 # seconds
def __init__(
self,
config_path: Path | None = None,
cache_dir: Path | None = None,
plugins_dir: Path | None = None,
):
self.config_path = config_path or self.CONFIG_PATH
self.cache_dir = cache_dir or self.CACHE_DIR
self.plugins_dir = plugins_dir or self.PLUGINS_DIR
# ------------------------------------------------------------------ public
def list_marketplaces(self) -> list[MarketplaceEntry]:
"""Return all registered marketplaces."""
return self._load_config()
def add_marketplace(self, source: str) -> MarketplaceEntry:
"""
Register a new marketplace from a local path or git URL.
For git sources the repo is cloned (``--depth=1``) into the cache
directory and the manifest is read to determine the marketplace name.
For local sources the path must exist and contain a valid manifest.
Returns the created ``MarketplaceEntry``.
Raises ``ValueError`` on invalid source or duplicate name.
"""
source_type = self._detect_type(source)
if source_type == "git":
entry = self._add_git_marketplace(source)
else:
entry = self._add_local_marketplace(source)
# Persist — update existing entry if one with the same name exists
entries = self._load_config()
replaced = False
for i, existing in enumerate(entries):
if existing.name == entry.name:
logger.info(
"Updating existing marketplace '{}' (old source: {} → new source: {})",
entry.name,
existing.source,
entry.source,
)
entries[i] = entry
replaced = True
break
if not replaced:
entries.append(entry)
self._save_config(entries)
logger.info("Registered marketplace '{}' from {}", entry.name, entry.source)
return entry
def remove_marketplace(self, name: str) -> None:
"""
Unregister a marketplace by name.
If the marketplace was cloned from git, the cached clone is also deleted.
Raises ``ValueError`` if the marketplace is not found.
"""
entries = self._load_config()
entry = self._find_entry(entries, name)
# Clean up git cache if applicable
cache_path = self.cache_dir / name
if cache_path.exists():
shutil.rmtree(cache_path)
logger.debug("Removed cached clone at {}", cache_path)
entries = [e for e in entries if e.name != name]
self._save_config(entries)
logger.info("Removed marketplace '{}'", name)
def list_available_plugins(
self, marketplace_name: str
) -> list[MarketplacePluginInfo]:
"""
List all plugins offered by a registered marketplace.
For git marketplaces the cached clone is updated (``git pull --ff-only``)
before reading the manifest.
Raises ``ValueError`` if the marketplace is not found or the manifest
is missing/invalid.
"""
entries = self._load_config()
entry = self._find_entry(entries, marketplace_name)
root = self._resolve_root(entry)
manifest = self._read_manifest(root, entry.name)
installed_names = self._installed_plugin_names()
plugins: list[MarketplacePluginInfo] = []
for p in manifest.get("plugins", []):
pname = p.get("name", "")
if not pname:
continue
# Skip plugins whose names would be unsafe as directory names
try:
self._validate_name(pname, "plugin name")
except ValueError:
logger.warning(
"Skipping plugin with unsafe name '{}' in marketplace '{}'",
pname,
marketplace_name,
)
continue
plugins.append(
MarketplacePluginInfo(
name=pname,
description=p.get("description", ""),
source_path=p.get("source", ""),
marketplace_name=entry.name,
installed=pname in installed_names,
)
)
return plugins
def install_plugin(self, marketplace_name: str, plugin_name: str) -> Path:
"""
Install a plugin from a marketplace into ``~/.nanobot/plugins/``.
The plugin directory is copied (not symlinked) so it works even if the
marketplace source is later removed.
Returns the ``Path`` to the installed plugin directory.
Raises ``ValueError`` if the marketplace or plugin is not found, or if
the plugin source directory does not exist.
"""
self._validate_name(plugin_name, "plugin name")
entries = self._load_config()
entry = self._find_entry(entries, marketplace_name)
root = self._resolve_root(entry)
manifest = self._read_manifest(root, entry.name)
plugin_meta = self._find_plugin_in_manifest(manifest, plugin_name, entry.name)
source_rel = plugin_meta.get("source", "")
source_dir = (root / source_rel).resolve()
root_resolved = root.resolve()
# Guard against path traversal — source_dir must be inside the marketplace root
if not str(source_dir).startswith(str(root_resolved)):
raise ValueError(
f"Plugin source '{source_rel}' resolves outside the marketplace "
f"root ({root_resolved}). This looks like a path traversal attempt."
)
if not source_dir.is_dir():
raise ValueError(
f"Plugin source directory does not exist: {source_dir}"
)
dest = self.plugins_dir / plugin_name
if dest.exists():
logger.debug("Removing existing plugin dir at {}", dest)
shutil.rmtree(dest)
self.plugins_dir.mkdir(parents=True, exist_ok=True)
shutil.copytree(source_dir, dest)
logger.info(
"Installed plugin '{}' from marketplace '{}'{}",
plugin_name,
entry.name,
dest,
)
return dest
def update_marketplace(self, name: str) -> MarketplaceEntry:
"""
Update a marketplace's cached data.
For git marketplaces: clones if cache is missing, pulls if it exists.
For local marketplaces: validates the path still exists.
Returns the ``MarketplaceEntry``.
Raises ``ValueError`` if the marketplace is not registered or the
update fails.
"""
entries = self._load_config()
entry = self._find_entry(entries, name)
if entry.type == "git":
cache_path = self.cache_dir / name
if not cache_path.exists():
# Cache missing (e.g. fresh Docker container) — clone
self.cache_dir.mkdir(parents=True, exist_ok=True)
try:
subprocess.run(
["git", "clone", "--depth=1", entry.source, str(cache_path)],
capture_output=True,
timeout=self.GIT_TIMEOUT,
check=True,
)
logger.info(
"Cloned marketplace '{}' from {}", name, entry.source
)
except subprocess.CalledProcessError as e:
stderr = (
e.stderr.decode(errors="replace").strip()
if e.stderr
else ""
)
raise ValueError(
f"Failed to clone marketplace '{name}': {stderr}"
) from e
except subprocess.TimeoutExpired as e:
raise ValueError(
f"Git clone timed out after {self.GIT_TIMEOUT}s "
f"for marketplace '{name}'"
) from e
else:
# Cache exists — pull latest
try:
subprocess.run(
["git", "pull", "--ff-only"],
cwd=cache_path,
capture_output=True,
timeout=self.GIT_TIMEOUT,
check=True,
)
logger.info(
"Updated marketplace '{}' from {}", name, entry.source
)
except subprocess.CalledProcessError as e:
stderr = (
e.stderr.decode(errors="replace").strip()
if e.stderr
else ""
)
raise ValueError(
f"Failed to update marketplace '{name}': {stderr}"
) from e
except subprocess.TimeoutExpired as e:
raise ValueError(
f"Git pull timed out after {self.GIT_TIMEOUT}s "
f"for marketplace '{name}'"
) from e
else:
# Local marketplace — just verify path still exists
path = Path(entry.source).expanduser().resolve()
if not path.is_dir():
raise ValueError(
f"Local marketplace directory no longer exists: {path}"
)
logger.debug("Local marketplace '{}' verified at {}", name, path)
return entry
def uninstall_plugin(self, plugin_name: str) -> None:
"""
Remove an installed plugin from ``~/.nanobot/plugins/``.
Raises ``ValueError`` if the plugin directory does not exist.
"""
dest = self.plugins_dir / plugin_name
if not dest.exists():
raise ValueError(
f"Plugin '{plugin_name}' is not installed (expected at {dest})"
)
shutil.rmtree(dest)
logger.info("Uninstalled plugin '{}'", plugin_name)
# ------------------------------------------------------------------ config
def _load_config(self) -> list[MarketplaceEntry]:
"""Load the marketplaces config file. Returns empty list on missing/corrupt file."""
if not self.config_path.exists():
return []
try:
raw = json.loads(self.config_path.read_text(encoding="utf-8"))
if not isinstance(raw, list):
logger.warning(
"marketplaces.json is not a list, resetting to empty"
)
return []
return [
MarketplaceEntry(
name=item["name"],
source=item["source"],
type=item["type"],
)
for item in raw
if isinstance(item, dict) and "name" in item and "source" in item and "type" in item
]
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to read marketplaces.json: {}", e)
return []
def _save_config(self, entries: list[MarketplaceEntry]) -> None:
"""Persist the marketplaces list to disk."""
self.config_path.parent.mkdir(parents=True, exist_ok=True)
data = [asdict(e) for e in entries]
self.config_path.write_text(
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
# ------------------------------------------------------------------ helpers
@staticmethod
def _validate_name(name: str, label: str = "name") -> None:
"""Reject names that could cause path traversal when used in filesystem paths.
Raises ``ValueError`` if *name* contains ``/``, ``\\``, or is ``.`` / `..``.
"""
if "/" in name or "\\" in name or name in (".", ".."):
raise ValueError(
f"Invalid {label} '{name}': must not contain path separators "
f"or be '.' / '..'"
)
@staticmethod
def _detect_type(source: str) -> str:
"""Determine whether a source string is a git URL or a local path."""
if (
source.startswith("http://")
or source.startswith("https://")
or source.startswith("ssh://")
or source.startswith("git://")
or source.startswith("git@")
or source.endswith(".git")
):
return "git"
return "local"
def _find_entry(
self, entries: list[MarketplaceEntry], name: str
) -> MarketplaceEntry:
"""Lookup a marketplace entry by name or raise ValueError."""
for entry in entries:
if entry.name == name:
return entry
raise ValueError(
f"Marketplace '{name}' is not registered. "
f"Use add_marketplace() first."
)
def _resolve_root(self, entry: MarketplaceEntry) -> Path:
"""
Return the filesystem root of a marketplace.
For local marketplaces this is the source path directly.
For git marketplaces this is the cached clone, updated with
``git pull --ff-only`` before returning.
"""
if entry.type == "git":
cache_path = self.cache_dir / entry.name
if not cache_path.exists():
raise ValueError(
f"Git cache for marketplace '{entry.name}' not found at "
f"{cache_path}. Try removing and re-adding the marketplace."
)
# Update the cached clone
try:
subprocess.run(
["git", "pull", "--ff-only"],
cwd=cache_path,
capture_output=True,
timeout=self.GIT_TIMEOUT,
check=True,
)
logger.debug("Updated git cache for '{}'", entry.name)
except subprocess.CalledProcessError as e:
logger.warning(
"git pull failed for '{}': {}",
entry.name,
e.stderr.decode(errors="replace").strip() if e.stderr else str(e),
)
except subprocess.TimeoutExpired:
logger.warning("git pull timed out for '{}'", entry.name)
return cache_path
else:
path = Path(entry.source).expanduser().resolve()
if not path.is_dir():
raise ValueError(
f"Local marketplace directory does not exist: {path}"
)
return path
def _read_manifest(self, root: Path, marketplace_name: str) -> dict:
"""Read marketplace manifest, or auto-discover plugins if no manifest exists.
Looks for ``.claude-plugin/marketplace.json`` first. If that file is
missing, falls back to scanning ``claude-plugins/`` for subdirectories
that contain a ``plugin.json`` or ``.claude-plugin/plugin.json``.
"""
manifest_path = root / ".claude-plugin" / "marketplace.json"
if manifest_path.exists():
try:
data = json.loads(manifest_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
raise ValueError(
f"Failed to parse marketplace manifest at {manifest_path}: {e}"
) from e
if not isinstance(data, dict):
raise ValueError(
f"Marketplace manifest at {manifest_path} must be a JSON object"
)
if "plugins" not in data or not isinstance(data["plugins"], list):
raise ValueError(
f"Marketplace manifest at {manifest_path} missing 'plugins' array"
)
return data
# Fallback: auto-discover plugins under claude-plugins/
return self._auto_discover_plugins(root, marketplace_name)
def _auto_discover_plugins(self, root: Path, marketplace_name: str) -> dict:
"""Scan ``claude-plugins/`` for plugin directories and build a manifest."""
plugins_dir = root / "claude-plugins"
if not plugins_dir.is_dir():
raise ValueError(
f"Marketplace at {root} has no .claude-plugin/marketplace.json "
f"and no claude-plugins/ directory to scan."
)
plugins: list[dict] = []
for plugin_dir in sorted(plugins_dir.iterdir()):
if not plugin_dir.is_dir():
continue
# Read plugin metadata
name = plugin_dir.name
description = ""
for candidate in (plugin_dir / "plugin.json", plugin_dir / ".claude-plugin" / "plugin.json"):
if candidate.exists():
try:
meta = json.loads(candidate.read_text(encoding="utf-8"))
name = meta.get("name", name)
description = meta.get("description", "")
except (json.JSONDecodeError, OSError):
pass
break
plugins.append({
"name": name,
"source": f"./claude-plugins/{plugin_dir.name}",
"description": description,
})
logger.info(
"Auto-discovered {} plugins in marketplace '{}' (no manifest file)",
len(plugins), marketplace_name,
)
return {"name": marketplace_name, "plugins": plugins}
@staticmethod
def _find_plugin_in_manifest(
manifest: dict, plugin_name: str, marketplace_name: str
) -> dict:
"""Find a plugin entry by name in a marketplace manifest."""
for p in manifest.get("plugins", []):
if p.get("name") == plugin_name:
return p
raise ValueError(
f"Plugin '{plugin_name}' not found in marketplace '{marketplace_name}'. "
f"Available: {[p.get('name') for p in manifest.get('plugins', [])]}"
)
def _installed_plugin_names(self) -> set[str]:
"""Return the set of currently installed plugin directory names."""
if not self.plugins_dir.exists():
return set()
return {d.name for d in self.plugins_dir.iterdir() if d.is_dir()}
# ------------------------------------------------------------------ git
def _add_git_marketplace(self, source: str) -> MarketplaceEntry:
"""Clone a git URL, read the manifest to get the name, move to cache."""
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp) / "repo"
logger.debug("Cloning {} into temp dir", source)
try:
subprocess.run(
["git", "clone", "--depth=1", source, str(tmp_path)],
capture_output=True,
timeout=self.GIT_TIMEOUT,
check=True,
)
except subprocess.CalledProcessError as e:
stderr = e.stderr.decode(errors="replace").strip() if e.stderr else ""
raise ValueError(
f"Failed to clone git repository '{source}': {stderr}"
) from e
except subprocess.TimeoutExpired as e:
raise ValueError(
f"Git clone timed out after {self.GIT_TIMEOUT}s for '{source}'"
) from e
# Derive a fallback name from the git URL (e.g. "my-marketplace" from ".../my-marketplace.git")
fallback_name = source.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git") or "unknown"
manifest = self._read_manifest(tmp_path, fallback_name)
name = manifest.get("name")
if not name or not isinstance(name, str):
name = fallback_name
self._validate_name(name, "marketplace name")
# Move to permanent cache location
cache_path = self.cache_dir / name
if cache_path.exists():
shutil.rmtree(cache_path)
self.cache_dir.mkdir(parents=True, exist_ok=True)
shutil.move(str(tmp_path), str(cache_path))
logger.debug("Cached git marketplace '{}' at {}", name, cache_path)
return MarketplaceEntry(name=name, source=source, type="git")
def _add_local_marketplace(self, source: str) -> MarketplaceEntry:
"""Register a local directory as a marketplace source."""
path = Path(source).expanduser().resolve()
if not path.is_dir():
raise ValueError(
f"Local marketplace path does not exist or is not a directory: {path}"
)
fallback_name = path.name
manifest = self._read_manifest(path, fallback_name)
name = manifest.get("name")
if not name or not isinstance(name, str):
name = fallback_name
self._validate_name(name, "marketplace name")
return MarketplaceEntry(name=name, source=str(path), type="local")

View File

@ -0,0 +1,143 @@
"""Memory system for persistent agent memory."""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING
from loguru import logger
from nanobot.utils.helpers import ensure_dir
if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider
from nanobot.session.manager import Session
_SAVE_MEMORY_TOOL = [
{
"type": "function",
"function": {
"name": "save_memory",
"description": "Save the memory consolidation result to persistent storage.",
"parameters": {
"type": "object",
"properties": {
"history_entry": {
"type": "string",
"description": "A paragraph (2-5 sentences) summarizing key events/decisions/topics. "
"Start with [YYYY-MM-DD HH:MM]. Include detail useful for grep search.",
},
"memory_update": {
"type": "string",
"description": "Full updated long-term memory as markdown. Include all existing "
"facts plus new ones. Return unchanged if nothing new.",
},
},
"required": ["history_entry", "memory_update"],
},
},
}
]
class MemoryStore:
"""Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log)."""
def __init__(self, workspace: Path):
self.memory_dir = ensure_dir(workspace / "memory")
self.memory_file = self.memory_dir / "MEMORY.md"
self.history_file = self.memory_dir / "HISTORY.md"
def read_long_term(self) -> str:
if self.memory_file.exists():
return self.memory_file.read_text(encoding="utf-8")
return ""
def write_long_term(self, content: str) -> None:
self.memory_file.write_text(content, encoding="utf-8")
def append_history(self, entry: str) -> None:
with open(self.history_file, "a", encoding="utf-8") as f:
f.write(entry.rstrip() + "\n\n")
def get_memory_context(self) -> str:
long_term = self.read_long_term()
return f"## Long-term Memory\n{long_term}" if long_term else ""
async def consolidate(
self,
session: Session,
provider: LLMProvider,
model: str,
*,
archive_all: bool = False,
memory_window: int = 50,
) -> bool:
"""Consolidate old messages into MEMORY.md + HISTORY.md via LLM tool call.
Returns True on success (including no-op), False on failure.
"""
if archive_all:
old_messages = session.messages
keep_count = 0
logger.info("Memory consolidation (archive_all): {} messages", len(session.messages))
else:
keep_count = memory_window // 2
if len(session.messages) <= keep_count:
return True
if len(session.messages) - session.last_consolidated <= 0:
return True
old_messages = session.messages[session.last_consolidated:-keep_count]
if not old_messages:
return True
logger.info("Memory consolidation: {} to consolidate, {} keep", len(old_messages), keep_count)
lines = []
for m in old_messages:
if not m.get("content"):
continue
tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else ""
lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}")
current_memory = self.read_long_term()
prompt = f"""Process this conversation and call the save_memory tool with your consolidation.
## Current Long-term Memory
{current_memory or "(empty)"}
## Conversation to Process
{chr(10).join(lines)}"""
try:
response = await provider.chat(
messages=[
{"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."},
{"role": "user", "content": prompt},
],
tools=_SAVE_MEMORY_TOOL,
model=model,
)
if not response.has_tool_calls:
logger.warning("Memory consolidation: LLM did not call save_memory, skipping")
return False
args = response.tool_calls[0].arguments
if entry := args.get("history_entry"):
if not isinstance(entry, str):
entry = json.dumps(entry, ensure_ascii=False)
self.append_history(entry)
if update := args.get("memory_update"):
if not isinstance(update, str):
update = json.dumps(update, ensure_ascii=False)
if update != current_memory:
self.write_long_term(update)
session.last_consolidated = 0 if archive_all else len(session.messages) - keep_count
logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated)
return True
except Exception:
logger.exception("Memory consolidation failed")
return False

View File

@ -0,0 +1,291 @@
"""Plugin system for Boardware Genius - load agents, commands, and skills from plugin directories."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from loguru import logger
@dataclass
class PluginAgent:
name: str
description: str
model: str | None
system_prompt: str
plugin_name: str
@dataclass
class PluginCommand:
name: str
description: str
argument_hint: str | None
content: str # Raw body with $ARGUMENTS placeholder
plugin_name: str
def expand(self, arguments: str) -> str:
return self.content.replace("$ARGUMENTS", arguments.strip())
@dataclass
class Plugin:
name: str
description: str
source: str # "global" or "workspace"
agents: dict[str, PluginAgent] = field(default_factory=dict)
commands: dict[str, PluginCommand] = field(default_factory=dict)
skill_dirs: list[Path] = field(default_factory=list)
class PluginLoader:
"""
Loads plugins from global and workspace plugin directories.
Search paths (workspace takes priority over global):
- Global: ~/.nanobot/plugins/<plugin-name>/
- Workspace: <workspace>/plugins/<plugin-name>/
Each plugin directory may contain:
- plugin.json — manifest with name/description
- agents/<name>.md — agent definitions (frontmatter + system prompt)
- commands/<name>.md — slash command definitions (frontmatter + content)
- skills/<name>/SKILL.md — skill files exposed to SkillsLoader
"""
GLOBAL_DIR = Path.home() / ".nanobot" / "plugins"
def __init__(self, workspace: Path, global_dir: Path | None = None):
self.workspace = workspace
self.global_dir = global_dir or self.GLOBAL_DIR
self.workspace_dir = workspace / "plugins"
self._plugins: dict[str, Plugin] | None = None
@property
def plugins(self) -> dict[str, Plugin]:
if self._plugins is None:
self._plugins = self._load_all()
return self._plugins
def find_command(self, cmd_name: str) -> PluginCommand | None:
"""Find a command by name. Workspace plugins take priority over global."""
for plugin in self.plugins.values():
if plugin.source == "workspace" and cmd_name in plugin.commands:
return plugin.commands[cmd_name]
for plugin in self.plugins.values():
if plugin.source == "global" and cmd_name in plugin.commands:
return plugin.commands[cmd_name]
return None
def find_agent(self, agent_name: str) -> PluginAgent | None:
"""Find an agent by name. Workspace plugins take priority over global."""
for plugin in self.plugins.values():
if plugin.source == "workspace" and agent_name in plugin.agents:
return plugin.agents[agent_name]
for plugin in self.plugins.values():
if plugin.source == "global" and agent_name in plugin.agents:
return plugin.agents[agent_name]
return None
def get_skill_dirs(self) -> list[Path]:
"""Return all skill root directories contributed by plugins."""
dirs = []
for plugin in self.plugins.values():
dirs.extend(plugin.skill_dirs)
return dirs
def build_agents_summary(self) -> str:
"""Build an XML summary of all plugin agents for the system prompt."""
agents = []
for plugin in self.plugins.values():
agents.extend(plugin.agents.values())
if not agents:
return ""
def esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
lines = ["<agents>"]
for agent in agents:
lines.append(" <agent>")
lines.append(f" <name>{esc(agent.name)}</name>")
lines.append(f" <plugin>{esc(agent.plugin_name)}</plugin>")
lines.append(f" <description>{esc(agent.description)}</description>")
if agent.model:
lines.append(f" <model>{esc(agent.model)}</model>")
lines.append(" </agent>")
lines.append("</agents>")
return "\n".join(lines)
def build_commands_summary(self) -> str:
"""Build an XML summary of all plugin commands for the system prompt."""
commands = []
for plugin in self.plugins.values():
commands.extend(plugin.commands.values())
if not commands:
return ""
def esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
lines = ["<commands>"]
for cmd in commands:
lines.append(" <command>")
lines.append(f" <name>/{esc(cmd.name)}</name>")
lines.append(f" <plugin>{esc(cmd.plugin_name)}</plugin>")
lines.append(f" <description>{esc(cmd.description)}</description>")
if cmd.argument_hint:
lines.append(f" <argument-hint>{esc(cmd.argument_hint)}</argument-hint>")
lines.append(" </command>")
lines.append("</commands>")
return "\n".join(lines)
# ------------------------------------------------------------------ private
def _load_all(self) -> dict[str, Plugin]:
"""Load all plugins from global then workspace (workspace wins)."""
plugins: dict[str, Plugin] = {}
if self.global_dir.exists():
for plugin_dir in sorted(self.global_dir.iterdir()):
if plugin_dir.is_dir():
plugin = self._load_plugin(plugin_dir, "global")
if plugin:
plugins[plugin.name] = plugin
logger.debug("Loaded global plugin: {}", plugin.name)
if self.workspace_dir.exists():
for plugin_dir in sorted(self.workspace_dir.iterdir()):
if plugin_dir.is_dir():
plugin = self._load_plugin(plugin_dir, "workspace")
if plugin:
plugins[plugin.name] = plugin # override global
logger.debug("Loaded workspace plugin: {}", plugin.name)
return plugins
def _load_plugin(self, plugin_dir: Path, source: str) -> Plugin | None:
"""Load a single plugin from a directory."""
try:
name = plugin_dir.name
description = ""
# Look for plugin.json at root, then fall back to .claude-plugin/plugin.json
# so that Claude Code plugin repos work without copying files.
manifest_file = plugin_dir / "plugin.json"
if not manifest_file.exists():
manifest_file = plugin_dir / ".claude-plugin" / "plugin.json"
if manifest_file.exists():
try:
manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
name = manifest.get("name", name)
description = manifest.get("description", "")
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to parse plugin.json in {}: {}", plugin_dir, e)
agents_dir = plugin_dir / "agents"
agents = self._load_agents(agents_dir, name) if agents_dir.exists() else {}
commands_dir = plugin_dir / "commands"
commands = self._load_commands(commands_dir, name) if commands_dir.exists() else {}
skills_dir = plugin_dir / "skills"
skill_dirs = [skills_dir] if skills_dir.exists() else []
return Plugin(
name=name,
description=description,
source=source,
agents=agents,
commands=commands,
skill_dirs=skill_dirs,
)
except Exception as e:
logger.warning("Failed to load plugin from {}: {}", plugin_dir, e)
return None
def _load_agents(self, agents_dir: Path, plugin_name: str) -> dict[str, PluginAgent]:
"""Load agent .md files from a directory."""
agents: dict[str, PluginAgent] = {}
for md_file in sorted(agents_dir.glob("*.md")):
try:
content = md_file.read_text(encoding="utf-8")
meta, body = self._parse_frontmatter(content)
name = meta.get("name", md_file.stem)
description = meta.get("description", "")
model = meta.get("model") or None
agents[name] = PluginAgent(
name=name,
description=description,
model=model,
system_prompt=body,
plugin_name=plugin_name,
)
except Exception as e:
logger.warning("Failed to load agent {}: {}", md_file, e)
return agents
def _load_commands(self, commands_dir: Path, plugin_name: str) -> dict[str, PluginCommand]:
"""Load command .md files from a directory."""
commands: dict[str, PluginCommand] = {}
for md_file in sorted(commands_dir.glob("*.md")):
try:
content = md_file.read_text(encoding="utf-8")
meta, body = self._parse_frontmatter(content)
name = md_file.stem
description = meta.get("description", "")
argument_hint = meta.get("argument-hint") or None
commands[name] = PluginCommand(
name=name,
description=description,
argument_hint=argument_hint,
content=body,
plugin_name=plugin_name,
)
except Exception as e:
logger.warning("Failed to load command {}: {}", md_file, e)
return commands
def _parse_frontmatter(self, content: str) -> tuple[dict[str, str], str]:
"""
Parse YAML frontmatter delimited by ``---`` lines.
Returns (meta_dict, body). Supports simple ``key: value`` pairs and
block scalars (``key: |``). Does not require PyYAML.
"""
if not content.startswith("---"):
return {}, content
match = re.match(r"^---\n(.*?)\n---\n?", content, re.DOTALL)
if not match:
return {}, content
raw = match.group(1)
body = content[match.end():].strip()
meta: dict[str, str] = {}
lines = raw.split("\n")
i = 0
while i < len(lines):
line = lines[i]
if ":" in line and not line.startswith((" ", "\t")):
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
if value == "|":
# Block scalar: collect following indented lines
block_lines: list[str] = []
i += 1
while i < len(lines) and (lines[i].startswith(" ") or lines[i] == ""):
block_lines.append(lines[i][2:] if lines[i].startswith(" ") else "")
i += 1
meta[key] = "\n".join(block_lines).strip()
continue
else:
meta[key] = value.strip("\"'")
i += 1
return meta, body

View File

@ -0,0 +1,84 @@
"""结构化过程事件辅助工具。
这个模块的作用是把“运行中的中间状态”从底层执行逻辑安全地带到上层 UI
1. 用 `ContextVar` 记录当前异步上下文是否挂了事件 sink
2. 用单独的 run_id 上下文把父子流程串起来;
3. 让委派、MCP、A2A 等模块只管发事件,不需要知道 WebSocket/SSE 细节。
"""
from __future__ import annotations
import uuid
from contextlib import contextmanager
from contextvars import ContextVar
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable
ProcessEvent = dict[str, Any]
ProcessEventSink = Callable[[ProcessEvent], Awaitable[None]]
# `_sink_var` 保存“当前异步上下文的事件接收器”。
# 这样可以避免把回调一层层显式往下传,同时又不会污染并发请求之间的上下文。
_sink_var: ContextVar[ProcessEventSink | None] = ContextVar("process_event_sink", default=None)
# `_run_id_var` 保存“当前流程的父 run_id”。
# 子流程发事件时可以把它挂到 `parent_run_id`,供前端拼接树状执行视图。
_run_id_var: ContextVar[str | None] = ContextVar("process_current_run_id", default=None)
def new_run_id(prefix: str = "run") -> str:
"""生成一个短且可读的运行 ID。"""
# 只截取 8 位十六进制是为了兼顾:
# 1. 日志 / WebSocket 里更短、更容易肉眼追踪;
# 2. 同一进程内短期冲突概率仍足够低。
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def utc_now_iso() -> str:
"""返回带 `Z` 后缀的 UTC ISO8601 时间戳。"""
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
@contextmanager
def process_event_sink(sink: ProcessEventSink | None):
"""为当前异步上下文临时绑定一个事件 sink。"""
# `ContextVar.set()` 会返回 token退出时要 reset避免泄漏到后续请求。
token = _sink_var.set(sink)
try:
yield
finally:
_sink_var.reset(token)
@contextmanager
def process_run_context(run_id: str | None):
"""为当前异步上下文绑定一个逻辑父 run_id。"""
token = _run_id_var.set(run_id)
try:
yield
finally:
_run_id_var.reset(token)
def current_process_run_id() -> str | None:
"""读取当前上下文里绑定的 run_id。"""
return _run_id_var.get()
def has_process_event_sink() -> bool:
"""判断当前上下文是否具备过程事件接收能力。"""
return _sink_var.get() is not None
async def emit_process_event(event_type: str, **payload: Any) -> None:
"""在存在 sink 时发出一个结构化过程事件。"""
sink = _sink_var.get()
# 没有 sink 说明当前调用链不关心中间态,例如纯 CLI 单轮场景,直接静默跳过。
if sink is None:
return
# `created_at` 允许调用方覆盖;未传时统一补 UTC 时间,方便前端排序。
event: ProcessEvent = {
"type": event_type,
"created_at": payload.pop("created_at", utc_now_iso()),
**payload,
}
await sink(event)

View File

@ -0,0 +1,58 @@
"""委派执行结果的共享类型定义。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
_PLACEHOLDER_SUMMARY_MARKERS = (
"task completed but no final response was generated",
"no final response was generated",
"已启动代理团队",
"代理团队正在后台工作",
"agent team [",
"spawn_agent_team",
"error calling llm",
"litellm.timeout",
"dashscopeexception",
"service temporarily unavailable",
"planner调用失败",
"本任务当前不可执行",
"无法由单一非sop工具完成",
)
def normalize_summary_text(text: str | None) -> str:
"""把摘要文本压成便于判定的稳定形式。"""
return " ".join(str(text or "").strip().split())
def contains_placeholder_summary(text: str | None) -> bool:
"""判断摘要是否只是占位兜底文本。"""
normalized = normalize_summary_text(text).lower()
if not normalized:
return True
return any(marker in normalized for marker in _PLACEHOLDER_SUMMARY_MARKERS)
def has_meaningful_summary(text: str | None) -> bool:
"""判断摘要是否包含可复用的真实结果。"""
normalized = normalize_summary_text(text)
return bool(normalized) and not contains_placeholder_summary(normalized)
@dataclass
class AgentRunResult:
"""统一描述一次 agent 执行结果。"""
# 执行方的稳定 ID适合程序判断和日志检索。
agent_id: str
# 展示给用户或前端时使用的人类可读名称。
agent_name: str
# 归一化状态:通常是 `ok` / `error` / `cancelled` 等。
status: str
# 面向上层的简要总结,是最终展示和二次总结的主要输入。
summary: str
# 可选原始载荷,保留底层协议返回值,便于调试或后续扩展。
raw: dict[str, Any] | None = None

View File

@ -0,0 +1,238 @@
"""Review-first skill installation helpers."""
from __future__ import annotations
import json
import secrets
import shutil
import zipfile
from pathlib import Path, PurePosixPath
from typing import Any
from nanobot.utils.helpers import ensure_dir, get_workspace_state_path, safe_filename, timestamp
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _parse_frontmatter(content: str) -> dict[str, str]:
if not content.startswith("---"):
return {}
end = content.find("\n---", 3)
if end == -1:
return {}
metadata: dict[str, str] = {}
for line in content[3:end].splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
metadata[key.strip()] = value.strip().strip("\"'")
return metadata
def _parse_skill_metadata(raw: str) -> dict[str, Any]:
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {}
if not isinstance(data, dict):
return {}
nested = data.get("nanobot", data.get("openclaw", {}))
return nested if isinstance(nested, dict) else {}
class SkillReviewManager:
"""Stage workspace skill installs until the user explicitly approves them."""
REVIEW_META_FILE = "review.json"
ARCHIVE_FILE = "upload.zip"
STAGED_DIR = "staged"
def __init__(self, workspace: Path):
self.workspace = workspace.expanduser().resolve()
self.workspace_skills = ensure_dir(self.workspace / "skills")
self.reviews_dir = ensure_dir(get_workspace_state_path(self.workspace) / "skill-reviews")
def list_reviews(self) -> list[dict[str, Any]]:
reviews: list[dict[str, Any]] = []
for review_dir in sorted(self.reviews_dir.iterdir(), reverse=True):
if not review_dir.is_dir():
continue
try:
reviews.append(self._read_review(review_dir))
except FileNotFoundError:
continue
return reviews
def get_review(self, review_id: str) -> dict[str, Any]:
return self._read_review(self._review_dir(review_id))
def create_review_from_zip(self, filename: str, content: bytes) -> dict[str, Any]:
review_id = secrets.token_hex(8)
review_dir = ensure_dir(self._review_dir(review_id))
archive_path = review_dir / self.ARCHIVE_FILE
archive_path.write_bytes(content)
staged_root = ensure_dir(review_dir / self.STAGED_DIR)
preview = self._extract_archive(archive_path, staged_root, filename)
review = {
"id": review_id,
"status": "pending_review",
"created_at": timestamp(),
"archive_name": filename,
**preview,
}
self._write_review(review_dir, review)
return review
def approve_review(self, review_id: str, overwrite: bool = False) -> dict[str, Any]:
review_dir = self._review_dir(review_id)
review = self._read_review(review_dir)
if review.get("status") == "approved":
return review
skill_name = str(review.get("skill_name") or "").strip()
if not skill_name:
raise ValueError("Review is missing a skill_name")
source_dir = review_dir / self.STAGED_DIR / skill_name
if not source_dir.is_dir():
raise FileNotFoundError(f"Staged skill not found for review {review_id}")
target_dir = self.workspace_skills / skill_name
if target_dir.exists():
if not overwrite:
raise FileExistsError(
f"Skill '{skill_name}' already exists. Re-submit approval with overwrite=true."
)
shutil.rmtree(target_dir)
shutil.copytree(source_dir, target_dir)
review["status"] = "approved"
review["approved_at"] = timestamp()
review["overwrite"] = overwrite
review["installed_path"] = str(target_dir / "SKILL.md")
self._write_review(review_dir, review)
return review
def discard_review(self, review_id: str) -> None:
review_dir = self._review_dir(review_id)
if not review_dir.exists():
raise FileNotFoundError(f"Skill review '{review_id}' not found")
shutil.rmtree(review_dir)
def _review_dir(self, review_id: str) -> Path:
return self.reviews_dir / review_id
def _read_review(self, review_dir: Path) -> dict[str, Any]:
review_file = review_dir / self.REVIEW_META_FILE
if not review_file.exists():
raise FileNotFoundError(f"Skill review metadata not found: {review_dir.name}")
return json.loads(review_file.read_text(encoding="utf-8"))
def _write_review(self, review_dir: Path, review: dict[str, Any]) -> None:
review_file = review_dir / self.REVIEW_META_FILE
review_file.write_text(
json.dumps(review, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def _extract_archive(
self,
archive_path: Path,
staged_root: Path,
upload_name: str,
) -> dict[str, Any]:
with zipfile.ZipFile(archive_path, "r") as zf:
file_infos = [info for info in zf.infolist() if not info.is_dir()]
if not file_infos:
raise ValueError("Zip archive is empty")
skill_md_entries: list[str] = []
for info in file_infos:
rel = PurePosixPath(info.filename)
if rel.name != "SKILL.md":
continue
if len(rel.parts) not in (1, 2):
raise ValueError(
"SKILL.md must be at the archive root or inside a single top-level directory"
)
skill_md_entries.append(info.filename)
if not skill_md_entries:
raise ValueError("Zip must contain a top-level SKILL.md file")
skill_md_entry = skill_md_entries[0]
skill_md_parts = PurePosixPath(skill_md_entry).parts
top_level_dir = skill_md_parts[0] if len(skill_md_parts) == 2 else ""
frontmatter = _parse_frontmatter(
zf.read(skill_md_entry).decode("utf-8", errors="replace")
)
if top_level_dir:
skill_name = top_level_dir
else:
skill_name = frontmatter.get("name") or Path(upload_name).stem
skill_name = safe_filename(skill_name).replace(" ", "-")
if not skill_name:
raise ValueError("Could not determine a safe skill name")
staged_skill_dir = staged_root / skill_name
staged_skill_dir.mkdir(parents=True, exist_ok=False)
extracted_files: list[str] = []
for info in file_infos:
raw_rel = PurePosixPath(info.filename)
if "__MACOSX" in raw_rel.parts or raw_rel.name == ".DS_Store":
continue
if top_level_dir:
if not raw_rel.parts or raw_rel.parts[0] != top_level_dir:
continue
rel_parts = raw_rel.parts[1:]
else:
rel_parts = raw_rel.parts
if not rel_parts:
continue
if any(part in {"", ".", ".."} for part in rel_parts):
raise ValueError(f"Unsafe archive entry: {info.filename}")
dest = staged_skill_dir.joinpath(*rel_parts)
dest.parent.mkdir(parents=True, exist_ok=True)
resolved_dest = dest.resolve()
if not _is_relative_to(resolved_dest, staged_skill_dir.resolve()):
raise ValueError(f"Unsafe archive entry: {info.filename}")
with zf.open(info) as src, open(dest, "wb") as dst:
shutil.copyfileobj(src, dst)
extracted_files.append(PurePosixPath(*rel_parts).as_posix())
if not (staged_skill_dir / "SKILL.md").exists():
raise ValueError("Staged skill is missing SKILL.md after extraction")
skill_meta = _parse_skill_metadata(frontmatter.get("metadata", ""))
target_dir = self.workspace_skills / skill_name
return {
"skill_name": skill_name,
"declared_name": frontmatter.get("name", skill_name),
"description": frontmatter.get("description", ""),
"metadata": frontmatter,
"requires": skill_meta.get("requires", {}),
"file_count": len(extracted_files),
"files": sorted(extracted_files),
"target_exists": target_dir.exists(),
"target_path": str(target_dir / "SKILL.md"),
"staged_path": str(staged_skill_dir / "SKILL.md"),
}

View File

@ -0,0 +1,284 @@
"""Skills loader for agent capabilities."""
import json
import os
import re
import shutil
from pathlib import Path
# Default builtin skills directory (relative to this file)
BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills"
class SkillsLoader:
"""
Loader for agent skills.
Skills are markdown files (SKILL.md) that teach the agent how to use
specific tools or perform certain tasks.
"""
def __init__(
self,
workspace: Path,
builtin_skills_dir: Path | None = None,
extra_dirs: list[Path] | None = None,
):
self.workspace = workspace
self.workspace_skills = workspace / "skills"
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
if extra_dirs is None:
from nanobot.agent.plugins import PluginLoader
extra_dirs = PluginLoader(workspace).get_skill_dirs()
self.extra_dirs: list[Path] = extra_dirs
def list_skills(self, filter_unavailable: bool = True) -> list[dict[str, str]]:
"""
List all available skills.
Args:
filter_unavailable: If True, filter out skills with unmet requirements.
Returns:
List of skill info dicts with 'name', 'path', 'source'.
"""
skills = []
# Workspace skills (highest priority)
if self.workspace_skills.exists():
for skill_dir in self.workspace_skills.iterdir():
if skill_dir.is_dir():
skill_file = skill_dir / "SKILL.md"
if skill_file.exists():
skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "workspace"})
# Extra skill roots (e.g. plugin-provided skills)
for extra_dir in self.extra_dirs:
if extra_dir.exists():
for skill_dir in extra_dir.iterdir():
if skill_dir.is_dir():
skill_file = skill_dir / "SKILL.md"
if skill_file.exists() and not any(s["name"] == skill_dir.name for s in skills):
skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "plugin"})
# Built-in skills
if self.builtin_skills and self.builtin_skills.exists():
for skill_dir in self.builtin_skills.iterdir():
if skill_dir.is_dir():
skill_file = skill_dir / "SKILL.md"
if skill_file.exists() and not any(s["name"] == skill_dir.name for s in skills):
skills.append({"name": skill_dir.name, "path": str(skill_file), "source": "builtin"})
# Filter by requirements
if filter_unavailable:
return [s for s in skills if self._check_requirements(self._get_skill_meta(s["name"]))]
return skills
def load_skill(self, name: str) -> str | None:
"""
Load a skill by name.
Args:
name: Skill name (directory name).
Returns:
Skill content or None if not found.
"""
# Check workspace first
workspace_skill = self.workspace_skills / name / "SKILL.md"
if workspace_skill.exists():
return workspace_skill.read_text(encoding="utf-8")
# Check plugin-provided roots
for extra_dir in self.extra_dirs:
extra_skill = extra_dir / name / "SKILL.md"
if extra_skill.exists():
return extra_skill.read_text(encoding="utf-8")
# Check built-in
if self.builtin_skills:
builtin_skill = self.builtin_skills / name / "SKILL.md"
if builtin_skill.exists():
return builtin_skill.read_text(encoding="utf-8")
return None
def load_skills_for_context(self, skill_names: list[str]) -> str:
"""
Load specific skills for inclusion in agent context.
Args:
skill_names: List of skill names to load.
Returns:
Formatted skills content.
"""
parts = []
for name in skill_names:
content = self.load_skill(name)
if content:
content = self._strip_frontmatter(content)
parts.append(f"### Skill: {name}\n\n{content}")
return "\n\n---\n\n".join(parts) if parts else ""
def build_skills_summary(self) -> str:
"""
Build a summary of all skills (name, description, path, availability).
This is used for progressive loading - the agent can read the full
skill content using read_file when needed.
Returns:
XML-formatted skills summary.
"""
all_skills = self.list_skills(filter_unavailable=False)
if not all_skills:
return ""
def escape_xml(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
lines = ["<skills>"]
for s in all_skills:
name = escape_xml(s["name"])
path = s["path"]
desc = escape_xml(self._get_skill_description(s["name"]))
skill_meta = self._get_skill_meta(s["name"])
available = self._check_requirements(skill_meta)
lines.append(f" <skill available=\"{str(available).lower()}\">")
lines.append(f" <name>{name}</name>")
lines.append(f" <description>{desc}</description>")
lines.append(f" <location>{path}</location>")
# Show missing requirements for unavailable skills
if not available:
missing = self._get_missing_requirements(skill_meta)
if missing:
lines.append(f" <requires>{escape_xml(missing)}</requires>")
lines.append(" </skill>")
lines.append("</skills>")
return "\n".join(lines)
def _get_missing_requirements(self, skill_meta: dict) -> str:
"""Get a description of missing requirements."""
missing = []
requires = skill_meta.get("requires", {})
for b in requires.get("bins", []):
if not shutil.which(b):
missing.append(f"CLI: {b}")
for env in requires.get("env", []):
if not os.environ.get(env):
missing.append(f"ENV: {env}")
return ", ".join(missing)
def _get_skill_description(self, name: str) -> str:
"""Get the description of a skill from its frontmatter."""
meta = self.get_skill_metadata(name)
if meta and meta.get("description"):
return meta["description"]
return name # Fallback to skill name
def _strip_frontmatter(self, content: str) -> str:
"""Remove YAML frontmatter from markdown content."""
if content.startswith("---"):
match = re.match(r"^---\n.*?\n---\n", content, re.DOTALL)
if match:
return content[match.end():].strip()
return content
def _parse_nanobot_metadata(self, raw: str) -> dict:
"""Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys)."""
try:
data = json.loads(raw)
return data.get("nanobot", data.get("openclaw", {})) if isinstance(data, dict) else {}
except (json.JSONDecodeError, TypeError):
return {}
def _check_requirements(self, skill_meta: dict) -> bool:
"""Check if skill requirements are met (bins, env vars)."""
requires = skill_meta.get("requires", {})
for b in requires.get("bins", []):
if not shutil.which(b):
return False
for env in requires.get("env", []):
if not os.environ.get(env):
return False
return True
def _get_skill_meta(self, name: str) -> dict:
"""Get nanobot metadata for a skill (cached in frontmatter)."""
meta = self.get_skill_metadata(name) or {}
return self._parse_nanobot_metadata(meta.get("metadata", ""))
def get_always_skills(self) -> list[str]:
"""Get skills marked as always=true that meet requirements."""
result = []
for s in self.list_skills(filter_unavailable=True):
meta = self.get_skill_metadata(s["name"]) or {}
skill_meta = self._parse_nanobot_metadata(meta.get("metadata", ""))
if skill_meta.get("always") or meta.get("always"):
result.append(s["name"])
return result
def get_skill_metadata(self, name: str) -> dict | None:
"""
Get metadata from a skill's frontmatter.
Args:
name: Skill name.
Returns:
Metadata dict or None.
"""
content = self.load_skill(name)
if not content:
return None
if content.startswith("---"):
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if match:
# Simple YAML parsing
metadata = {}
for line in match.group(1).split("\n"):
if ":" in line:
key, value = line.split(":", 1)
metadata[key.strip()] = value.strip().strip('"\'')
return metadata
return None
def get_skill_agent_cards(self, name: str) -> list[dict]:
"""从 skill 元数据里提取 A2A agent card 声明。"""
# 技能 frontmatter 里的 metadata 是字符串形式,先复用现有解析逻辑拿到 nanobot 扩展字段。
meta = self.get_skill_metadata(name) or {}
skill_meta = self._parse_nanobot_metadata(meta.get("metadata", ""))
cards = skill_meta.get("agent_cards", [])
if not isinstance(cards, list):
return []
result = []
for idx, card in enumerate(cards):
if not isinstance(card, dict):
continue
# 复制一份,避免直接修改原 metadata 结构。
item = dict(card)
# 对缺失字段做兜底补全,保证后续 AgentRegistry 可以稳定消费。
item.setdefault("id", item.get("name") or f"{name}-agent-{idx + 1}")
item.setdefault("name", item["id"])
item.setdefault("description", meta.get("description", item["name"]))
# 额外挂回 skill_name方便前端展示来源也便于后续定位声明位置。
item["skill_name"] = name
result.append(item)
return result
def list_skill_agent_cards(self) -> list[dict]:
"""聚合所有可见 skill 中声明的 agent card。"""
cards = []
for skill in self.list_skills(filter_unavailable=False):
cards.extend(self.get_skill_agent_cards(skill["name"]))
return cards

View File

@ -0,0 +1,311 @@
"""本地委派执行器。
这个类不再负责“后台任务管理”和“结果回流”,只保留一件事:
在统一委派层要求执行本地任务时,提供一个受限工具集的本地 agent 执行环境。
"""
from __future__ import annotations
import json
import re
import time as _time
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger
from nanobot.agent.run_result import AgentRunResult, has_meaningful_summary
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.spawn import NestedDelegateTool
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.providers.base import LLMProvider
if TYPE_CHECKING:
from nanobot.agent.delegation import DelegationManager
from nanobot.config.schema import ExecToolConfig
class SubagentManager:
"""用受限工具集在本地执行委派任务。"""
def __init__(
self,
provider: LLMProvider,
workspace: Path,
model: str | None = None,
temperature: float = 0.7,
max_tokens: int = 4096,
brave_api_key: str | None = None,
exec_config: ExecToolConfig | None = None,
restrict_to_workspace: bool = False,
):
from nanobot.config.schema import ExecToolConfig
# 这里保存的都是本地执行所需的静态配置,不再维护后台任务表。
self.provider = provider
self.workspace = workspace
self.model = model or provider.get_default_model()
self.temperature = temperature
self.max_tokens = max_tokens
self.brave_api_key = brave_api_key
self.exec_config = exec_config or ExecToolConfig()
self.restrict_to_workspace = restrict_to_workspace
self._nested_delegate: DelegationManager | None = None
def set_nested_delegate(self, manager: "DelegationManager | None") -> None:
"""注入 delegated worker 可用的受控下游委派器。"""
self._nested_delegate = manager
async def run_local_task(
self,
task: str,
label: str | None = None,
agent_id: str = "local-subagent",
agent_name: str = "Local Subagent",
system_prompt: str | None = None,
model: str | None = None,
progress_callback: Callable[..., Awaitable[None]] | None = None,
allow_nested_delegation: bool = True,
skill_context: str = "",
skill_names: list[str] | None = None,
) -> AgentRunResult:
"""执行一次本地委派任务,并返回结构化结果。"""
# 每次任务都新建一套局部工具注册表,避免不同任务之间共享临时状态。
tools = self._build_local_tools(
allow_nested_delegation=allow_nested_delegation,
skill_names=skill_names,
)
prompt = self._build_subagent_prompt(
task,
agent_name=agent_name,
custom_system_prompt=system_prompt,
allow_nested_delegation=allow_nested_delegation,
skill_context=skill_context,
)
# 本地委派不共享主会话历史,只带“专用 system prompt + 当前任务”。
messages: list[dict[str, Any]] = [
{"role": "system", "content": prompt},
{"role": "user", "content": task},
]
# 本地子 agent 也走“模型 -> 工具 -> 模型”的短循环,但轮数更保守。
max_iterations = 15
iteration = 0
final_result: str | None = None
while iteration < max_iterations:
iteration += 1
response = await self.provider.chat(
messages=messages,
tools=tools.get_definitions(),
model=model or self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
)
if response.has_tool_calls:
if progress_callback:
# 进度回调只发对用户有价值的文本,不把 `<think>` 之类内部推理暴露出去。
clean = self._strip_think(response.content)
if clean:
await progress_callback(clean, tool_hint=False)
# 额外补一条短工具提示,让上层 UI 知道当前在做什么。
await progress_callback(self._tool_hint(response.tool_calls), tool_hint=True)
tool_call_dicts = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments, ensure_ascii=False),
},
}
for tc in response.tool_calls
]
messages.append({
"role": "assistant",
"content": response.content or "",
"tool_calls": tool_call_dicts,
})
for tool_call in response.tool_calls:
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
logger.debug("Agent [{}] executing: {} with arguments: {}", agent_id, tool_call.name, args_str)
# 真正执行工具后,把结果回填到 messages让下一轮模型能看到执行结果。
result = await tools.execute(tool_call.name, tool_call.arguments)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"name": tool_call.name,
"content": result,
})
else:
# 没有继续调用工具时,视为任务已收敛,直接采纳当前回复。
final_result = response.content
break
status = "ok"
raw: dict[str, Any] | None = None
if not has_meaningful_summary(final_result):
# 兜底避免出现“任务做完了但完全没文本”的空结果,并显式标记为失败,
# 防止上层把这类占位结果学习成 procedure。
final_result = "Task completed but no final response was generated."
status = "error"
raw = {
"reason": "no_final_response_generated",
"iterations": iteration,
}
return AgentRunResult(
agent_id=agent_id,
agent_name=agent_name,
status=status,
summary=final_result,
raw=raw,
)
def _build_local_tools(
self,
*,
allow_nested_delegation: bool,
skill_names: list[str] | None = None,
) -> ToolRegistry:
"""构建本地委派可用的受限工具集。"""
tools = ToolRegistry()
allowed_dir = self.workspace if self.restrict_to_workspace else None
protected_skill_paths = [self.workspace / "skills"]
# 文件工具统一按相同的 workspace / allowed_dir 约束注册。
tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
tools.register(
WriteFileTool(
workspace=self.workspace,
allowed_dir=allowed_dir,
protected_paths=protected_skill_paths,
)
)
tools.register(
EditFileTool(
workspace=self.workspace,
allowed_dir=allowed_dir,
protected_paths=protected_skill_paths,
)
)
# 本地命令执行沿用主配置里的超时和 workspace 限制。
tools.register(ExecTool(
working_dir=str(self.workspace),
timeout=self.exec_config.timeout,
restrict_to_workspace=self.restrict_to_workspace,
protected_paths=protected_skill_paths,
))
# 网络能力保持只读:搜索和抓取,不提供消息发送/再次委派等工具。
tools.register(WebSearchTool(api_key=self.brave_api_key))
tools.register(WebFetchTool())
if allow_nested_delegation and self._nested_delegate is not None:
tools.register(NestedDelegateTool(manager=self._nested_delegate, default_skills=skill_names))
return tools
@staticmethod
def _strip_think(text: str | None) -> str | None:
"""Remove provider-specific think blocks from visible progress text."""
if not text:
return None
return re.sub(r"<think>[\s\S]*?</think>", "", text).strip() or None
@staticmethod
def _tool_hint(tool_calls: list) -> str:
"""把工具调用列表格式化成简短进度提示。"""
def _fmt(tc):
val = next(iter(tc.arguments.values()), None) if tc.arguments else None
if not isinstance(val, str):
return tc.name
return f'{tc.name}("{val[:40]}...")' if len(val) > 40 else f'{tc.name}("{val}")'
return ", ".join(_fmt(tc) for tc in tool_calls)
def _build_subagent_prompt(
self,
task: str,
agent_name: str = "Local Subagent",
custom_system_prompt: str | None = None,
allow_nested_delegation: bool = True,
skill_context: str = "",
) -> str:
"""构建子代理专用 system prompt。"""
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
tz = _time.strftime("%Z") or "UTC"
# plugin agent 的自定义系统提示拼到末尾,保留通用约束,再叠加个性化指令。
extra = f"\n\n## Agent Instructions\n{custom_system_prompt.strip()}" if custom_system_prompt else ""
can_do_lines = [
"- Read and write files in the workspace",
"- Execute shell commands",
"- Search the web and fetch web pages",
"- Complete the task thoroughly",
]
cannot_do_lines = [
"- Send messages directly to users (no message tool available)",
"- Access the main agent's conversation history",
]
delegation_section = (
"\n## Downstream Delegation\n"
"- Do not delegate further. Complete the task yourself with the tools you have."
)
if allow_nested_delegation and self._nested_delegate is not None:
can_do_lines.append(
"- Use `delegate_task` for controlled downstream delegation when specialized help is required"
)
cannot_do_lines.append("- Do not start agent teams or use background delegation tools")
nested_summary = self._nested_delegate.build_nested_agents_summary()
summary_block = f"\n\n{nested_summary}" if nested_summary else ""
delegation_section = (
"\n## Downstream Delegation\n"
"- Use `delegate_task` only when a specialized downstream worker is actually needed.\n"
"- `strategy=\"a2a\"` delegates directly to an available A2A agent.\n"
"- `strategy=\"ephemeral_subagent\"` runs a temporary local worker for this task only.\n"
"- Never create, register, or persist a new local sub-agent through `subagentctl.py`, `/api/subagents`, or registry edits."
f"{summary_block}"
)
else:
cannot_do_lines.append("- Spawn other subagents or downstream workers")
skill_section = f"\n## Required Skills\n{skill_context.strip()}" if skill_context.strip() else ""
return f"""# {agent_name}
## Current Time
{now} ({tz})
You are a delegated agent spawned by the main agent to complete a specific task.
## Rules
1. Stay focused - complete only the assigned task, nothing else
2. Your final response will be reported back to the main agent
3. Do not initiate conversations or take on side tasks
4. Be concise but informative in your findings
5. Do not create or modify persistent local sub-agents unless the task explicitly requires that workflow
## What You Can Do
{chr(10).join(can_do_lines)}
## What You Cannot Do
{chr(10).join(cannot_do_lines)}
{delegation_section}
{skill_section}
## Workspace
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}"""

View File

@ -0,0 +1,258 @@
"""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_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.
"""

View File

@ -0,0 +1,6 @@
"""Agent tools module."""
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
__all__ = ["Tool", "ToolRegistry"]

View File

@ -0,0 +1,102 @@
"""Base class for agent tools."""
from abc import ABC, abstractmethod
from typing import Any
class Tool(ABC):
"""
Abstract base class for agent tools.
Tools are capabilities that the agent can use to interact with
the environment, such as reading files, executing commands, etc.
"""
_TYPE_MAP = {
"string": str,
"integer": int,
"number": (int, float),
"boolean": bool,
"array": list,
"object": dict,
}
@property
@abstractmethod
def name(self) -> str:
"""Tool name used in function calls."""
pass
@property
@abstractmethod
def description(self) -> str:
"""Description of what the tool does."""
pass
@property
@abstractmethod
def parameters(self) -> dict[str, Any]:
"""JSON Schema for tool parameters."""
pass
@abstractmethod
async def execute(self, **kwargs: Any) -> str:
"""
Execute the tool with given parameters.
Args:
**kwargs: Tool-specific parameters.
Returns:
String result of the tool execution.
"""
pass
def validate_params(self, params: dict[str, Any]) -> list[str]:
"""Validate tool parameters against JSON schema. Returns error list (empty if valid)."""
schema = self.parameters or {}
if schema.get("type", "object") != "object":
raise ValueError(f"Schema must be object type, got {schema.get('type')!r}")
return self._validate(params, {**schema, "type": "object"}, "")
def _validate(self, val: Any, schema: dict[str, Any], path: str) -> list[str]:
t, label = schema.get("type"), path or "parameter"
if t in self._TYPE_MAP and not isinstance(val, self._TYPE_MAP[t]):
return [f"{label} should be {t}"]
errors = []
if "enum" in schema and val not in schema["enum"]:
errors.append(f"{label} must be one of {schema['enum']}")
if t in ("integer", "number"):
if "minimum" in schema and val < schema["minimum"]:
errors.append(f"{label} must be >= {schema['minimum']}")
if "maximum" in schema and val > schema["maximum"]:
errors.append(f"{label} must be <= {schema['maximum']}")
if t == "string":
if "minLength" in schema and len(val) < schema["minLength"]:
errors.append(f"{label} must be at least {schema['minLength']} chars")
if "maxLength" in schema and len(val) > schema["maxLength"]:
errors.append(f"{label} must be at most {schema['maxLength']} chars")
if t == "object":
props = schema.get("properties", {})
for k in schema.get("required", []):
if k not in val:
errors.append(f"missing required {path + '.' + k if path else k}")
for k, v in val.items():
if k in props:
errors.extend(self._validate(v, props[k], path + '.' + k if path else k))
if t == "array" and "items" in schema:
for i, item in enumerate(val):
errors.extend(self._validate(item, schema["items"], f"{path}[{i}]" if path else f"[{i}]"))
return errors
def to_schema(self) -> dict[str, Any]:
"""Convert tool to OpenAI function schema format."""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
}
}

View File

@ -0,0 +1,246 @@
"""cron 工具:给 Agent 提供“定时任务管理”能力。
这个工具是 LLM 在对话中可调用的 function tool主要负责三件事
1. `add`:创建一个定时任务(周期/cron/一次性);
2. `list`:列出现有任务;
3. `remove`:删除指定任务。
设计定位说明:
- 本工具只做“任务管理面”,不直接负责“定时器循环”;
- 真正的调度与执行由 `CronService` 统一负责start/stop/on_job
- 工具层通过 `set_context(channel, chat_id)` 注入当前会话路由,
从而让定时任务在触发后把结果回投到正确会话。
"""
from typing import Any
from nanobot.agent.tools.base import Tool
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
class CronTool(Tool):
"""对话可调用的 cron 管理工具。
调用来源:
- 主 agent 在工具调用回合中发起 `cron(...)`。
关键约束:
- action 仅支持 `add/list/remove` 三种;
- `add` 必须带 message并且必须先注入 session 上下文channel/chat_id
- 时间相关参数三选一:`every_seconds` / `cron_expr` / `at`。
"""
def __init__(self, cron_service: CronService):
# 持有同一个 CronService 实例,保证:
# 1) CLI 命令与 agent 工具看到同一份 jobs.json
# 2) 任务状态next_run、enabled在进程内一致。
self._cron = cron_service
# 路由上下文由 AgentLoop 每轮注入。
# 任务触发时将按该路由把结果投递回原会话。
self._channel = ""
self._chat_id = ""
self._session_key = ""
def set_context(self, channel: str, chat_id: str, session_key: str | None = None) -> None:
"""设置当前会话路由上下文。
为什么需要它:
- 用户在 A 会话里让 agent“每天提醒我”
任务未来触发时应回到 A而不是误发到其他会话。
- 因此 channel/chat_id 不依赖模型每次显式传参,
而是由运行时在调用前预注入默认目标。
"""
self._channel = channel
self._chat_id = chat_id
self._session_key = session_key or f"{channel}:{chat_id}"
@property
def name(self) -> str:
# 暴露给模型的工具名。模型会以 `cron(...)` 发起 function call。
return "cron"
@property
def description(self) -> str:
# 给模型看的简要能力描述,尽量短而明确。
return "Schedule reminders and recurring tasks. Actions: add, list, remove. Use mode=reminder or task."
@property
def parameters(self) -> dict[str, Any]:
# OpenAI function schema
# - 定义参数结构与类型;
# - 由 ToolRegistry 在调用前做基础参数校验。
return {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "list", "remove"],
"description": "Action to perform"
},
"message": {
"type": "string",
# add 时的任务文本:
# - 既可做“纯提醒文案”,也可做“交给 agent 执行的提示”。
"description": "Reminder message (for add)"
},
"mode": {
"type": "string",
"enum": ["reminder", "task"],
"description": "Execution mode: reminder sends message directly; task re-enters agent"
},
"every_seconds": {
"type": "integer",
# 固定间隔调度(单位秒),内部会转换为毫秒。
"description": "Interval in seconds (for recurring tasks)"
},
"cron_expr": {
"type": "string",
# 标准 cron 表达式5 段),例如每天 9 点0 9 * * *
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
},
"tz": {
"type": "string",
# 仅与 cron_expr 搭配使用的 IANA 时区。
"description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')"
},
"at": {
"type": "string",
# 一次性触发时间ISO 格式(本地/带偏移都可由 fromisoformat 解析)。
"description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')"
},
"job_id": {
"type": "string",
"description": "Job ID (for remove)"
}
},
"required": ["action"]
}
async def execute(
self,
action: str,
message: str = "",
mode: str | None = None,
every_seconds: int | None = None,
cron_expr: str | None = None,
tz: str | None = None,
at: str | None = None,
job_id: str | None = None,
**kwargs: Any
) -> str:
"""工具主入口:按 action 分发到具体处理函数。
注意:
- 这里不直接抛异常给上层;尽量返回可读错误字符串。
- 真正未捕获异常(如非法日期解析)会被 ToolRegistry 包装成 Error 文本。
"""
# add创建任务并立即持久化返回任务 ID。
if action == "add":
return self._add_job(message, mode, every_seconds, cron_expr, tz, at)
# list只读取并格式化输出不改状态。
elif action == "list":
return self._list_jobs()
# remove按 ID 删除任务并重置调度器。
elif action == "remove":
return self._remove_job(job_id)
# schema 已限制枚举,这里是兜底防御。
return f"Unknown action: {action}"
def _add_job(
self,
message: str,
mode: str | None,
every_seconds: int | None,
cron_expr: str | None,
tz: str | None,
at: str | None,
) -> str:
"""创建任务并写入 CronService。
参数优先级(互斥选择):
1. `every_seconds` -> 固定间隔任务
2. `cron_expr` -> cron 表达式任务
3. `at` -> 一次性任务(执行后自动删除)
"""
# message 是 add 的必填语义字段:没有内容就无法定义“要做什么”。
if not message:
return "Error: message is required for add"
# channel/chat_id 由 AgentLoop 注入;
# 若缺失,说明当前调用上下文不完整,无法保证结果回投目标正确。
if not self._channel or not self._chat_id:
return "Error: no session context (channel/chat_id)"
# 时区仅对 cron 表达式有意义;避免用户误把 tz 用在 every/at 上。
if tz and not cron_expr:
return "Error: tz can only be used with cron_expr"
# 尽早校验时区,提前给出明确错误,避免把非法数据写入存储。
if tz:
from zoneinfo import ZoneInfo
try:
ZoneInfo(tz)
except (KeyError, Exception):
return f"Error: unknown timezone '{tz}'"
# mode 缺省时默认按“提醒”处理:
# - 与 cron skill 的说明一致;
# - 避免把原始建任务指令再次送回 agent造成任务自复制。
normalized_mode = (mode or "reminder").strip().lower()
if normalized_mode not in {"reminder", "task"}:
return "Error: mode must be 'reminder' or 'task'"
payload_kind = "system_event" if normalized_mode == "reminder" else "agent_turn"
# 构建调度对象:
# - CronService 内部统一使用毫秒时间戳;
# - `at` 任务默认 delete_after_run=True执行一次后自动移除。
delete_after = False
if every_seconds:
schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000)
elif cron_expr:
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
elif at:
from datetime import datetime
# fromisoformat 解析失败会抛 ValueError
# 该异常会由 ToolRegistry 统一转换为错误字符串返回给模型。
dt = datetime.fromisoformat(at)
at_ms = int(dt.timestamp() * 1000)
schedule = CronSchedule(kind="at", at_ms=at_ms)
delete_after = True
else:
return "Error: either every_seconds, cron_expr, or at is required"
# 创建任务并持久化:
# - name 使用 message 前 30 字符做简短标题,便于列表展示;
# - deliver=True任务触发后默认向当前会话投递结果
# - channel/to 使用注入上下文,确保消息路由一致。
job = self._cron.add_job(
name=message[:30],
schedule=schedule,
message=message,
payload_kind=payload_kind,
session_key=self._session_key or None,
deliver=True,
channel=self._channel,
to=self._chat_id,
delete_after_run=delete_after,
)
# 返回简明确认文本,便于模型后续引用 job_id 做删除或说明。
return f"Created {normalized_mode} job '{job.name}' (id: {job.id})"
def _list_jobs(self) -> str:
"""列出当前可见任务(默认仅启用任务)。"""
jobs = self._cron.list_jobs()
if not jobs:
return "No scheduled jobs."
# 输出格式保持轻量,避免把过多状态塞给模型。
# 详细状态next_run/last_error可在 CLI 的 `nanobot cron list` 查看。
lines = [f"- {j.name} (id: {j.id}, {j.schedule.kind})" for j in jobs]
return "Scheduled jobs:\n" + "\n".join(lines)
def _remove_job(self, job_id: str | None) -> str:
"""按 ID 删除任务。"""
if not job_id:
return "Error: job_id is required for remove"
# remove_job 返回 bool工具层负责转换成对话友好的文案。
if self._cron.remove_job(job_id):
return f"Removed job {job_id}"
return f"Job {job_id} not found"

View File

@ -0,0 +1,116 @@
"""结构化 cron 生命周期控制工具。
cron 任务不是普通用户对话,它经常需要在运行完成后主动告诉调度器:
- 这个任务已经可以删掉;
- 今天这一轮先结束,下一天再继续;
- 下次应该改成新的时间表。
这个工具就是让模型把这些决策显式写成结构化数据,而不是只留在自然语言里。
"""
from __future__ import annotations
from typing import Any
from nanobot.agent.tools.base import Tool
from nanobot.cron.types import CronAction
class CronActionTool(Tool):
"""捕获模型输出的机器可读 cron 控制决策。"""
def __init__(self, job_id: str):
# `job_id` 仅用于回显和审计,不参与决策本身。
self.job_id = job_id
# `_decision` 在本轮 agent 执行期间最多被写一次,外部在结束后读取。
self._decision: CronAction | None = None
@property
def name(self) -> str:
return "cron_action"
@property
def description(self) -> str:
return "Record a structured lifecycle action for the currently running cron job."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["none", "remove", "disable", "complete_today", "reschedule"],
"description": "Lifecycle action for the current cron job",
},
"reason": {
"type": "string",
"description": "Short reason for audit logs",
},
"every_seconds": {
"type": "integer",
"description": "Required when action=reschedule and using fixed interval",
},
"cron_expr": {
"type": "string",
"description": "Required when action=reschedule and using cron expression",
},
"tz": {
"type": "string",
"description": "Optional timezone for cron_expr reschedules",
},
"at": {
"type": "string",
"description": "Required when action=reschedule and using one-time ISO datetime",
},
},
"required": ["action"],
}
@property
def decision(self) -> CronAction | None:
# 暴露最终结构化决策给 cron runtime便于后处理调度状态。
return self._decision
async def execute(
self,
action: str,
reason: str | None = None,
every_seconds: int | None = None,
cron_expr: str | None = None,
tz: str | None = None,
at: str | None = None,
**_kwargs: Any,
) -> str:
# 统一做小写规范化,避免模型传入 `Remove` / `REMOVE` 之类大小写变体。
normalized = (action or "").strip().lower()
allowed_actions = {"none", "remove", "disable", "complete_today", "reschedule"}
if normalized not in allowed_actions:
return f"Error: unsupported cron action '{action}'"
# 非重排任务不允许额外携带调度字段,避免出现“说 remove 但又传 cron_expr”的脏数据。
if normalized != "reschedule" and any(value is not None for value in (every_seconds, cron_expr, tz, at)):
return "Error: schedule fields can only be used when action='reschedule'"
if normalized == "reschedule":
# 重新排期必须在三种时间表达方式里三选一,不能都不传,也不能混传。
options = int(every_seconds is not None) + int(bool(cron_expr)) + int(bool(at))
if options != 1:
return "Error: reschedule requires exactly one of every_seconds, cron_expr, or at"
# 时区只有 cron 表达式才有意义。
if tz and not cron_expr:
return "Error: tz can only be used with cron_expr"
# 校验通过后,把本轮决策固化为 dataclass交给 runtime 在执行后统一消费。
self._decision = CronAction(
action=normalized or "none",
reason=(reason or "").strip() or None,
every_seconds=every_seconds,
cron_expr=cron_expr,
tz=tz,
at=at,
)
# 返回给模型/日志的是一条可读确认文本,方便工具调用结果出现在上下文里。
detail = f" for job {self.job_id}"
if self._decision.reason:
detail += f" ({self._decision.reason})"
return f"Recorded cron_action={self._decision.action}{detail}"

View File

@ -0,0 +1,275 @@
"""File system tools: read, write, edit."""
import difflib
from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import Tool
def _resolve_path(path: str, workspace: Path | None = None, allowed_dir: Path | None = None) -> Path:
"""Resolve path against workspace (if relative) and enforce directory restriction."""
p = Path(path).expanduser()
if not p.is_absolute() and workspace:
p = workspace / p
resolved = p.resolve()
if allowed_dir:
try:
resolved.relative_to(allowed_dir.resolve())
except ValueError:
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
return resolved
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root.resolve())
return True
except ValueError:
return False
def _protected_write_error() -> str:
return (
"Error: Direct writes to workspace skills are blocked. "
"Stage the skill for review and require explicit user approval before installation."
)
class ReadFileTool(Tool):
"""Tool to read file contents."""
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
self._workspace = workspace
self._allowed_dir = allowed_dir
@property
def name(self) -> str:
return "read_file"
@property
def description(self) -> str:
return "Read the contents of a file at the given path."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path to read"
}
},
"required": ["path"]
}
async def execute(self, path: str, **kwargs: Any) -> str:
try:
file_path = _resolve_path(path, self._workspace, self._allowed_dir)
if not file_path.exists():
return f"Error: File not found: {path}"
if not file_path.is_file():
return f"Error: Not a file: {path}"
content = file_path.read_text(encoding="utf-8")
return content
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error reading file: {str(e)}"
class WriteFileTool(Tool):
"""Tool to write content to a file."""
def __init__(
self,
workspace: Path | None = None,
allowed_dir: Path | None = None,
protected_paths: list[Path] | None = None,
):
self._workspace = workspace
self._allowed_dir = allowed_dir
self._protected_paths = [p.expanduser().resolve() for p in protected_paths or []]
@property
def name(self) -> str:
return "write_file"
@property
def description(self) -> str:
return "Write content to a file at the given path. Creates parent directories if needed."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path to write to"
},
"content": {
"type": "string",
"description": "The content to write"
}
},
"required": ["path", "content"]
}
async def execute(self, path: str, content: str, **kwargs: Any) -> str:
try:
file_path = _resolve_path(path, self._workspace, self._allowed_dir)
if any(_is_relative_to(file_path, protected) for protected in self._protected_paths):
return _protected_write_error()
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return f"Successfully wrote {len(content)} bytes to {file_path}"
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error writing file: {str(e)}"
class EditFileTool(Tool):
"""Tool to edit a file by replacing text."""
def __init__(
self,
workspace: Path | None = None,
allowed_dir: Path | None = None,
protected_paths: list[Path] | None = None,
):
self._workspace = workspace
self._allowed_dir = allowed_dir
self._protected_paths = [p.expanduser().resolve() for p in protected_paths or []]
@property
def name(self) -> str:
return "edit_file"
@property
def description(self) -> str:
return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path to edit"
},
"old_text": {
"type": "string",
"description": "The exact text to find and replace"
},
"new_text": {
"type": "string",
"description": "The text to replace with"
}
},
"required": ["path", "old_text", "new_text"]
}
async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str:
try:
file_path = _resolve_path(path, self._workspace, self._allowed_dir)
if any(_is_relative_to(file_path, protected) for protected in self._protected_paths):
return _protected_write_error()
if not file_path.exists():
return f"Error: File not found: {path}"
content = file_path.read_text(encoding="utf-8")
if old_text not in content:
return self._not_found_message(old_text, content, path)
# Count occurrences
count = content.count(old_text)
if count > 1:
return f"Warning: old_text appears {count} times. Please provide more context to make it unique."
new_content = content.replace(old_text, new_text, 1)
file_path.write_text(new_content, encoding="utf-8")
return f"Successfully edited {file_path}"
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error editing file: {str(e)}"
@staticmethod
def _not_found_message(old_text: str, content: str, path: str) -> str:
"""Build a helpful error when old_text is not found."""
lines = content.splitlines(keepends=True)
old_lines = old_text.splitlines(keepends=True)
window = len(old_lines)
best_ratio, best_start = 0.0, 0
for i in range(max(1, len(lines) - window + 1)):
ratio = difflib.SequenceMatcher(None, old_lines, lines[i : i + window]).ratio()
if ratio > best_ratio:
best_ratio, best_start = ratio, i
if best_ratio > 0.5:
diff = "\n".join(difflib.unified_diff(
old_lines, lines[best_start : best_start + window],
fromfile="old_text (provided)", tofile=f"{path} (actual, line {best_start + 1})",
lineterm="",
))
return f"Error: old_text not found in {path}.\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
class ListDirTool(Tool):
"""Tool to list directory contents."""
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
self._workspace = workspace
self._allowed_dir = allowed_dir
@property
def name(self) -> str:
return "list_dir"
@property
def description(self) -> str:
return "List the contents of a directory."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The directory path to list"
}
},
"required": ["path"]
}
async def execute(self, path: str, **kwargs: Any) -> str:
try:
dir_path = _resolve_path(path, self._workspace, self._allowed_dir)
if not dir_path.exists():
return f"Error: Directory not found: {path}"
if not dir_path.is_dir():
return f"Error: Not a directory: {path}"
items = []
for item in sorted(dir_path.iterdir()):
prefix = "📁 " if item.is_dir() else "📄 "
items.append(f"{prefix}{item.name}")
if not items:
return f"Directory {path} is empty"
return "\n".join(items)
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error listing directory: {str(e)}"

View File

@ -0,0 +1,382 @@
"""MCP 客户端封装。
职责分两层:
1. `connect_mcp_servers()` 负责建立与 MCP server 的连接,并把远端工具注册成 nanobot 本地工具;
2. `MCPToolWrapper` 负责把单个远端 MCP tool 包装成可供 LLM 调用的 `Tool`,同时发出结构化过程事件。
"""
import asyncio
import json
from collections.abc import Awaitable, Callable
from contextlib import AsyncExitStack
from typing import Any
import httpx
from loguru import logger
from nanobot.agent.process_events import current_process_run_id, emit_process_event, new_run_id
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
def _iter_leaf_exceptions(exc: BaseException) -> list[BaseException]:
if isinstance(exc, BaseExceptionGroup):
leaves: list[BaseException] = []
for sub_exc in exc.exceptions:
leaves.extend(_iter_leaf_exceptions(sub_exc))
return leaves
return [exc]
def _describe_mcp_exception(exc: BaseException, *, server_name: str, url: str | None = None) -> str:
leaves = _iter_leaf_exceptions(exc)
target = f" ({url})" if url else ""
for leaf in leaves:
if isinstance(leaf, httpx.TimeoutException):
return f"MCP server '{server_name}' timed out while waiting for a response{target}"
if isinstance(leaf, httpx.ConnectError):
return f"MCP server '{server_name}' is unreachable{target}"
if isinstance(leaf, httpx.HTTPStatusError):
return f"MCP server '{server_name}' returned HTTP {leaf.response.status_code}{target}"
if isinstance(leaf, httpx.HTTPError):
detail = str(leaf).strip() or leaf.__class__.__name__
return f"MCP server '{server_name}' HTTP error{target}: {detail}"
detail_source = leaves[0] if leaves else exc
detail = str(detail_source).strip() or detail_source.__class__.__name__
if isinstance(exc, BaseExceptionGroup):
return f"MCP server '{server_name}' failed: {detail_source.__class__.__name__}: {detail}"
return detail
class MCPToolWrapper(Tool):
"""把单个 MCP server tool 包装成 nanobot Tool。"""
def __init__(
self,
session,
server_name: str,
tool_def,
*,
call_tool: Callable[[str, dict[str, Any]], Awaitable[Any]] | None = None,
tool_timeout: int = 30,
sensitive: bool = False,
):
self._session = session
self._call_tool = call_tool or self._default_call_tool
# 记录来源服务名,便于日志、事件流和最终导出的工具名保持可追踪。
self._server_name = server_name
self._original_name = tool_def.name
# 在 nanobot 内部为 MCP 工具统一加 `mcp_<server>_` 前缀,避免同名冲突。
self._name = f"mcp_{server_name}_{tool_def.name}"
self._description = tool_def.description or tool_def.name
self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
self._tool_timeout = tool_timeout
self._sensitive = sensitive
@property
def name(self) -> str:
return self._name
@property
def description(self) -> str:
return self._description
@property
def parameters(self) -> dict[str, Any]:
return self._parameters
async def execute(self, **kwargs: Any) -> str:
from mcp import types
# 每次 MCP 调用都分配独立 run_id前端可以把它显示成树状子步骤。
run_id = new_run_id("mcp")
args_json = json.dumps(kwargs, ensure_ascii=False) if kwargs else "{}"
await emit_process_event(
"process_run_started",
run_id=run_id,
parent_run_id=current_process_run_id(),
actor_type="mcp",
actor_id=self._server_name,
actor_name=self._server_name,
title=f"{self._server_name}.{self._original_name}",
status="running",
metadata={
"tool_name": self._original_name,
"tool_args": None if self._sensitive else kwargs,
"tool_timeout": self._tool_timeout,
"sensitive": self._sensitive,
},
)
# 在真正请求远端前先发一条 progress方便 UI 及时显示“正在调用哪个工具”。
await emit_process_event(
"process_run_progress",
run_id=run_id,
parent_run_id=current_process_run_id(),
actor_type="mcp",
actor_id=self._server_name,
actor_name=self._server_name,
text=(
f"Calling {self._original_name}"
if self._sensitive
else f"Calling {self._original_name} with {args_json}"
),
metadata={"tool_name": self._original_name, "sensitive": self._sensitive},
)
try:
result = await asyncio.wait_for(
self._call_tool(self._original_name, kwargs),
timeout=self._tool_timeout,
)
except asyncio.TimeoutError:
# 超时被视为业务失败,但不抛异常给上层 agent 循环,而是返回可读错误文本。
logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout)
summary = f"(MCP tool call timed out after {self._tool_timeout}s)"
await emit_process_event(
"process_run_status",
run_id=run_id,
actor_type="mcp",
actor_id=self._server_name,
actor_name=self._server_name,
status="error",
text=summary,
metadata={"tool_name": self._original_name, "sensitive": self._sensitive},
)
await emit_process_event(
"process_run_finished",
run_id=run_id,
actor_type="mcp",
actor_id=self._server_name,
actor_name=self._server_name,
status="error",
summary=summary,
metadata={"tool_name": self._original_name, "sensitive": self._sensitive},
)
return summary
# MCP SDK 返回的是结构化 content block 列表,这里统一摊平成文本。
parts = []
for block in result.content:
if isinstance(block, types.TextContent):
parts.append(block.text)
else:
parts.append(str(block))
output = "\n".join(parts) or "(no output)"
artifact_type = "text"
artifact_data: Any | None = None
stripped = output.strip()
# 如果看起来像 JSON则额外解析成结构化 artifact方便前端做更丰富展示。
if stripped.startswith("{") or stripped.startswith("["):
try:
artifact_data = json.loads(stripped)
artifact_type = "json"
except json.JSONDecodeError:
artifact_data = None
await emit_process_event(
"process_run_artifact",
run_id=run_id,
actor_type="mcp",
actor_id=self._server_name,
actor_name=self._server_name,
title=f"{self._server_name}.{self._original_name} result",
artifact_type="redacted" if self._sensitive else artifact_type,
content=None if self._sensitive or artifact_data is not None else output,
data=None if self._sensitive else artifact_data,
metadata={"tool_name": self._original_name, "sensitive": self._sensitive},
)
await emit_process_event(
"process_run_finished",
run_id=run_id,
actor_type="mcp",
actor_id=self._server_name,
actor_name=self._server_name,
status="done",
summary=(
f"{self._original_name} completed"
if self._sensitive
else output[:1000]
),
metadata={"tool_name": self._original_name, "sensitive": self._sensitive},
)
return output
async def _default_call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
return await self._session.call_tool(tool_name, arguments=arguments)
async def connect_mcp_servers(
mcp_servers: dict,
registry: ToolRegistry,
stack: AsyncExitStack,
*,
authz_config: Any | None = None,
backend_identity: Any | None = None,
) -> dict[str, dict[str, Any]]:
"""连接所有配置中的 MCP server并把工具注册到 registry。"""
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamable_http_client
from nanobot.authz.client import AuthzClient
async def _build_http_headers(server_name: str, cfg: Any) -> dict[str, str]:
headers = dict(getattr(cfg, "headers", {}) or {})
if getattr(cfg, "auth_mode", "none") != "oauth_backend_token":
return headers
if not (
authz_config
and getattr(authz_config, "base_url", "").strip()
and backend_identity
and getattr(backend_identity, "client_id", "").strip()
and getattr(backend_identity, "client_secret", "").strip()
):
raise RuntimeError(
f"MCP server '{server_name}' requires AuthZ backend token, but authz/backend identity is incomplete"
)
authz_client = AuthzClient(
getattr(authz_config, "base_url"),
timeout_seconds=int(getattr(authz_config, "request_timeout_seconds", 10)),
)
raw_audience = str(getattr(cfg, "auth_audience", "") or "").strip()
# Older managed Outlook configs stored `auth_audience="mcp"`, but AuthZ
# permissions are issued against `mcp:<server_id>`.
if not raw_audience or raw_audience == "mcp":
audience = f"mcp:{server_name}"
elif raw_audience.startswith("mcp:"):
audience = raw_audience
else:
audience = f"mcp:{raw_audience}"
token_response = await authz_client.issue_token(
client_id=getattr(backend_identity, "client_id"),
client_secret=getattr(backend_identity, "client_secret"),
audience=audience,
scopes=[str(item) for item in list(getattr(cfg, "auth_scopes", []) or [])],
)
access_token = str(token_response.get("access_token") or "").strip()
if not access_token:
raise RuntimeError(f"MCP server '{server_name}' did not receive an access token from AuthZ")
headers["Authorization"] = f"Bearer {access_token}"
return headers
async def _open_http_session(
session_stack: AsyncExitStack,
cfg: Any,
*,
headers: dict[str, str],
):
http_client = await session_stack.enter_async_context(
httpx.AsyncClient(
headers=headers or None,
follow_redirects=True,
trust_env=False,
)
)
read, write, _ = await session_stack.enter_async_context(
streamable_http_client(cfg.url, http_client=http_client)
)
session = await session_stack.enter_async_context(ClientSession(read, write))
await session.initialize()
return session
async def _list_http_tools(server_name: str, cfg: Any):
async with AsyncExitStack() as session_stack:
headers = await _build_http_headers(server_name, cfg)
session = await _open_http_session(session_stack, cfg, headers=headers)
tools = await session.list_tools()
return tools.tools
def _make_http_call_tool(server_name: str, cfg: Any) -> Callable[[str, dict[str, Any]], Awaitable[Any]]:
async def _call_tool(tool_name: str, arguments: dict[str, Any]) -> Any:
async with AsyncExitStack() as session_stack:
headers = await _build_http_headers(server_name, cfg)
session = await _open_http_session(session_stack, cfg, headers=headers)
return await session.call_tool(tool_name, arguments=arguments)
return _call_tool
# `report` 会返回给调用方,用于 Web UI 展示连接状态和已发现工具。
report: dict[str, dict[str, Any]] = {}
for name, cfg in mcp_servers.items():
report[name] = {
"status": "disconnected",
"last_error": None,
"tool_names": [],
"tool_count": 0,
"transport": "stdio" if getattr(cfg, "command", "") else "http",
}
try:
if cfg.command:
# stdio 模式:本地拉起一个子进程,通过 stdin/stdout 与 MCP server 通信。
params = StdioServerParameters(
command=cfg.command, args=cfg.args, env=cfg.env or None
)
read, write = await stack.enter_async_context(stdio_client(params))
session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize()
tools = await session.list_tools()
for tool_def in tools.tools:
wrapper = MCPToolWrapper(
session,
name,
tool_def,
tool_timeout=cfg.tool_timeout,
sensitive=bool(getattr(cfg, "sensitive", False)),
)
registry.register(wrapper)
logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
report[name]["tool_names"].append(wrapper.name)
elif cfg.url:
if getattr(cfg, "auth_mode", "none") == "oauth_backend_token":
tools_defs = await _list_http_tools(name, cfg)
call_tool = _make_http_call_tool(name, cfg)
for tool_def in tools_defs:
wrapper = MCPToolWrapper(
None,
name,
tool_def,
call_tool=call_tool,
tool_timeout=cfg.tool_timeout,
sensitive=bool(getattr(cfg, "sensitive", False)),
)
registry.register(wrapper)
logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
report[name]["tool_names"].append(wrapper.name)
else:
headers = await _build_http_headers(name, cfg)
session = await _open_http_session(stack, cfg, headers=headers)
tools = await session.list_tools()
for tool_def in tools.tools:
wrapper = MCPToolWrapper(
session,
name,
tool_def,
tool_timeout=cfg.tool_timeout,
sensitive=bool(getattr(cfg, "sensitive", False)),
)
registry.register(wrapper)
logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
report[name]["tool_names"].append(wrapper.name)
else:
# 没有 command 也没有 url 的条目视为无效配置,跳过但不抛异常。
logger.warning("MCP server '{}': no command or url configured, skipping", name)
continue
report[name]["tool_count"] = len(report[name]["tool_names"])
report[name]["status"] = "connected"
logger.info(
"MCP server '{}': connected, {} tools registered",
name,
len(report[name]["tool_names"]),
)
except Exception as e:
# 单个 server 失败不影响其他 server 继续连;错误写进 report 供 UI 展示。
error_detail = _describe_mcp_exception(
e,
server_name=name,
url=str(getattr(cfg, "url", "") or "").strip() or None,
)
report[name]["status"] = "error"
report[name]["last_error"] = error_detail
logger.error("MCP server '{}': failed to connect: {}", name, error_detail)
return report

View File

@ -0,0 +1,108 @@
"""Message tool for sending messages to users."""
from typing import Any, Awaitable, Callable
from nanobot.agent.tools.base import Tool
from nanobot.bus.events import OutboundMessage
class MessageTool(Tool):
"""Tool to send messages to users on chat channels."""
def __init__(
self,
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
default_channel: str = "",
default_chat_id: str = "",
default_message_id: str | None = None,
):
self._send_callback = send_callback
self._default_channel = default_channel
self._default_chat_id = default_chat_id
self._default_message_id = default_message_id
self._sent_in_turn: bool = False
def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
"""Set the current message context."""
self._default_channel = channel
self._default_chat_id = chat_id
self._default_message_id = message_id
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
"""Set the callback for sending messages."""
self._send_callback = callback
def start_turn(self) -> None:
"""Reset per-turn send tracking."""
self._sent_in_turn = False
@property
def name(self) -> str:
return "message"
@property
def description(self) -> str:
return "Send a message to the user. Use this when you want to communicate something."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The message content to send"
},
"channel": {
"type": "string",
"description": "Optional: target channel (telegram, discord, etc.)"
},
"chat_id": {
"type": "string",
"description": "Optional: target chat/user ID"
},
"media": {
"type": "array",
"items": {"type": "string"},
"description": "Optional: list of file paths to attach (images, audio, documents)"
}
},
"required": ["content"]
}
async def execute(
self,
content: str,
channel: str | None = None,
chat_id: str | None = None,
message_id: str | None = None,
media: list[str] | None = None,
**kwargs: Any
) -> str:
channel = channel or self._default_channel
chat_id = chat_id or self._default_chat_id
message_id = message_id or self._default_message_id
if not channel or not chat_id:
return "Error: No target channel/chat specified"
if not self._send_callback:
return "Error: Message sending not configured"
msg = OutboundMessage(
channel=channel,
chat_id=chat_id,
content=content,
media=media or [],
metadata={
"message_id": message_id,
}
)
try:
await self._send_callback(msg)
self._sent_in_turn = True
media_info = f" with {len(media)} attachments" if media else ""
return f"Message sent to {channel}:{chat_id}{media_info}"
except Exception as e:
return f"Error sending message: {str(e)}"

View File

@ -0,0 +1,96 @@
"""工具注册中心。
职责很单一:
1. 保存当前可用工具实例;
2. 向 LLM 暴露 function schema
3. 在执行前做基础参数校验,并把异常统一转成文本结果。
"""
from typing import Any
from nanobot.agent.tools.base import Tool
class ToolRegistry:
"""
Registry for agent tools.
Allows dynamic registration and execution of tools.
"""
def __init__(self):
# 工具名到实例的映射表;工具名在整个 registry 内必须唯一。
self._tools: dict[str, Tool] = {}
def register(self, tool: Tool) -> None:
"""注册一个工具实例。"""
self._tools[tool.name] = tool
def clone(self) -> "ToolRegistry":
"""创建一个浅拷贝,复用同一批工具实例。"""
# 这里不深拷贝工具对象,因为很多工具本身持有运行时状态或外部连接。
# 当前需求只是“在一个请求里临时附加额外工具”,复用实例即可。
other = ToolRegistry()
other._tools = dict(self._tools)
return other
def unregister(self, name: str) -> None:
"""Unregister a tool by name."""
self._tools.pop(name, None)
def get(self, name: str) -> Tool | None:
"""Get a tool by name."""
return self._tools.get(name)
def has(self, name: str) -> bool:
"""Check if a tool is registered."""
return name in self._tools
def get_definitions(self) -> list[dict[str, Any]]:
"""Get all tool definitions in OpenAI format."""
return [tool.to_schema() for tool in self._tools.values()]
async def execute(self, name: str, params: dict[str, Any]) -> str:
"""
Execute a tool by name with given parameters.
Args:
name: Tool name.
params: Tool parameters.
Returns:
Tool execution result as string.
Raises:
KeyError: If tool not found.
"""
_hint = "\n\n[Analyze the error above and try a different approach.]"
tool = self._tools.get(name)
if not tool:
return f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}"
try:
# schema 级参数校验放在真正调用前做,尽量把错误反馈成模型能自修复的文本。
errors = tool.validate_params(params)
if errors:
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) + _hint
result = await tool.execute(**params)
# 约定:工具若返回以 Error 开头的文本,说明是业务失败而非程序崩溃。
if isinstance(result, str) and result.startswith("Error"):
return result + _hint
return result
except Exception as e:
# 保持“不抛异常到模型层”的接口语义,统一回成可读文本。
return f"Error executing {name}: {str(e)}" + _hint
@property
def tool_names(self) -> list[str]:
"""Get list of registered tool names."""
return list(self._tools.keys())
def __len__(self) -> int:
return len(self._tools)
def __contains__(self, name: str) -> bool:
return name in self._tools

View File

@ -0,0 +1,284 @@
"""Shell execution tool."""
import asyncio
import os
import re
import shlex
from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import Tool
class ExecTool(Tool):
"""Tool to execute shell commands."""
def __init__(
self,
timeout: int = 60,
working_dir: str | None = None,
deny_patterns: list[str] | None = None,
allow_patterns: list[str] | None = None,
restrict_to_workspace: bool = False,
protected_paths: list[Path] | None = None,
):
self.timeout = timeout
self.working_dir = working_dir
self.deny_patterns = deny_patterns or [
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
r"\bdel\s+/[fq]\b", # del /f, del /q
r"\brmdir\s+/s\b", # rmdir /s
r"(?:^|[;&|]\s*)format\b", # format (as standalone command only)
r"\b(mkfs|diskpart)\b", # disk operations
r"\bdd\s+if=", # dd
r">\s*/dev/sd", # write to disk
r"\b(shutdown|reboot|poweroff)\b", # system power
r":\(\)\s*\{.*\};\s*:", # fork bomb
]
self.allow_patterns = allow_patterns or []
self.restrict_to_workspace = restrict_to_workspace
self.protected_paths = [Path(p).expanduser().resolve() for p in protected_paths or []]
@property
def name(self) -> str:
return "exec"
@property
def description(self) -> str:
return "Execute a shell command and return its output. Use with caution."
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute"
},
"working_dir": {
"type": "string",
"description": "Optional working directory for the command"
}
},
"required": ["command"]
}
async def execute(self, command: str, working_dir: str | None = None, **kwargs: Any) -> str:
cwd = working_dir or self.working_dir or os.getcwd()
guard_error = self._guard_command(command, cwd)
if guard_error:
return guard_error
try:
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=self.timeout
)
except asyncio.TimeoutError:
process.kill()
# Wait for the process to fully terminate so pipes are
# drained and file descriptors are released.
try:
await asyncio.wait_for(process.wait(), timeout=5.0)
except asyncio.TimeoutError:
pass
return f"Error: Command timed out after {self.timeout} seconds"
output_parts = []
if stdout:
output_parts.append(stdout.decode("utf-8", errors="replace"))
if stderr:
stderr_text = stderr.decode("utf-8", errors="replace")
if stderr_text.strip():
output_parts.append(f"STDERR:\n{stderr_text}")
if process.returncode != 0:
output_parts.append(f"\nExit code: {process.returncode}")
result = "\n".join(output_parts) if output_parts else "(no output)"
# Truncate very long output
max_len = 10000
if len(result) > max_len:
result = result[:max_len] + f"\n... (truncated, {len(result) - max_len} more chars)"
return result
except Exception as e:
return f"Error executing command: {str(e)}"
def _guard_command(self, command: str, cwd: str) -> str | None:
"""Best-effort safety guard for potentially destructive commands."""
cmd = command.strip()
lower = cmd.lower()
for pattern in self.deny_patterns:
if re.search(pattern, lower):
return "Error: Command blocked by safety guard (dangerous pattern detected)"
if self.allow_patterns:
if not any(re.search(p, lower) for p in self.allow_patterns):
return "Error: Command blocked by safety guard (not in allowlist)"
if self.restrict_to_workspace:
if "..\\" in cmd or "../" in cmd:
return "Error: Command blocked by safety guard (path traversal detected)"
cwd_path = Path(cwd).resolve()
win_paths = re.findall(r"[A-Za-z]:\\[^\\\"']+", cmd)
# Only match absolute paths — avoid false positives on relative
# paths like ".venv/bin/python" where "/bin/python" would be
# incorrectly extracted by the old pattern.
posix_paths = re.findall(r"(?:^|[\s|>])(/[^\s\"'>]+)", cmd)
for raw in win_paths + posix_paths:
try:
p = Path(raw.strip()).resolve()
except Exception:
continue
if p.is_absolute() and cwd_path not in p.parents and p != cwd_path:
return "Error: Command blocked by safety guard (path outside working dir)"
protected_error = self._guard_protected_paths(command, cwd)
if protected_error:
return protected_error
return None
def _guard_protected_paths(self, command: str, cwd: str) -> str | None:
if not self.protected_paths:
return None
cwd_path = Path(cwd).expanduser().resolve()
if self._is_blocked_clawhub_install(command, cwd_path):
return self._protected_write_error()
if not self._looks_like_write(command):
return None
for raw in self._extract_path_tokens(command):
resolved = self._resolve_command_path(raw, cwd_path)
if resolved and any(self._is_relative_to(resolved, root) for root in self.protected_paths):
return self._protected_write_error()
return None
def _is_blocked_clawhub_install(self, command: str, cwd_path: Path) -> bool:
lower = command.lower()
if "clawhub" not in lower or not re.search(r"\b(install|update)\b", lower):
return False
workdir = self._extract_flag_value(command, "--workdir")
if workdir:
resolved = self._resolve_command_path(workdir, cwd_path)
return any(
resolved == root.parent or self._is_relative_to(root, resolved)
for root in self.protected_paths
)
return any(cwd_path == root.parent for root in self.protected_paths)
@staticmethod
def _protected_write_error() -> str:
return (
"Error: Direct writes to workspace skills are blocked. "
"Stage the skill for review and require explicit user approval before installation."
)
@staticmethod
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
@staticmethod
def _extract_flag_value(command: str, flag: str) -> str | None:
tokens = ExecTool._tokenize(command)
for i, token in enumerate(tokens):
if token == flag and i + 1 < len(tokens):
return tokens[i + 1]
if token.startswith(flag + "="):
return token.split("=", 1)[1]
return None
@staticmethod
def _looks_like_write(command: str) -> bool:
lower = command.lower()
if re.search(r"(^|[^<])>>?\s*\S+", command):
return True
if re.search(r"\bsed\s+-i(?:\s|$)", lower):
return True
return bool(re.search(
r"\b(cp|mv|rm|mkdir|touch|install|tee|tar|unzip|zip|chmod|chown|git|python|python3|node|npx|bash|sh|zsh|pwsh|powershell)\b",
lower,
))
@staticmethod
def _extract_path_tokens(command: str) -> list[str]:
tokens = ExecTool._tokenize(command)
path_tokens: list[str] = []
skip_next = False
for i, token in enumerate(tokens):
if skip_next:
skip_next = False
continue
if token in {"--workdir", "-C"}:
if i + 1 < len(tokens):
path_tokens.append(tokens[i + 1])
skip_next = True
continue
if "=" in token:
key, value = token.split("=", 1)
if key in {"--workdir"}:
path_tokens.append(value)
continue
cleaned = token.strip("\"'")
if ExecTool._looks_like_path_token(cleaned):
path_tokens.append(cleaned)
return path_tokens
@staticmethod
def _looks_like_path_token(token: str) -> bool:
if not token or token in {".", ".."}:
return True
if token.startswith(("~", "/", "./", "../")):
return True
if re.match(r"^[A-Za-z]:\\", token):
return True
return "/" in token or "\\" in token
@staticmethod
def _resolve_command_path(raw: str, cwd_path: Path) -> Path | None:
token = raw.strip().strip("\"'")
if not token:
return None
try:
path = Path(token).expanduser()
if not path.is_absolute():
path = (cwd_path / path).resolve()
else:
path = path.resolve()
return path
except Exception:
return None
@staticmethod
def _tokenize(command: str) -> list[str]:
try:
return shlex.split(command, posix=os.name != "nt")
except ValueError:
return command.split()

View File

@ -0,0 +1,204 @@
"""委派工具:分别暴露 subagent 与 agent team 两种调用接口。"""
from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool
if TYPE_CHECKING:
from nanobot.agent.delegation import DelegationManager
class DelegationTool(Tool):
"""委派类工具的公共上下文注入逻辑。"""
def __init__(self, manager: "DelegationManager"):
self._manager = manager
self._origin_channel = "cli"
self._origin_chat_id = "direct"
self._announce_via_bus = True
def set_context(self, channel: str, chat_id: str, announce_via_bus: bool = True) -> None:
"""设置后台委派结果回传的目标会话。"""
self._origin_channel = channel
self._origin_chat_id = chat_id
self._announce_via_bus = announce_via_bus
class SpawnSubagentTool(DelegationTool):
"""把任务委派给单个 subagent。"""
@property
def name(self) -> str:
return "spawn_subagent"
@property
def description(self) -> str:
return (
"Delegate a focused task to one background subagent. "
"Use this for complex or time-consuming work that can run independently. "
"You only provide the task and optional required skills; downstream routing decides the concrete agent. "
"The subagent will report back when done."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"task": {
"type": "string",
"description": "The task for the delegated subagent to complete",
},
"label": {
"type": "string",
"description": "Optional short label for the task (for display)",
},
"skills": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of skill names the delegated worker must follow",
},
},
"required": ["task"],
}
async def execute(
self,
task: str,
label: str | None = None,
skills: list[str] | None = None,
**kwargs: Any,
) -> str:
"""创建并启动一个 subagent 后台任务。"""
return await self._manager.dispatch_subagent(
task=task,
label=label,
skills=skills,
origin_channel=self._origin_channel,
origin_chat_id=self._origin_chat_id,
announce_via_bus=self._announce_via_bus,
)
class SpawnAgentTeamTool(DelegationTool):
"""启动一个 agent team 任务。"""
@property
def name(self) -> str:
return "spawn_agent_team"
@property
def description(self) -> str:
return (
"Start an agent team for parallel exploration. "
"Use this when multiple agents should investigate the task in parallel and return a combined result. "
"You only provide the task and optional required skills; downstream routing selects the concrete members."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"task": {
"type": "string",
"description": "The shared task for the agent team",
},
"label": {
"type": "string",
"description": "Optional short label for the team task (for display)",
},
"skills": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of skill names the team must follow",
},
},
"required": ["task"],
}
async def execute(
self,
task: str,
label: str | None = None,
skills: list[str] | None = None,
**kwargs: Any,
) -> str:
"""创建并启动一个 agent team 后台任务。"""
return await self._manager.dispatch_agent_team(
task=task,
label=label,
skills=skills,
origin_channel=self._origin_channel,
origin_chat_id=self._origin_chat_id,
announce_via_bus=self._announce_via_bus,
)
class NestedDelegateTool(Tool):
"""供 delegated worker 使用的受控下游委派工具。"""
def __init__(self, manager: "DelegationManager", default_skills: list[str] | None = None):
self._manager = manager
self._default_skills = [str(item).strip() for item in (default_skills or []) if str(item).strip()]
@property
def name(self) -> str:
return "delegate_task"
@property
def description(self) -> str:
return (
"Synchronously delegate a downstream task from a delegated worker. "
"Use this only when specialized help is needed. "
"It can route to an A2A agent or an ephemeral local subagent, but never creates a persistent subagent."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"task": {
"type": "string",
"description": "The downstream task to delegate",
},
"label": {
"type": "string",
"description": "Optional short label for the downstream task",
},
"target": {
"type": "string",
"description": "Optional agent ID or name for the downstream worker",
},
"strategy": {
"type": "string",
"enum": ["auto", "a2a", "ephemeral_subagent"],
"description": "Routing strategy for downstream delegation. Default is auto.",
},
"skills": {
"type": "array",
"items": {"type": "string"},
"description": "Optional required skills for the downstream delegate. Defaults to the current worker's required skills.",
},
},
"required": ["task"],
}
async def execute(
self,
task: str,
label: str | None = None,
target: str | None = None,
strategy: str = "auto",
skills: list[str] | None = None,
**kwargs: Any,
) -> str:
"""同步执行一次受控下游委派,并把结果返回给当前 worker。"""
return await self._manager.delegate_for_subagent(
task=task,
label=label,
target=target,
strategy=strategy,
skills=skills if skills is not None else list(self._default_skills),
)

View File

@ -0,0 +1,163 @@
"""Web tools: web_search and web_fetch."""
import html
import json
import os
import re
from typing import Any
from urllib.parse import urlparse
import httpx
from nanobot.agent.tools.base import Tool
# Shared constants
USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36"
MAX_REDIRECTS = 5 # Limit redirects to prevent DoS attacks
def _strip_tags(text: str) -> str:
"""Remove HTML tags and decode entities."""
text = re.sub(r'<script[\s\S]*?</script>', '', text, flags=re.I)
text = re.sub(r'<style[\s\S]*?</style>', '', text, flags=re.I)
text = re.sub(r'<[^>]+>', '', text)
return html.unescape(text).strip()
def _normalize(text: str) -> str:
"""Normalize whitespace."""
text = re.sub(r'[ \t]+', ' ', text)
return re.sub(r'\n{3,}', '\n\n', text).strip()
def _validate_url(url: str) -> tuple[bool, str]:
"""Validate URL: must be http(s) with valid domain."""
try:
p = urlparse(url)
if p.scheme not in ('http', 'https'):
return False, f"Only http/https allowed, got '{p.scheme or 'none'}'"
if not p.netloc:
return False, "Missing domain"
return True, ""
except Exception as e:
return False, str(e)
class WebSearchTool(Tool):
"""Search the web using Brave Search API."""
name = "web_search"
description = "Search the web. Returns titles, URLs, and snippets."
parameters = {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"count": {"type": "integer", "description": "Results (1-10)", "minimum": 1, "maximum": 10}
},
"required": ["query"]
}
def __init__(self, api_key: str | None = None, max_results: int = 5):
self.api_key = api_key or os.environ.get("BRAVE_API_KEY", "")
self.max_results = max_results
async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str:
if not self.api_key:
return "Error: BRAVE_API_KEY not configured"
try:
n = min(max(count or self.max_results, 1), 10)
async with httpx.AsyncClient() as client:
r = await client.get(
"https://api.search.brave.com/res/v1/web/search",
params={"q": query, "count": n},
headers={"Accept": "application/json", "X-Subscription-Token": self.api_key},
timeout=10.0
)
r.raise_for_status()
results = r.json().get("web", {}).get("results", [])
if not results:
return f"No results for: {query}"
lines = [f"Results for: {query}\n"]
for i, item in enumerate(results[:n], 1):
lines.append(f"{i}. {item.get('title', '')}\n {item.get('url', '')}")
if desc := item.get("description"):
lines.append(f" {desc}")
return "\n".join(lines)
except Exception as e:
return f"Error: {e}"
class WebFetchTool(Tool):
"""Fetch and extract content from a URL using Readability."""
name = "web_fetch"
description = "Fetch URL and extract readable content (HTML → markdown/text)."
parameters = {
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL to fetch"},
"extractMode": {"type": "string", "enum": ["markdown", "text"], "default": "markdown"},
"maxChars": {"type": "integer", "minimum": 100}
},
"required": ["url"]
}
def __init__(self, max_chars: int = 50000):
self.max_chars = max_chars
async def execute(self, url: str, extractMode: str = "markdown", maxChars: int | None = None, **kwargs: Any) -> str:
from readability import Document
max_chars = maxChars or self.max_chars
# Validate URL before fetching
is_valid, error_msg = _validate_url(url)
if not is_valid:
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
try:
async with httpx.AsyncClient(
follow_redirects=True,
max_redirects=MAX_REDIRECTS,
timeout=30.0
) as client:
r = await client.get(url, headers={"User-Agent": USER_AGENT})
r.raise_for_status()
ctype = r.headers.get("content-type", "")
# JSON
if "application/json" in ctype:
text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json"
# HTML
elif "text/html" in ctype or r.text[:256].lower().startswith(("<!doctype", "<html")):
doc = Document(r.text)
content = self._to_markdown(doc.summary()) if extractMode == "markdown" else _strip_tags(doc.summary())
text = f"# {doc.title()}\n\n{content}" if doc.title() else content
extractor = "readability"
else:
text, extractor = r.text, "raw"
truncated = len(text) > max_chars
if truncated:
text = text[:max_chars]
return json.dumps({"url": url, "finalUrl": str(r.url), "status": r.status_code,
"extractor": extractor, "truncated": truncated, "length": len(text), "text": text}, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
def _to_markdown(self, html: str) -> str:
"""Convert HTML to markdown."""
# Convert links, headings, lists before stripping tags
text = re.sub(r'<a\s+[^>]*href=["\']([^"\']+)["\'][^>]*>([\s\S]*?)</a>',
lambda m: f'[{_strip_tags(m[2])}]({m[1]})', html, flags=re.I)
text = re.sub(r'<h([1-6])[^>]*>([\s\S]*?)</h\1>',
lambda m: f'\n{"#" * int(m[1])} {_strip_tags(m[2])}\n', text, flags=re.I)
text = re.sub(r'<li[^>]*>([\s\S]*?)</li>', lambda m: f'\n- {_strip_tags(m[1])}', text, flags=re.I)
text = re.sub(r'</(p|div|section|article)>', '\n\n', text, flags=re.I)
text = re.sub(r'<(br|hr)\s*/?>', '\n', text, flags=re.I)
return _normalize(_strip_tags(text))